# Webhooks

Docs

Receive signed notifications when jobs end or credits run low: Standard Webhooks signatures, retries for days, delivery logs and secret rotation.

Instead of polling, register an HTTPS endpoint and SocialTap sends it an [event](https://staging.socialtap.dev/docs/events/index.md) when something happens: a job ends, your credits run low. Deliveries follow [Standard Webhooks](https://www.standardwebhooks.com/) exactly, so its verification libraries work too.

| Route | What it does | Key scope |
| --- | --- | --- |
| `POST /v1/webhook_endpoints` | Register a webhook endpoint | `admin` |
| `GET /v1/webhook_endpoints` | List webhook endpoints | `account:read` |
| `GET /v1/webhook_endpoints/{id}` | Get a webhook endpoint | `account:read` |
| `PATCH /v1/webhook_endpoints/{id}` | Update a webhook endpoint | `admin` |
| `DELETE /v1/webhook_endpoints/{id}` | Delete a webhook endpoint | `admin` |
| `POST /v1/webhook_endpoints/{id}/rotate_secret` | Rotate a webhook endpoint's signing secret | `admin` |
| `POST /v1/webhook_endpoints/{id}/test` | Send a webhook.test event to an endpoint | `admin` |
| `GET /v1/webhook_endpoints/{id}/deliveries` | List an endpoint's delivery attempts | `account:read` |

## Register an endpoint

`POST /v1/webhook_endpoints` takes the `url` and the `events` it receives: event types, a family such as `job.*`, or `*` for all. The answer is the only time you see its signing `secret` (`whsec_…`): store it with your other secrets. An account has at most 16 endpoints.

| Field | Type | Description |
| --- | --- | --- |
| `object` | `string` | Always webhook_endpoint.<br>One of `webhook_endpoint` |
| `id` | `string` | we_… |
| `url` | `string` | https URL deliveries are POSTed to. |
| `events` | `string[]` | Event types it receives: types, prefix wildcards (job.\*) or \* for all. |
| `description` | `string` or `null` |  |
| `status` | `string` | One of `enabled`, `disabled` |
| `disabled_reason` | `string` or `null` | Why it is disabled: gone (it answered 410), failing (deliveries failed for disable_after_failing_s), user. Null while enabled.<br>One of `gone`, `failing`, `user` |
| `secret?` | `string` or `null` | The signing secret (whsec_…): only in the create and rotate_secret answers. Store it. |
| `previous_secret_expires_at` | `string` (date-time) or `null` | Until then the previous secret signs too; null when none. |
| `created_at` | `string` (date-time) |  |
| `updated_at` | `string` (date-time) |  |

The URL must use `https`, on port 443 or a port above 1023, without credentials, and its host must resolve only to public addresses: private, loopback, link-local and cloud metadata addresses are refused when you register it and checked again before every delivery. Redirects are not followed: a `3xx` answer is a failed delivery, so register the final URL.

A job can also notify one of these endpoints that its subscriptions don't cover: set the job's `webhook_url` to the endpoint's URL.

## What a delivery looks like

Each delivery is a `POST` of the event as JSON, with these headers:

| Header | Type | Description |
| --- | --- | --- |
| `webhook-id` | `string` | The event ID (evt_…), the same on every retry: deduplicate deliveries on it. |
| `webhook-timestamp` | `int` | When this attempt was signed, in Unix seconds: reject deliveries outside your tolerance (replays). |
| `webhook-signature` | `string` | Space-separated v1,SIGNATURE entries, each the base64 HMAC-SHA256 of webhook-id, webhook-timestamp and the raw body joined by dots, with one active secret (two during a rotation): accept the delivery when one matches. |

The body is small, a summary and links, never a job's results: fetch those from the links when you need them.

## Verify every delivery

Compute the HMAC-SHA256 of `webhook-id.webhook-timestamp.<raw body>` with the secret's bytes (the base64 after `whsec_`), and accept the delivery only when one of the `v1,<signature>` entries of `webhook-signature` matches, compared in constant time, and the timestamp is within 5 minutes of your clock. Use the raw body as it arrived: parsing and re-serializing JSON changes the bytes.

TypeScript:

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

/** The verified event, or null: pass the RAW body, before any JSON parsing. */
export function verify(rawBody: string, headers: Record<string, string>, secret = process.env.SOCIALTAP_WEBHOOK_SECRET!): unknown {
  const id = headers["webhook-id"], ts = headers["webhook-timestamp"], sigs = headers["webhook-signature"];
  if (!id || !ts || !sigs || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return null;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const want = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
  const ok = sigs.split(" ").some((s) => {
    const [version, b64] = s.split(",", 2);
    const got = Buffer.from(b64 ?? "", "base64");
    return version === "v1" && got.length === want.length && timingSafeEqual(got, want);
  });
  return ok ? JSON.parse(rawBody) : null; // deduplicate on its id
}
```

Python:

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

def verify(raw_body: bytes, headers: dict, secret: str = os.environ["SOCIALTAP_WEBHOOK_SECRET"]):
    """The verified event, or None: pass the RAW body, before any JSON parsing."""
    msg_id, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
    if not msg_id or not ts or not sigs or abs(time.time() - int(ts)) > 300:
        return None
    key = base64.b64decode(secret.removeprefix("whsec_"))
    want = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
    for part in sigs.split(" "):
        version, _, got = part.partition(",")
        if version == "v1" and hmac.compare_digest(got, want):
            return json.loads(raw_body)  # deduplicate on its id
    return None
```

Go:

```go
// Verify returns the raw event when one signature matches: pass the RAW body.
func Verify(body []byte, h http.Header, secret string) ([]byte, bool) {
	id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
	t, err := strconv.ParseInt(ts, 10, 64)
	if id == "" || sigs == "" || err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 {
		return nil, false
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return nil, false
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "."))
	mac.Write(body)
	want := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	for _, part := range strings.Split(sigs, " ") {
		if version, got, ok := strings.Cut(part, ","); ok && version == "v1" && hmac.Equal([]byte(got), []byte(want)) {
			return body, true // deduplicate on the event's id
		}
	}
	return nil, false
}
```

The SDKs ship the same check (`verifyWebhook` in TypeScript, `verify_webhook` in Python, `VerifyWebhook` in Go), and any Standard Webhooks library verifies these deliveries.

Answer with any `2xx` quickly (within 15 seconds) and do the work afterwards. Deliveries can repeat and arrive out of order: deduplicate on the event ID (`webhook-id`), the same on every retry.

## Retries

A delivery that doesn't get a `2xx` within 15 seconds is retried on this schedule, each delay varied by a little so retries don't arrive in lockstep:

| Attempt | After the previous one | Since the event |
| --- | --- | --- |
| 1 | immediately | 0 |
| 2 | 5 seconds | about 5 seconds |
| 3 | 5 minutes | about 305 seconds |
| 4 | 30 minutes | about 2105 seconds |
| 5 | 2 hours | about 155 minutes |
| 6 | 5 hours | about 455 minutes |
| 7 | 10 hours | about 1055 minutes |
| 8 | 14 hours | about 32 hours |
| 9 | 20 hours | about 52 hours |
| 10 | 1 day | about 76 hours |

On `429`, `502` and `504`, a longer `retry-after` from your server is respected. A `410 Gone` answer disables the endpoint at once; an endpoint whose deliveries keep failing for 5 days is disabled too. A disabled endpoint's `disabled_reason` says why; set its `status` back to `enabled` with `PATCH /v1/webhook_endpoints/{id}` once it works.

## Delivery logs and redelivery

`GET /v1/webhook_endpoints/{id}/deliveries` lists every delivery to an endpoint with its last status code, duration, the start of your server's answer and when the next attempt is due:

| Field | Type | Description |
| --- | --- | --- |
| `object` | `string` | Always webhook_delivery.<br>One of `webhook_delivery` |
| `id` | `string` | whd_… |
| `event_id` | `string` |  |
| `event_type` | `string` |  |
| `webhook_endpoint_id` | `string` |  |
| `status` | `string` | pending: an attempt is due (next_attempt_at). failed: every attempt failed, or the endpoint was disabled or deleted.<br>One of `pending`, `succeeded`, `failed` |
| `attempts` | `integer` | Attempts made so far. |
| `status_code` | `integer` or `null` | The last attempt's HTTP status; null when it got none (timeout, refused, blocked URL). |
| `duration_ms` | `integer` or `null` | The last attempt's duration. |
| `response_excerpt` | `string` or `null` | The start of the last response body. |
| `error` | `string` or `null` | Why the last attempt failed when there was no response. |
| `created_at` | `string` (date-time) |  |
| `delivered_at` | `string` (date-time) or `null` |  |
| `next_attempt_at` | `string` (date-time) or `null` |  |

`POST /v1/events/{id}/redeliver` sends an event again (with the same `webhook-id`), to one endpoint or to every endpoint subscribed to it. `POST /v1/webhook_endpoints/{id}/test` sends a `webhook.test` event to one endpoint, to check your handler end to end.

## Rotate a secret

`POST /v1/webhook_endpoints/{id}/rotate_secret` issues a new secret, shown once. For `previous_secret_expires_in` seconds (default and maximum 1 day) every delivery carries two signatures, one per secret, so you can deploy the new secret without dropping a delivery. Verify against both during the switch.

## Keys and test mode

Reading webhook endpoints and their deliveries needs the `account:read` scope; creating, changing, deleting, rotating, testing and redelivering need `admin`. A test key manages a separate set of test endpoints: they receive test events only (a test job's `job.*` events and test deliveries), signed and retried like live ones, and live endpoints never receive them.

---

[HTML version](https://staging.socialtap.dev/docs/webhooks/) · [Docs index for AI agents](https://staging.socialtap.dev/llms.txt) · [All docs in one file](https://staging.socialtap.dev/llms-full.txt)
