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 want | filter (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 ready | customAttributeConditions: [{ field: "<transcription key>", operator: IS_NOT_EMPTY, type: TEXT }] |
| Positive sentiment | customAttributeConditions: [{ field: "<sentiment key>", operator: IS, type: TEXT, value: "positive" }] |
| EMV above 1,000 | customAttributeConditions: [{ field: "<emv key>", operator: MORE_THAN, type: NUMBER, value: 1000 }] |
followersCounttakes a range,{ "from": n },{ "to": n }or both.verifiedtakes an array of booleans.providertakes a single enum value. The status fields take arrays.tagsNamesmatches any of the names you list. Use thenameof a tag your workspace tracks, from theworkspacequery’smentions { name }orhashtags { name }. A name your workspace doesn’t track matches nothing.- Collection ids come from the
collectionsquery. - 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:
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:
mutation CreateMacroMentionsView($input: CreateContentViewInput!) {
createContentView(input: $input) {
contentView {
id
}
userErrors {
field
message
}
}
}{
"input": {
"name": "Macro mentions",
"filter": { "followersCount": { "from": 500000 }, "tagsNames": ["acme"] }
}
}Subscribe your endpoint to it:
mutation SubscribeToMacroMentions($input: CreateWebhookSubscriptionInput!) {
createWebhookSubscription(input: $input) {
webhookSubscription {
id
status
}
secret
userErrors {
field
message
}
}
}{
"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:
mutation CreateWebhookSubscriptionDefault($input: CreateWebhookSubscriptionInput!) {
createWebhookSubscription(input: $input) {
webhookSubscription {
id
name
url
eventTypes
viewIds
metadata
status
}
secret
userErrors {
field
message
}
}
} {
"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"
}
}
} Authorization: Bearer docs_demo_token_0000000000000000000000
WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a curl https://app.archive.com/api/v2 \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer docs_demo_token_0000000000000000000000' \
-H 'WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a' \
-d '{
"query": "mutation CreateWebhookSubscriptionDefault($input: CreateWebhookSubscriptionInput!) {\n createWebhookSubscription(input: $input) {\n webhookSubscription {\n id\n name\n url\n eventTypes\n viewIds\n metadata\n status\n }\n secret\n userErrors {\n field\n message\n }\n }\n}",
"variables": {
"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"
}
}
}
}' const response = await fetch("https://app.archive.com/api/v2", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID": "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a"
},
body: JSON.stringify({
query: `mutation CreateWebhookSubscriptionDefault($input: CreateWebhookSubscriptionInput!) {
createWebhookSubscription(input: $input) {
webhookSubscription {
id
name
url
eventTypes
viewIds
metadata
status
}
secret
userErrors {
field
message
}
}
}`,
variables: {
"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"
}
}
}
})
})
console.log(JSON.stringify(await response.json(), null, 2)) require "json"
require "net/http"
query = <<~'GRAPHQL'
mutation CreateWebhookSubscriptionDefault($input: CreateWebhookSubscriptionInput!) {
createWebhookSubscription(input: $input) {
webhookSubscription {
id
name
url
eventTypes
viewIds
metadata
status
}
secret
userErrors {
field
message
}
}
}
GRAPHQL
response = Net::HTTP.post(
URI("https://app.archive.com/api/v2"),
{ query: query, variables: { "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" } } } }.to_json,
"Content-Type" => "application/json",
"Authorization" => "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID" => "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a"
)
puts response.body import json
import requests
query = """
mutation CreateWebhookSubscriptionDefault($input: CreateWebhookSubscriptionInput!) {
createWebhookSubscription(input: $input) {
webhookSubscription {
id
name
url
eventTypes
viewIds
metadata
status
}
secret
userErrors {
field
message
}
}
}
"""
response = requests.post(
"https://app.archive.com/api/v2",
json={"query": query, "variables": {"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",
},
)
print(json.dumps(response.json(), indent=2)) {
"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:
| 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 or operation.completed |
The body is a fixed eight-key envelope:
{
"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:
{
"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.
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:
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
endCall it from your controller with the raw request body:
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
endOpenSSL.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:
mutation EnableWebhookSubscriptionDefault($id: ID!, $replayFailedSince24h: Boolean) {
enableWebhookSubscription(id: $id, replayFailedSince24h: $replayFailedSince24h) {
webhookSubscription {
id
name
status
consecutiveFailures
disabledAt
}
userErrors {
field
message
}
}
} {
"id": "41662e09-10c4-52e4-844a-62ddedb38f11",
"replayFailedSince24h": false
} Authorization: Bearer docs_demo_token_0000000000000000000000
WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a curl https://app.archive.com/api/v2 \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer docs_demo_token_0000000000000000000000' \
-H 'WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a' \
-d '{
"query": "mutation EnableWebhookSubscriptionDefault($id: ID!, $replayFailedSince24h: Boolean) {\n enableWebhookSubscription(id: $id, replayFailedSince24h: $replayFailedSince24h) {\n webhookSubscription {\n id\n name\n status\n consecutiveFailures\n disabledAt\n }\n userErrors {\n field\n message\n }\n }\n}",
"variables": {
"id": "41662e09-10c4-52e4-844a-62ddedb38f11",
"replayFailedSince24h": false
}
}' const response = await fetch("https://app.archive.com/api/v2", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID": "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a"
},
body: JSON.stringify({
query: `mutation EnableWebhookSubscriptionDefault($id: ID!, $replayFailedSince24h: Boolean) {
enableWebhookSubscription(id: $id, replayFailedSince24h: $replayFailedSince24h) {
webhookSubscription {
id
name
status
consecutiveFailures
disabledAt
}
userErrors {
field
message
}
}
}`,
variables: {
"id": "41662e09-10c4-52e4-844a-62ddedb38f11",
"replayFailedSince24h": false
}
})
})
console.log(JSON.stringify(await response.json(), null, 2)) require "json"
require "net/http"
query = <<~'GRAPHQL'
mutation EnableWebhookSubscriptionDefault($id: ID!, $replayFailedSince24h: Boolean) {
enableWebhookSubscription(id: $id, replayFailedSince24h: $replayFailedSince24h) {
webhookSubscription {
id
name
status
consecutiveFailures
disabledAt
}
userErrors {
field
message
}
}
}
GRAPHQL
response = Net::HTTP.post(
URI("https://app.archive.com/api/v2"),
{ query: query, variables: { "id" => "41662e09-10c4-52e4-844a-62ddedb38f11", "replayFailedSince24h" => false } }.to_json,
"Content-Type" => "application/json",
"Authorization" => "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID" => "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a"
)
puts response.body import json
import requests
query = """
mutation EnableWebhookSubscriptionDefault($id: ID!, $replayFailedSince24h: Boolean) {
enableWebhookSubscription(id: $id, replayFailedSince24h: $replayFailedSince24h) {
webhookSubscription {
id
name
status
consecutiveFailures
disabledAt
}
userErrors {
field
message
}
}
}
"""
response = requests.post(
"https://app.archive.com/api/v2",
json={"query": query, "variables": {"id": "41662e09-10c4-52e4-844a-62ddedb38f11", "replayFailedSince24h": False}},
headers={
"Authorization": "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID": "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a",
},
)
print(json.dumps(response.json(), indent=2)) 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.
mutation SendWebhookTestEventDefault($id: ID!) {
sendWebhookTestEvent(id: $id) {
webhookDelivery {
id
subscriptionId
eventId
status
attemptCount
lastResponseStatus
responseTimeMs
}
userErrors {
field
message
}
}
} {
"id": "eb418e28-bf9d-5350-b87b-446a6813bca4"
} Authorization: Bearer docs_demo_token_0000000000000000000000
WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a curl https://app.archive.com/api/v2 \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer docs_demo_token_0000000000000000000000' \
-H 'WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a' \
-d '{
"query": "mutation SendWebhookTestEventDefault($id: ID!) {\n sendWebhookTestEvent(id: $id) {\n webhookDelivery {\n id\n subscriptionId\n eventId\n status\n attemptCount\n lastResponseStatus\n responseTimeMs\n }\n userErrors {\n field\n message\n }\n }\n}",
"variables": {
"id": "eb418e28-bf9d-5350-b87b-446a6813bca4"
}
}' const response = await fetch("https://app.archive.com/api/v2", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID": "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a"
},
body: JSON.stringify({
query: `mutation SendWebhookTestEventDefault($id: ID!) {
sendWebhookTestEvent(id: $id) {
webhookDelivery {
id
subscriptionId
eventId
status
attemptCount
lastResponseStatus
responseTimeMs
}
userErrors {
field
message
}
}
}`,
variables: {
"id": "eb418e28-bf9d-5350-b87b-446a6813bca4"
}
})
})
console.log(JSON.stringify(await response.json(), null, 2)) require "json"
require "net/http"
query = <<~'GRAPHQL'
mutation SendWebhookTestEventDefault($id: ID!) {
sendWebhookTestEvent(id: $id) {
webhookDelivery {
id
subscriptionId
eventId
status
attemptCount
lastResponseStatus
responseTimeMs
}
userErrors {
field
message
}
}
}
GRAPHQL
response = Net::HTTP.post(
URI("https://app.archive.com/api/v2"),
{ query: query, variables: { "id" => "eb418e28-bf9d-5350-b87b-446a6813bca4" } }.to_json,
"Content-Type" => "application/json",
"Authorization" => "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID" => "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a"
)
puts response.body import json
import requests
query = """
mutation SendWebhookTestEventDefault($id: ID!) {
sendWebhookTestEvent(id: $id) {
webhookDelivery {
id
subscriptionId
eventId
status
attemptCount
lastResponseStatus
responseTimeMs
}
userErrors {
field
message
}
}
}
"""
response = requests.post(
"https://app.archive.com/api/v2",
json={"query": query, "variables": {"id": "eb418e28-bf9d-5350-b87b-446a6813bca4"}},
headers={
"Authorization": "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID": "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a",
},
)
print(json.dumps(response.json(), indent=2)) {
"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 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
}
} {
"filter": {
"status": "FAILED"
},
"first": 10
} Authorization: Bearer docs_demo_token_0000000000000000000000
WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a curl https://app.archive.com/api/v2 \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer docs_demo_token_0000000000000000000000' \
-H 'WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a' \
-d '{
"query": "query WebhookDeliveriesDefault($filter: WebhookDeliveryFilterInput, $first: Int) {\n webhookDeliveries(filter: $filter, first: $first) {\n nodes {\n id\n subscriptionId\n eventId\n eventType\n status\n attemptCount\n lastResponseStatus\n lastError\n responseTimeMs\n nextAttemptAt\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n totalCount\n }\n}",
"variables": {
"filter": {
"status": "FAILED"
},
"first": 10
}
}' const response = await fetch("https://app.archive.com/api/v2", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID": "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a"
},
body: JSON.stringify({
query: `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: {
"filter": {
"status": "FAILED"
},
"first": 10
}
})
})
console.log(JSON.stringify(await response.json(), null, 2)) require "json"
require "net/http"
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
}
}
GRAPHQL
response = Net::HTTP.post(
URI("https://app.archive.com/api/v2"),
{ query: query, variables: { "filter" => { "status" => "FAILED" }, "first" => 10 } }.to_json,
"Content-Type" => "application/json",
"Authorization" => "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID" => "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a"
)
puts response.body import json
import requests
query = """
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
}
}
"""
response = requests.post(
"https://app.archive.com/api/v2",
json={"query": query, "variables": {"filter": {"status": "FAILED"}, "first": 10}},
headers={
"Authorization": "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID": "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a",
},
)
print(json.dumps(response.json(), indent=2)) {
"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 WebhookEventsDefault($filter: WebhookEventFilterInput, $first: Int) {
webhookEvents(filter: $filter, first: $first) {
nodes {
id
eventType
eventVersion
payload
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
} {
"filter": {
"eventTypes": [
"content_view.item_added"
]
},
"first": 10
} Authorization: Bearer docs_demo_token_0000000000000000000000
WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a curl https://app.archive.com/api/v2 \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer docs_demo_token_0000000000000000000000' \
-H 'WORKSPACE-ID: 6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a' \
-d '{
"query": "query WebhookEventsDefault($filter: WebhookEventFilterInput, $first: Int) {\n webhookEvents(filter: $filter, first: $first) {\n nodes {\n id\n eventType\n eventVersion\n payload\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n totalCount\n }\n}",
"variables": {
"filter": {
"eventTypes": [
"content_view.item_added"
]
},
"first": 10
}
}' const response = await fetch("https://app.archive.com/api/v2", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID": "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a"
},
body: JSON.stringify({
query: `query WebhookEventsDefault($filter: WebhookEventFilterInput, $first: Int) {
webhookEvents(filter: $filter, first: $first) {
nodes {
id
eventType
eventVersion
payload
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}`,
variables: {
"filter": {
"eventTypes": [
"content_view.item_added"
]
},
"first": 10
}
})
})
console.log(JSON.stringify(await response.json(), null, 2)) require "json"
require "net/http"
query = <<~'GRAPHQL'
query WebhookEventsDefault($filter: WebhookEventFilterInput, $first: Int) {
webhookEvents(filter: $filter, first: $first) {
nodes {
id
eventType
eventVersion
payload
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
GRAPHQL
response = Net::HTTP.post(
URI("https://app.archive.com/api/v2"),
{ query: query, variables: { "filter" => { "eventTypes" => ["content_view.item_added"] }, "first" => 10 } }.to_json,
"Content-Type" => "application/json",
"Authorization" => "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID" => "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a"
)
puts response.body import json
import requests
query = """
query WebhookEventsDefault($filter: WebhookEventFilterInput, $first: Int) {
webhookEvents(filter: $filter, first: $first) {
nodes {
id
eventType
eventVersion
payload
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
"""
response = requests.post(
"https://app.archive.com/api/v2",
json={"query": query, "variables": {"filter": {"eventTypes": ["content_view.item_added"]}, "first": 10}},
headers={
"Authorization": "Bearer docs_demo_token_0000000000000000000000",
"WORKSPACE-ID": "6ccefa76-e8ba-5ab0-9c60-e48a9e235a4a",
},
)
print(json.dumps(response.json(), indent=2)) {
"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
| 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: 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
createWebhookSubscriptionreference — full input and payload shapes, plus the other six webhook mutations alongside it.webhookDeliveriesandwebhookEvents— the monitoring surface.webhookSubscriptionsand its paginated twinwebhookSubscriptionsConnection— list what’s subscribed; AI agents reach the same list through thegetWebhookSubscriptionsMCP tool.- Filtering & search: every filter and custom-attribute operator you can use in a view, and how saved views are evaluated.