# Webhooks

Jobs and events

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://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. A key an agent collected from an approval reads only its host: https://host/. |
| `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 5 days), 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) |  |
| `created_by_user` | `string` or `null` | The person (GET /v1/members user_id) who created this endpoint in the dashboard, or whose API key created it. They may have left the account since. Null when no person did, or it is from before this was recorded. |

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 same check ships in the TypeScript SDK, and any Standard Webhooks library verifies these deliveries.

> [!IMPORTANT]
>
> 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.

A target that has stopped answering at all gets a shorter timeout, 5 seconds, on most of its attempts until it answers again. One that answers slowly keeps 15 seconds.

## Pacing

Deliveries are paced per account and per host so that no one's traffic holds up anyone else's: a delivery over the pace waits, `pending` with a later `next_attempt_at`, and is never dropped.

## 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

Reading webhook endpoints and their deliveries needs the `account:read` scope; creating, changing, deleting, rotating, testing and redelivering need `admin`.

---

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