# Webhooks

Webhooks push new content to you instead of making you poll for it. There is one subscribable event type, `content_view.item_added`, and it is deliberately general: a [Content View](/api/v2/docs/guides/concepts#collections-views-and-view-groups) is a saved filter, so any condition you can express as a view — a platform, an approval status, an engagement threshold, an AI Filter verdict — becomes a webhook trigger by pointing a subscription at that view. When an item newly matches a subscribed view, Archive POSTs a signed JSON event to your HTTPS endpoint.

## How events fire

An event’s identity is the **(item, view) pair**. Each pair fires **at most once, ever** — an item that leaves and re-enters a view does not fire again. One item matching two of your subscribed views produces two events; two subscriptions sharing one view produce one event delivered to each endpoint separately (same `event_id`, each signed with its own secret).

Timing has two phases:

- **First evaluation** runs a few minutes after an item is captured, once transcription and enrichment have settled.
- **Re-evaluation** runs hourly for 14 days after the item’s post date. Views that filter on values arriving *after* capture — engagement thresholds, usage-rights status, AI Filters — still fire when the item crosses into the view later, with up to about an hour of lag.

When you subscribe to a view that already has matching items, those existing matches are recorded as [events](/api/v2/docs/queries/webhook-events) but **not delivered** — a new subscription never floods your endpoint with history. Only items matching from that point on are POSTed.

Delivery is **at-least-once** with **no ordering guarantee**: a later event can arrive before an earlier one, and a retry can deliver the same event twice. Deduplicate on `event_id` and treat arrival order as meaningless.

## Create a subscription

`createWebhookSubscription` takes a name, the HTTPS endpoint, and the view ids to watch. `metadata` is an optional object (up to 4 KB) echoed verbatim on every delivery — use it to route events inside your systems:

```graphql
mutation CreateWebhookSubscriptionDefault($input: CreateWebhookSubscriptionInput!) {
  createWebhookSubscription(input: $input) {
    webhookSubscription {
      id
      name
      url
      eventTypes
      viewIds
      metadata
      status
    }
    secret
    userErrors {
      field
      message
    }
  }
}
```

```json
{
  "input": {
    "name": "Fulfilment webhook",
    "url": "https://hooks.northwind-botanicals.example/archive/new-content",
    "viewIds": [
      "eaa824b8-374f-5db8-bee2-51dfd8c3776c"
    ],
    "eventTypes": [
      "content_view.item_added"
    ],
    "metadata": {
      "team": "fulfilment"
    }
  }
}
```

```http
Authorization: Bearer docs_demo_token_0000000000000000000000
WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a
```

```json
{
  "data": {
    "createWebhookSubscription": {
      "webhookSubscription": {
        "id": "b24c9093-72a3-504d-b97c-cb023efca979",
        "name": "Fulfilment webhook",
        "url": "https://hooks.northwind-botanicals.example/archive/new-content",
        "eventTypes": [
          "content_view.item_added"
        ],
        "viewIds": [
          "eaa824b8-374f-5db8-bee2-51dfd8c3776c"
        ],
        "metadata": {
          "team": "fulfilment"
        },
        "status": "ACTIVE"
      },
      "secret": "whsec_EXAMPLE_SECRET_SHOWN_ONCE_DO_NOT_USE",
      "userErrors": []
    }
  }
}
```

> **The secret is shown exactly once**
>
> The payload’s `secret` (prefix `whsec_`) signs every delivery to this subscription. No query ever re-exposes it — store it now. If you lose it, mint a new one with `rotateWebhookSubscriptionSecret`.

The endpoint URL must be `https`, on port 443 or an unprivileged port (≥1024), without embedded credentials, and its host must resolve to a public IP — private and internal addresses are rejected, and re-checked at every send. Creating an exact duplicate (same URL, event types, and view ids as an existing subscription) returns a `userError` naming the existing subscription rather than a second copy. Two subscriptions whose views merely *overlap* are allowed — and will double-deliver items matching both, by design.

To pause a subscription without deleting it, set `status: DISABLED_BY_USER` via `updateWebhookSubscription`; set `ACTIVE` to resume. Paused subscriptions accumulate nothing: their views are simply not evaluated while paused. `deleteWebhookSubscription` is a hard delete that also removes the subscription’s delivery history — the subscribed views survive.

## The delivery request

Each event arrives as its own `POST` — there is no batching. The request carries:

| Header                 | Value                                                                      |
| ---------------------- | -------------------------------------------------------------------------- |
| `Content-Type`         | `application/json`                                                         |
| `User-Agent`           | `Archive-Webhooks/1.0`                                                     |
| `X-Archive-Signature`  | `t=<unix-seconds>,v1=<hex-hmac>` (two `v1=` values during secret rotation) |
| `X-Archive-Event-Id`   | The event UUID — your idempotency key                                      |
| `X-Archive-Event-Type` | `content_view.item_added`                                                  |

The body is a fixed eight-key envelope:

```json
{
  "event_id": "0f8e0d63-409e-5fd1-88bd-2febbe05fa53",
  "event_type": "content_view.item_added",
  "event_version": "1.0",
  "created_at": "2026-08-14T09:30:12Z",
  "workspace_id": "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a",
  "subscription_id": "41662e09-10c4-52e4-844a-62ddedb38f11",
  "subscription_metadata": { "team": "growth" },
  "data": {
    "item": {
      "id": "12dedc45-e264-5d57-91f7-4d9aa7a05849",
      "media_item_id": "7f52a5cd-72cb-5d20-9a3e-b1461cf6f4b2",
      "provider": "tiktok",
      "public_url": "https://www.tiktok.com/@maya.skincare/video/7300112233445566778",
      "usage_rights_url": null,
      "creator": {
        "account_name": "maya.skincare",
        "followers_count": 12800,
        "verified": false
      }
    },
    "view": {
      "id": "eaa824b8-374f-5db8-bee2-51dfd8c3776c",
      "name": "UGC — Approved"
    }
  }
}
```

`data.item.creator` is `null` when the item has no associated creator. The payload is a compact notification, not the full item — fetch anything else through the [`items` query](/api/v2/docs/queries/items) using `data.item.id`.

Respond with **any 2xx** within 10 seconds. Anything else — including a redirect — counts as a failure and schedules a retry, so do the minimum (verify, enqueue, respond) and process the event asynchronously.

## Verify the signature

Every delivery is signed with your subscription’s secret: HMAC-SHA256, hex-encoded, over the string `"<t>.<raw body>"`, where `t` is the timestamp from the `X-Archive-Signature` header and the raw body is the exact bytes received — verify **before** parsing the JSON. The HMAC key is the full secret **including** the `whsec_` prefix.

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

// rawBody: Buffer or string of the exact request body bytes
export function verifyArchiveSignature(rawBody, signatureHeader, secret) {
  const pairs = signatureHeader.split(',').map((part) => {
    const eq = part.indexOf('=')
    return [part.slice(0, eq), part.slice(eq + 1)]
  })
  const t = pairs.find(([key]) => key === 't')?.[1]
  const candidates = pairs.filter(([key]) => key === 'v1').map(([, value]) => value)
  if (!t || candidates.length === 0) return false
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  return candidates.some(
    (candidate) =>
      candidate.length === expected.length &&
      timingSafeEqual(Buffer.from(candidate), Buffer.from(expected))
  )
}
```

The 5-minute timestamp tolerance is your replay defense — the sender does not enforce it for you. Use a constant-time comparison, and check **every** `v1=` value: after `rotateWebhookSubscriptionSecret`, deliveries carry two signatures for 24 hours (new secret first) so you can roll the new secret out without dropping events. Accept if either matches. Rotation mints a new secret on every call — don’t retry it blindly.

## Retries and automatic disabling

A failed delivery is retried on a backoff ladder: 8 attempts total, spaced roughly 1 minute → 5 minutes → 30 minutes → 2 hours → 6 hours → 12 hours → 24 hours (with jitter), about two days end to end. A delivery that exhausts the ladder becomes terminally `FAILED` — visible in [`webhookDeliveries`](/api/v2/docs/queries/webhook-deliveries) with `nextAttemptAt: null` — and can be replayed manually with `redeliverWebhookDelivery`. Transport failures (connection refused, connect timeout) consume retry attempts like any other failure; once they repeat consecutively, sends to your endpoint are briefly deferred instead of being fired into a dead host, so a sustained outage doesn’t burn through the rest of the ladder. Sustained matching bursts are paced out over time; nothing is dropped.

A subscription is automatically disabled (`status: DISABLED_BY_FAILURES`) when your endpoint returns **410 Gone** (the clean way to decommission an endpoint — delivery stops immediately), or after prolonged continuous failure. You’ll see it in the subscription’s `status` and `disabledAt`, as a `subscription.disabled` event in [`webhookEvents`](/api/v2/docs/queries/webhook-events), and as an email to workspace admins. Re-arm it once your endpoint is healthy:

```graphql
mutation EnableWebhookSubscriptionDefault($id: ID!, $replayFailedSince24h: Boolean) {
  enableWebhookSubscription(id: $id, replayFailedSince24h: $replayFailedSince24h) {
    webhookSubscription {
      id
      name
      status
      consecutiveFailures
      disabledAt
    }
    userErrors {
      field
      message
    }
  }
}
```

```json
{
  "id": "41662e09-10c4-52e4-844a-62ddedb38f11",
  "replayFailedSince24h": false
}
```

```http
Authorization: Bearer docs_demo_token_0000000000000000000000
WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a
```

`replayFailedSince24h` defaults to `true` and replays the last 24 hours of failed deliveries one-shot each; pass `false` to re-enable without the replay. Events that fired while the subscription was failure-disabled sit in the outbox — page `webhookEvents` to reconcile anything older than the replay window.

## Test and monitor

`sendWebhookTestEvent` POSTs a signed synthetic ping through the full delivery pipeline and returns the settled delivery, so you can verify transport and your signature check end to end before real traffic arrives. The ping’s `data` is `{ ping, subscription_id, sent_at }` — not the item shape — and repeated pings to one subscription are throttled with a short cooldown (the `userError` tells you how long to wait).

```graphql
mutation SendWebhookTestEventDefault($id: ID!) {
  sendWebhookTestEvent(id: $id) {
    webhookDelivery {
      id
      subscriptionId
      eventId
      status
      attemptCount
      lastResponseStatus
      responseTimeMs
    }
    userErrors {
      field
      message
    }
  }
}
```

```json
{
  "id": "eb418e28-bf9d-5350-b87b-446a6813bca4"
}
```

```http
Authorization: Bearer docs_demo_token_0000000000000000000000
WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a
```

```json
{
  "data": {
    "sendWebhookTestEvent": {
      "webhookDelivery": {
        "id": "51e830cd-4071-5889-9304-2ac229f5cf70",
        "subscriptionId": "eb418e28-bf9d-5350-b87b-446a6813bca4",
        "eventId": "4849ae8e-b058-59bb-95b3-4e993b82807c",
        "status": "SUCCEEDED",
        "attemptCount": 1,
        "lastResponseStatus": 200,
        "responseTimeMs": 142
      },
      "userErrors": []
    }
  }
}
```

For ongoing monitoring, filter deliveries to `FAILED` — anything with `nextAttemptAt: null` has spent its ladder and is waiting on you:

```graphql
query WebhookDeliveriesDefault($filter: WebhookDeliveryFilterInput, $first: Int) {
  webhookDeliveries(filter: $filter, first: $first) {
    nodes {
      id
      subscriptionId
      eventId
      status
      attemptCount
      lastResponseStatus
      lastError
      responseTimeMs
      nextAttemptAt
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}
```

```json
{
  "filter": {
    "status": "FAILED"
  },
  "first": 10
}
```

```http
Authorization: Bearer docs_demo_token_0000000000000000000000
WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a
```

```json
{
  "data": {
    "webhookDeliveries": {
      "nodes": [
        {
          "id": "dbe6be29-b520-5cdc-81a3-b7a0d5d8f62e",
          "subscriptionId": "41662e09-10c4-52e4-844a-62ddedb38f11",
          "eventId": "0f8e0d63-409e-5fd1-88bd-2febbe05fa53",
          "status": "FAILED",
          "attemptCount": 8,
          "lastResponseStatus": 500,
          "lastError": "HTTP 500 from endpoint",
          "responseTimeMs": 87,
          "nextAttemptAt": null
        }
      ],
      "pageInfo": {
        "hasNextPage": false,
        "endCursor": "Z2lkOi8vYXJjaGl2ZS9XZWJob29rRGVsaXZlcnkvMjAyNC0xMi0zMVQxNTowMDowMC4wMDAwMDBafGRiZTZiZTI5LWI1MjAtNWNkYy04MWEzLWI3YTBkNWQ4ZjYyZQ=="
      },
      "totalCount": 1
    }
  }
}
```

`webhookEvents` is the outbox: every event that ever fired, whether or not its deliveries succeeded. Page it on cold start (or after downtime) to catch anything you missed — the `id` is the same `event_id` you deduplicate deliveries on.

```graphql
query WebhookEventsDefault($filter: WebhookEventFilterInput, $first: Int) {
  webhookEvents(filter: $filter, first: $first) {
    nodes {
      id
      eventType
      eventVersion
      payload
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}
```

```json
{
  "filter": {
    "eventTypes": [
      "content_view.item_added"
    ]
  },
  "first": 10
}
```

```http
Authorization: Bearer docs_demo_token_0000000000000000000000
WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a
```

```json
{
  "data": {
    "webhookEvents": {
      "nodes": [
        {
          "id": "0f8e0d63-409e-5fd1-88bd-2febbe05fa53",
          "eventType": "content_view.item_added",
          "eventVersion": "1.0",
          "payload": {
            "item": {
              "id": "12dedc45-e264-5d57-91f7-4d9aa7a05849"
            },
            "view": {
              "id": "eaa824b8-374f-5db8-bee2-51dfd8c3776c"
            }
          }
        },
        {
          "id": "7f412c82-762e-5978-b0ae-bdcac334b9b5",
          "eventType": "content_view.item_added",
          "eventVersion": "1.0",
          "payload": {
            "item": {
              "id": "12dedc45-e264-5d57-91f7-4d9aa7a05849"
            },
            "view": {
              "id": "eaa824b8-374f-5db8-bee2-51dfd8c3776c"
            }
          }
        }
      ],
      "pageInfo": {
        "hasNextPage": false,
        "endCursor": "Z2lkOi8vYXJjaGl2ZS9XZWJob29rRXZlbnQvMjAyNC0xMi0zMVQxNDowMDowMC4wMDAwMDBafDdmNDEyYzgyLTc2MmUtNTk3OC1iMGFlLWJkY2FjMzM0YjliNQ=="
      },
      "totalCount": 2
    }
  }
}
```

> **History is retained for 30 days**
>
> Delivery and event history ages out after 30 days, and an aged-out delivery can no longer be redelivered. Reconcile against `webhookEvents` well inside that window.

## Limits

| Plan            | Subscriptions per workspace | Views per subscription |
| --------------- | --------------------------- | ---------------------- |
| Trial           | 1                           | 1                      |
| Startup         | 2                           | 5                      |
| Growth / Agency | 5                           | 10                     |
| Enterprise      | 10                          | 25                     |

`metadata` is capped at 4 KB on every plan. There is no cap on delivery volume — bursts are paced, not dropped. If you need more subscriptions or views per subscription, [contact support](/api/v2/docs/mcp/support): both caps can be raised per workspace.

## Where to go next

- [`createWebhookSubscription` reference](/api/v2/docs/mutations/create-webhook-subscription) — full input and payload shapes, plus the other six webhook mutations alongside it.
- [`webhookDeliveries`](/api/v2/docs/queries/webhook-deliveries) and [`webhookEvents`](/api/v2/docs/queries/webhook-events) — the monitoring surface.
- [`webhookSubscriptions`](/api/v2/docs/queries/webhook-subscriptions) and its paginated twin [`webhookSubscriptionsConnection`](/api/v2/docs/queries/webhook-subscriptions-connection) — list what’s subscribed; AI agents reach the same list through the `getWebhookSubscriptions` [MCP tool](/api/v2/docs/mcp/read-tools).
- [Filtering & search](/api/v2/docs/guides/filtering) — building the views that act as your event filters.
