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
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: 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.
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);
});
}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 FalseAny 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 Goneturns the endpoint off at once.- An endpoint that keeps failing for 5 days is turned off. Turn it back on in Settings or with
PATCHand"enabled": true. GET /v1/webhook_endpoints/{endpoint_id}/deliverieslists recent deliveries with their outcome, andPOST /v1/webhook_deliveries/{delivery_id}/retrysends 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.