Archive API docs
    Jump to

    Narrow by kind with a prefix — q: queries, m: mutations, t: types, g: guides, f: fields and arguments.

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

    query.graphql
    mutation CreateWebhookSubscriptionDefault($input: CreateWebhookSubscriptionInput!) {
      createWebhookSubscription(input: $input) {
        webhookSubscription {
          id
          name
          url
          eventTypes
          viewIds
          metadata
          status
        }
        secret
        userErrors {
          field
          message
        }
      }
    }
    variables.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"
        }
      }
    }
    Headers
    Authorization: Bearer docs_demo_token_0000000000000000000000
    WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a
    200 OK 23 lines
    {
      "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 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:

    HeaderValue
    Content-Typeapplication/json
    User-AgentArchive-Webhooks/1.0
    X-Archive-Signaturet=<unix-seconds>,v1=<hex-hmac> (two v1= values during secret rotation)
    X-Archive-Event-IdThe event UUID — your idempotency key
    X-Archive-Event-Typecontent_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 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 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, and as an email to workspace admins. Re-arm it once your endpoint is healthy:

    query.graphql
    mutation EnableWebhookSubscriptionDefault($id: ID!, $replayFailedSince24h: Boolean) {
      enableWebhookSubscription(id: $id, replayFailedSince24h: $replayFailedSince24h) {
        webhookSubscription {
          id
          name
          status
          consecutiveFailures
          disabledAt
        }
        userErrors {
          field
          message
        }
      }
    }
    variables.json
    {
      "id": "41662e09-10c4-52e4-844a-62ddedb38f11",
      "replayFailedSince24h": false
    }
    Headers
    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).

    query.graphql
    mutation SendWebhookTestEventDefault($id: ID!) {
      sendWebhookTestEvent(id: $id) {
        webhookDelivery {
          id
          subscriptionId
          eventId
          status
          attemptCount
          lastResponseStatus
          responseTimeMs
        }
        userErrors {
          field
          message
        }
      }
    }
    variables.json
    {
      "id": "eb418e28-bf9d-5350-b87b-446a6813bca4"
    }
    Headers
    Authorization: Bearer docs_demo_token_0000000000000000000000
    WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a
    200 OK 16 lines
    {
      "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:

    query.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
      }
    }
    variables.json
    {
      "filter": {
        "status": "FAILED"
      },
      "first": 10
    }
    Headers
    Authorization: Bearer docs_demo_token_0000000000000000000000
    WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a
    200 OK 24 lines
    {
      "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.

    query.graphql
    query WebhookEventsDefault($filter: WebhookEventFilterInput, $first: Int) {
      webhookEvents(filter: $filter, first: $first) {
        nodes {
          id
          eventType
          eventVersion
          payload
        }
        pageInfo {
          hasNextPage
          endCursor
        }
        totalCount
      }
    }
    variables.json
    {
      "filter": {
        "eventTypes": [
          "content_view.item_added"
        ]
      },
      "first": 10
    }
    Headers
    Authorization: Bearer docs_demo_token_0000000000000000000000
    WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a
    200 OK 39 lines
    {
      "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
        }
      }
    }

    Limits

    PlanSubscriptions per workspaceViews per subscription
    Trial11
    Startup25
    Growth / Agency510
    Enterprise1025

    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: both caps can be raised per workspace.

    Where to go next