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 content 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. Choose what triggers an event has ready-made views for the common cases. A second event type, operation.completed, tells you when a bulk engagement refresh or creator import finishes — see Operation completion events.

    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 about 5 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. Re-evaluation never widens a view’s own date filter: a view limited to the last 7 days stays limited to 7.

    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. Items captured in the last hour are the exception. They are still in the normal 5-minute pipeline, so they deliver as new matches instead of being recorded as history.

    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.

    Choose what triggers an event

    There is one content event type, so you never subscribe to “verified mentions” or “usage rights approved” directly. Instead you build a content view whose filters express the condition, then point a subscription’s viewIds at it. One view per condition. When an item starts matching the view, you get a content_view.item_added delivery.

    A view is created with createContentView. Pass the condition as its filter argument: the same ItemFilterInput you pass to items(filter:), so every key and enum value is checked against the schema. The filters argument (a JSON object, stored exactly as you send it) stays for copying an existing view’s filters verbatim. Send exactly one of the two.

    Event you wantfilter (or customAttributeConditions)
    Mention from a creator with 500k+ followers{ "followersCount": { "from": 500000 }, "tagsNames": ["<your brand tag>"] }
    Mention from a verified creator{ "verified": [true], "tagsNames": ["<your brand tag>"] }
    Usage rights approved{ "usageRightsStatus": ["APPROVED"] }
    Instagram whitelisting approved{ "instagramWhitelistingStatus": ["APPROVED"] }
    TikTok Spark Code approved{ "tiktokSparkCodeStatus": ["APPROVED"] }
    TikTok content only{ "provider": "TIKTOK" }
    Added to a collection{ "collectionsIds": ["<collection id>"] }
    Transcription readycustomAttributeConditions: [{ field: "<transcription key>", operator: IS_NOT_EMPTY, type: TEXT }]
    Positive sentimentcustomAttributeConditions: [{ field: "<sentiment key>", operator: IS, type: TEXT, value: "positive" }]
    EMV above 1,000customAttributeConditions: [{ field: "<emv key>", operator: MORE_THAN, type: NUMBER, value: 1000 }]
    • followersCount takes a range, { "from": n }, { "to": n } or both. verified takes an array of booleans. provider takes a single enum value. The status fields take arrays.
    • tagsNames matches any of the names you list. Use the name of a tag your workspace tracks, from the workspace query’s mentions { name } or hashtags { name }. A name your workspace doesn’t track matches nothing.
    • Collection ids come from the collections query.
    • Values that arrive after capture, such as a usage-rights approval or a collection add, fire only while the item is within 14 days of its post date.

    Custom-attribute conditions

    Sentiment, transcription status and EMV are custom attributes, not built-in filter fields. Their keys differ per workspace, so look the key up before you build the condition. Never hardcode one:

    graphql
    query ItemAttributeSchemas {
      customAttributeSchemas(entity: ITEM) {
        key
        name
        type
      }
    }

    Pass the key as field and the returned type as type. For a single-select attribute, value is the option ID. See Custom-attribute conditions for every operator. Put the conditions in createContentView’s customAttributeConditions argument, next to filter.

    Worked example: macro mentions to your endpoint

    Create the view:

    graphql
    mutation CreateMacroMentionsView($input: CreateContentViewInput!) {
      createContentView(input: $input) {
        contentView {
          id
        }
        userErrors {
          field
          message
        }
      }
    }
    json
    {
      "input": {
        "name": "Macro mentions",
        "filter": { "followersCount": { "from": 500000 }, "tagsNames": ["acme"] }
      }
    }

    Subscribe your endpoint to it:

    graphql
    mutation SubscribeToMacroMentions($input: CreateWebhookSubscriptionInput!) {
      createWebhookSubscription(input: $input) {
        webhookSubscription {
          id
          status
        }
        secret
        userErrors {
          field
          message
        }
      }
    }
    json
    {
      "input": {
        "name": "Macro mentions to Acme",
        "url": "https://example.com/webhooks/archive",
        "viewIds": ["<contentView.id from the first call>"],
        "metadata": { "customerRef": "acme-123" }
      }
    }

    Store secret now. It is shown once (see Create a subscription).

    There is no implicit self-mention filter. A view matches posts from your own brand accounts like any other. If they shouldn’t trigger deliveries, narrow the view so their posts don’t match.

    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.

    Every viewIds entry must be a content view your workspace owns. An unknown id, or one from another workspace, returns a userError naming it: Unknown or inaccessible view ids: ….

    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 not evaluated while paused. deleteWebhookSubscription is a hard delete that also removes the subscription’s delivery history — the subscribed views survive.

    updateWebhookSubscription changes only the fields you send. An explicit null is refused with a userError on that field, except for metadata, where null clears it, like {}. The MCP tool rejects null on those other fields as invalid arguments before the update runs.

    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 or operation.completed

    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.

    Operation completion events

    Subscribe to operation.completed to hear when a refetch_engagement or oneschema_creator_import operation finishes instead of polling operation(id:). It is not tied to a view, so a subscription carrying only this type takes no viewIds. It fires once per operation when it reaches completed, partial or failed; data.operation.status tells them apart. The envelope is the same, with X-Archive-Event-Type: operation.completed and this data:

    json
    {
      "operation": {
        "id": "5d3c1f2a-8b7e-4f6a-9c1d-2e3f4a5b6c7d",
        "operation_type": "refetch_engagement",
        "status": "partial",
        "total": 12,
        "processed": 12,
        "succeeded_count": 10,
        "failed_count": 2,
        "pending_count": 0,
        "created_at": "2026-08-14T09:25:40Z",
        "completed_at": "2026-08-14T09:30:12Z"
      }
    }

    The counts match what operation(id:) returns. Per-record outcomes and result CSVs are not in the payload — fetch them with operation(id:) and operationRecords.

    The event’s identity is the operation id for its lifetime. If a re-submit converges on an operation that was already announced and re-queues it, the event does not fire again — poll operation(id:) for its latest state.

    Respond with any 2xx within 10 seconds. Archive allows 3 seconds to connect and 10 seconds to read your response. 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.

    Parse t and every v1= value from the header, splitting each pair on its first = only. Reject a t that isn’t a number, or is more than 5 minutes from your clock. Compute the HMAC over the ASCII bytes of "<t>." followed by the raw body bytes, and compare it in constant time against each v1= value. Accept if any matches.

    Node.js

    rawBody must be the exact bytes as a Buffer. In Express, mount express.raw({ type: 'application/json' }) on the webhook route.

    js
    import { createHmac, timingSafeEqual } from 'node:crypto'
    
    // secrets: [current], or [current, previous] during a rotation overlap.
    export function verifyArchiveSignature(rawBody, signatureHeader, secrets) {
      if (typeof signatureHeader !== 'string') return false
      const pairs = signatureHeader.split(',').map((part) => {
        const eq = part.indexOf('=')
        return eq === -1 ? [part, undefined] : [part.slice(0, eq), part.slice(eq + 1)]
      })
      const t = pairs.find(([key]) => key === 't')?.[1]
      const candidates = pairs.filter(([key, value]) => key === 'v1' && value).map(([, value]) => value)
      if (!t || candidates.length === 0) return false
      const timestamp = Number(t)
      if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false
    
      return secrets.some((secret) => {
        // The key keeps its whsec_ prefix. Hash raw bytes: a string concat would re-encode the body.
        const expected = createHmac('sha256', secret)
          .update(Buffer.from(`${t}.`))
          .update(rawBody)
          .digest()
        return candidates.some(
          (candidate) =>
            /^[0-9a-f]{64}$/.test(candidate) &&
            timingSafeEqual(Buffer.from(candidate, 'hex'), expected)
        )
      })
    }

    Ruby

    The verifier is plain Ruby with no framework dependency:

    ruby
    require 'openssl'
    
    module ArchiveWebhookSignature
      TOLERANCE_SECONDS = 300
    
      # secrets: [current], or [current, previous] during a rotation overlap.
      def self.valid?(raw_body:, header:, secrets:, now: Time.now.to_i)
        pairs = header.split(',').filter_map do |part|
          key, value = part.split('=', 2)
          [key, value] unless value.nil?
        end
        timestamp = pairs.find { |key, _| key == 't' }&.last
        candidates = pairs.select { |key, _| key == 'v1' }.map(&:last)
        return false if timestamp.nil? || candidates.empty?
    
        seconds = Integer(timestamp, 10, exception: false)
        return false if seconds.nil? || (now - seconds).abs > TOLERANCE_SECONDS
    
        # The key keeps its whsec_ prefix. Sign the raw bytes, never re-serialized JSON.
        signed = "#{timestamp}.".b + raw_body.b
        secrets.any? do |secret|
          expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed)
          candidates.any? { |candidate| OpenSSL.secure_compare(expected, candidate) }
        end
      end
    end

    Call it from your controller with the raw request body:

    ruby
    class ArchiveWebhooksController < ActionController::API
      def create
        raw_body = request.raw_post
        secrets = [ENV.fetch('ARCHIVE_WEBHOOK_SECRET'), ENV['ARCHIVE_WEBHOOK_PREVIOUS_SECRET']].compact
        header = request.headers['X-Archive-Signature'].to_s
        return head :bad_request unless ArchiveWebhookSignature.valid?(raw_body: raw_body, header: header, secrets: secrets)
    
        ArchiveEventJob.perform_later(request.headers['X-Archive-Event-Id'], raw_body)
        head :ok
      end
    end

    OpenSSL.secure_compare ships with the openssl gem 2.2 and later, bundled since Ruby 3.0, and it handles inputs of different lengths. On older Ruby, use OpenSSL.fixed_length_secure_compare behind a length check (expected.bytesize == candidate.bytesize && …). It raises ArgumentError when the lengths differ. Inside Rails, ActiveSupport::SecurityUtils.secure_compare works too.

    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 fixed ladder: 8 attempts total, one immediate try plus seven retries after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. Each wait carries ±20% jitter, so the ladder spans about 44.6 hours, and up to about 53.5 hours. A delivery that exhausts it becomes terminally FAILED, visible in webhookDeliveries with nextAttemptAt: null. Between attempts, a delivery shows as FAILED with nextAttemptAt set to its next retry, which runs automatically.

    Every failed attempt counts against the ladder, transport failures included. When 5 consecutive sends to one host fail at the transport level (a timeout, a refused connection or a TLS error), Archive pauses that host for 10 minutes. HTTP error responses don’t count toward the 5, since they prove the host is up. The pause covers every subscription pointing at that host. Deliveries due during it keep their status, their nextAttemptAt moves past the pause, and they spend no attempts. After the pause, one delivery goes through as a probe, and a success resumes normal sending. Test pings are not held by the pause. Sustained matching bursts are paced out over time. Nothing is dropped.

    To replay a terminal FAILED delivery (nextAttemptAt: null) by hand, call redeliverWebhookDelivery. It grants exactly one more attempt. If that attempt fails, the delivery goes back to FAILED with no new retries. attemptCount keeps counting from where it was. Any other status returns a userError on deliveryId: Only a failed delivery can be redelivered. A FAILED delivery that still has a nextAttemptAt is refused too, since its retry is already scheduled. A test ping, or a delivery whose event has aged out of history, can’t be replayed either.

    A subscription is automatically disabled (status: DISABLED_BY_FAILURES) on any of:

    • your endpoint returns 410 Gone, the clean way to decommission an endpoint, which stops delivery immediately;
    • 20 consecutive deliveries exhaust their ladder;
    • 3 days of continuous failure with no success in between.

    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. It does not appear as a delivery. 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 terminal FAILED deliveries (nextAttemptAt: null, test pings excluded) one-shot each; deliveries still awaiting a scheduled retry resume on their own. 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 its event type is subscription.ping (in the envelope, the X-Archive-Event-Type header and webhookEvents), so route it apart from real events. A ping is a single attempt: a failed ping is not retried and never counts toward auto-disable, and you can ping a paused subscription to check the endpoint before resuming it. A ping is sent even while its endpoint host is paused after repeated transport failures, and it can’t be redelivered — send a new one. Repeated pings to one subscription are throttled. Over the limit, the mutation returns webhookDelivery: null and a userError on id: Rate limit exceeded. Retry after N seconds. Wait that long and retry.

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

    DROPPED is a historical status from a retired protection. Nothing produces it any more, so new deliveries never carry it.

    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.

    A create or update that would exceed a cap returns a userError whose message carries the limit and your current usage, for example Workspace webhook-subscription limit reached (2). Currently using 2. or Views-per-subscription limit reached (5). Requested 6.

    Moving to a smaller plan never breaks a live integration. updateWebhookSubscription doesn’t re-check the subscription count, and it checks viewIds and metadata only when you send them. A workspace over a lowered cap keeps its subscriptions, and they keep delivering. It just can’t create new ones until it is back under the cap.

    Where to go next