# Webhooks

> Signed events sent to your server, how to verify them, retries and secret rotation.

A webhook endpoint is an https address on your server. When something happens in your Workspace, NOWScale sends it a signed `POST` with the event. Add endpoints in **Settings > API keys > Webhooks**, or with a Workspace key through `POST /v1/webhook_endpoints`.

Each endpoint chooses its events (or all of them), can be limited to some Projects, and has its own signing secret that starts with `whsec_`. The URL must be public, use https on port 443, and answer within 15 seconds.

## The request

```http
POST /nowscale/webhooks HTTP/1.1
content-type: application/json
webhook-id: msg_2Lg8wVwqgQfM6s3E9hJcKx1Y
webhook-timestamp: 1764601200
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
  "id": "evt_hK4fUeZNPU0YyNcWPW12zrSa",
  "object": "event",
  "type": "post.published",
  "api_version": "2026-12-01",
  "created": 1764601199,
  "livemode": true,
  "workspace_id": "...",
  "project_id": "...",
  "data": { "object": { "object": "post", "id": "...", "status": "published", "published_at": "2026-12-01T15:00:04Z" } }
}
```

Events carry a small snapshot. Fetch the full object from the API when you need more.

Answer with any `2xx` quickly, then do the work. Use `webhook-id` to ignore duplicates: it is the same on every retry of the same delivery.

## Verify the signature

The signature follows [Standard Webhooks](https://www.standardwebhooks.com): an HMAC-SHA256 of `id.timestamp.body` with your secret, base64 encoded. Always verify it on the **raw** body, before parsing the JSON, and refuse old timestamps.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

// secret: "whsec_...", headers: the three webhook-* headers, body: the raw request body as a string.
export function verifyNowScaleWebhook(secret, headers, body, toleranceSeconds = 300) {
  const id = headers["webhook-id"];
  const timestamp = Number(headers["webhook-timestamp"]);
  if (!id || !Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
  return String(headers["webhook-signature"] ?? "").split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const given = Buffer.from(signature, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

```python
import base64, hashlib, hmac, time

def verify_nowscale_webhook(secret: str, headers: dict, body: str, tolerance: int = 300) -> bool:
    msg_id = headers.get("webhook-id", "")
    timestamp = int(headers.get("webhook-timestamp", "0"))
    if not msg_id or abs(time.time() - timestamp) > tolerance:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = hmac.new(key, f"{msg_id}.{timestamp}.{body}".encode(), hashlib.sha256).digest()
    for entry in headers.get("webhook-signature", "").split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(base64.b64decode(signature), expected):
            return True
    return False
```

Any Standard Webhooks library works too.

## Events

| Event | When |
| --- | --- |
| `post.created` | A post was created, in the app or through the API. |
| `post.published` | Every target of a post is live on its network. |
| `post.partially_published` | A post finished with some targets live and some failed. |
| `post.failed` | A post finished with every target failed. |
| `post.deleted` | A post was deleted from NOWScale. |
| `webhook_endpoint.test` | Sent only by the test button or POST /v1/webhook_endpoints/{id}/test. |

Post events carry the post's `id`, `project_id`, `status`, `scheduled_at` and `published_at`.

## Retries

If your endpoint does not answer `2xx` within 15 seconds, the delivery is retried:

After 1 minute, then 5 minutes, then 30 minutes, then 2 hours, then 6 hours, then 12 hours, then 24 hours, then 24 hours (each with up to 20% jitter): 9 attempts over about three days. A sandbox's endpoints retry after 1 minute, then 15 minutes, then 1.5 hours: 4 attempts over about two hours.

- `410 Gone` turns the endpoint off at once.
- An endpoint that keeps failing for 5 days is turned off. Turn it back on in Settings or with `PATCH` and `"enabled": true`.
- `GET /v1/webhook_endpoints/{endpoint_id}/deliveries` lists recent deliveries with their outcome, and `POST /v1/webhook_deliveries/{delivery_id}/retry` sends one again now.
- Events are also available for 30 days at `GET /v1/events`, so you can catch up after an outage.

## Test

**Send test event** in Settings, or `POST /v1/webhook_endpoints/{endpoint_id}/test`, sends a `webhook_endpoint.test` event to that endpoint only.

## Rotate the secret

Rolling a secret creates a new one. For the next 24 hours every delivery carries two signatures, one per secret, so you can deploy the new secret without dropping events (the verification above accepts either). In Settings, revealing or rolling a secret needs a passkey or an authenticator app.
