---
url: /webhooks.md
---

# Webhooks

Subscribe to a publication's progress instead of polling. Webhooks are brand-scoped:
an endpoint registered under a brand receives events only for that specific brand.

To configure webhooks, first list your brands and select the brand ID:

```sh
# List your brands and pick one (the id looks like brand_…)
curl -s "$API/brands" -H "Authorization: Bearer $SK"
export BRAND="brand_…"
```

## Register an endpoint

Register a URL, pick the events you care about, and target the brand-scoped webhooks endpoint:

```sh
curl -s -X POST "$API/brands/$BRAND/webhooks" \
  -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example/hooks/socialized",
    "events": ["publication.completed", "publication.target.failed"]
  }' | jq
```

The response includes a signing **secret** (`whsec_…`) returned **once**, like an
API key. Store it; you'll need it to verify deliveries. List your endpoints with
`GET /brands/{brandId}/webhooks` (without secrets) and remove one with
`DELETE /brands/{brandId}/webhooks/{id}`. The `url` must be `https`.

## Events

| Event | Fires when |
| --- | --- |
| `publication.target.published` | a single target goes live |
| `publication.target.failed` | a single target is rejected by its platform |
| `publication.target.deleted` | a live post is deleted on its platform via the API |
| `publication.target.removed_remotely` | a live post is observed to have been deleted manually on the platform |
| `publication.completed` | a publication reaches a terminal state (`done` / `partial` / `failed`) |
| `connection.reauth_required` | a platform needs a connection reconnected (reconnect to keep publishing) |

## Verifying a delivery

Every delivery carries two headers:

* `X-Socialized-Signature`: `sha256=<hex>`, an **HMAC-SHA256** of the **raw request body**, keyed by your endpoint secret.
* `X-Socialized-Delivery`: a stable id for this delivery (the same across retries).

Recompute the signature over the bytes you received and compare. Reject anything
that doesn't match, so a forged request can't be mistaken for ours.

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

// header value is "sha256=<hex>", so prefix the digest to match.
const expected = `sha256=${createHmac('sha256', endpointSecret).update(rawBody).digest('hex')}`;
const ok =
  signature.length === expected.length &&
  timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return new Response('bad signature', { status: 401 });
```

Compute the HMAC over the **exact bytes** of the body, before any JSON parsing:
re-serializing changes the bytes and breaks the signature. Compare in constant
time.

## Delivery

Deliveries are retried with backoff until they're accepted, so your handler
should be **idempotent**: the same event may arrive more than once with the same
`X-Socialized-Delivery` id, which you can dedupe on. Respond `2xx` quickly and do
the work asynchronously; a slow handler is a failed delivery.
