Events and webhooks
Every Rankbox event type with payloads, how to register signed webhooks, verify the HMAC signature in TypeScript or Python, handle retries and poll GET /events.
On this page
Rankbox records an event whenever something happens on an account: a scan finishes, an article is written or published, credits run low, a payment fails. An agent hears about events in two ways: Rankbox posts them to a webhook URL, or the agent polls GET /events. Both deliver the same event objects.
Webhooks or polling
| Webhooks | Polling GET /events | |
|---|---|---|
| How | Rankbox sends an HTTPS POST to your URL | You call the API on a schedule |
| Latency | Seconds | Your polling interval |
| Needs | A public https endpoint | Nothing beyond the API |
| Security | Verify the Rankbox-Signature header | Your agent key |
| Missed events | Retried for about 2 days | Kept for 30 days; read from your last cursor |
| Best for | Agents with a server | Agents in a chat app, a CLI, an MCP client, a cron job |
Many agents use both: webhooks for speed, and a daily GET /events pass from the last cursor to catch anything a webhook outage missed.
The event object
{
"id": "4c1f7a0e-3b5d-4e9a-8c2f-7d6e5b4a3c21",
"type": "article.finished",
"created_at": "2026-10-02T15:25:31Z",
"account_id": "8b0d3c4e-2f6a-4f1b-9d3e-5a7c1e2b9f40",
"site_id": "6f1c2a9e-4b7d-4e2a-8c3f-1d9e5b7a2c64",
"data": {
"article": {
"id": "f2b8c1d4-7e3a-4b9c-a6d5-3e8f1a2b4c76",
"status": "finished",
"title": "How to choose a product analytics tool for B2B SaaS",
"slug": "how-to-choose-a-product-analytics-tool-for-b2b-saas-f2b8c1d4",
"keyword": "product analytics tool for b2b saas",
"seo_score": 100,
"published_url": null,
"updated_at": "2026-10-02T15:25:31Z"
},
"trigger": "api"
}
}| Field | Type | Description |
|---|---|---|
id | string | Unique event ID. Use it to ignore duplicates |
type | string | The event type, from the table below |
created_at | string | When it happened |
account_id | string | The account |
site_id | string or null | The site, or null for account-wide events such as billing |
data | object | What changed. Article objects in events leave out the bodies; fetch the article when you need them |
Event types
| Type | When it fires | data |
|---|---|---|
account.claimed | The owner claimed the account | account |
agent_access.created | An agent key was created or an OAuth app was connected | agent_key (without the secret) |
agent_access.revoked | A key was revoked or an app disconnected | agent_key |
site.scan_completed | A site scan finished | scan |
site.scan_failed | A site scan failed | scan with error |
site.added | A Studio site was paid for and is live | site |
site.removal_scheduled | A Studio site was set to stop at the end of the period | site with removes_at |
site.archived | A Studio site stopped | site |
article.created | An idea or scheduled article was added, by anyone | article, source (scan, research, api, dashboard) |
article.generated | Rankbox finished writing an article | article, job_id, trigger (api, autopilot, dashboard), credits_spent |
article.generation_failed | Writing failed and the credit was refunded | article_id, job_id, trigger, error, credits_refunded |
article.finished | An article became finished, written by Rankbox or by hand | article, trigger |
article.updated | A finished article's title, description, body or tags changed | article, changed (field names) |
article.published | An article was pushed to a destination or its live URL was recorded | article_id, source, url, result |
article.publish_failed | A push to Webflow or Shopify failed | article_id, destination, error |
article.deleted | An article was deleted | article_id, title |
autopilot.queue_empty | Autopilot was due but had nothing scheduled | site_id, weekly_cadence |
credits.low | A site's article or Reddit reply credits fell to 3 for the period | kind, remaining, period_end |
credits.exhausted | A site's article or Reddit reply credits reached 0 | kind, period_end |
credits.reset | A new period's credits were granted | credits |
integration.connected | The owner finished connecting a platform | integration |
integration.error | A push integration recorded an error | integration with last_error |
integration.disconnected | A push integration was disconnected, here or on the platform | integration |
backlink.verified | A link was verified live and its credits settled | placement |
backlink.lost | A live link went missing after repeated checks | placement |
reddit.sweep_completed | A Reddit sweep finished | threads_seen, opportunities_created |
reddit.reply_verified | A recorded reply was found live on Reddit | reply |
reddit.reply_removed | A recorded reply was removed or couldn't be found | reply |
billing.trial_started | The owner completed checkout and the trial began | billing |
billing.card_check_failed | The trial's $1 card check failed; generation is off until the card is updated | billing |
billing.activated | The first invoice was paid. Paid-only features open | billing |
billing.payment_failed | A payment failed. Generation continues for 48 hours | billing, grace_ends_at |
billing.canceled | Cancellation was scheduled for the end of the period | billing |
billing.ended | The subscription ended. Generation stops | billing |
webhook.test | You called POST /webhooks/{webhook_id}/test | webhook_id |
article.published source is one of webflow, shopify, site_key (a website or plugin reported the URL through the public API), agent (POST …/publish) or dashboard. New event types are added over time; ignore types you don't handle.
Payload examples
Generation failed
{
"id": "7e9a1c3e-5b7d-4f9a-8c1e-3d5f7a9b1c42",
"type": "article.generation_failed",
"created_at": "2026-10-03T09:04:12Z",
"account_id": "8b0d3c4e-2f6a-4f1b-9d3e-5a7c1e2b9f40",
"site_id": "6f1c2a9e-4b7d-4e2a-8c3f-1d9e5b7a2c64",
"data": {
"article_id": "1a3c5e7b-9d0f-4b2d-8f4a-6c8e0a2c4e93",
"job_id": "5f7b9d1a-3c5e-4a7c-9e1b-3d5f7a9c1e64",
"trigger": "autopilot",
"error": { "code": "generation_failed", "message": "The writer didn't return an article." },
"credits_refunded": 1
}
}The article went back to scheduled. Retry with POST …/generate, or let autopilot pick it up on its next run.
Article published
{
"id": "2b4d6f8a-0c2e-4b4d-9f6a-8c0e2b4d6f15",
"type": "article.published",
"created_at": "2026-10-02T16:02:44Z",
"account_id": "8b0d3c4e-2f6a-4f1b-9d3e-5a7c1e2b9f40",
"site_id": "6f1c2a9e-4b7d-4e2a-8c3f-1d9e5b7a2c64",
"data": {
"article_id": "f2b8c1d4-7e3a-4b9c-a6d5-3e8f1a2b4c76",
"source": "agent",
"url": "https://northwind.example/blog/how-to-choose-a-product-analytics-tool-for-b2b-saas",
"result": "recorded"
}
}result is recorded when a live URL was recorded, and created or updated for a push to Webflow or Shopify.
Credits low
{
"id": "9c1e3a5b-7d9f-4b1d-8a3c-5e7a9c1e3b26",
"type": "credits.low",
"created_at": "2026-10-06T00:12:09Z",
"account_id": "8b0d3c4e-2f6a-4f1b-9d3e-5a7c1e2b9f40",
"site_id": "6f1c2a9e-4b7d-4e2a-8c3f-1d9e5b7a2c64",
"data": { "kind": "articles", "remaining": 3, "period_end": "2026-10-09T15:20:44Z" }
}Fires once per kind per period. During the trial, this is the moment to tell the owner what happens on day 8, or to slow autopilot.
Payment failed
{
"id": "3d5f7b9c-1e3a-4c5e-8b7d-9f1b3d5f7a38",
"type": "billing.payment_failed",
"created_at": "2026-10-09T15:21:02Z",
"account_id": "8b0d3c4e-2f6a-4f1b-9d3e-5a7c1e2b9f40",
"site_id": null,
"data": {
"billing": {
"status": "past_due",
"paid": false,
"card_verified": true,
"trial_ends_at": "2026-10-09T15:20:44Z",
"current_period_end": "2026-11-09T15:20:44Z",
"cancel_at_period_end": false,
"past_due_since": "2026-10-09T15:21:00Z",
"plan": { "name": "Business", "amount": 49.5, "currency": "usd", "interval": "month" },
"studio_sites": 0,
"monthly_total": 49.5
},
"grace_ends_at": "2026-10-11T15:21:00Z"
}
}Create a portal link with POST /billing/portal and ask the owner to update the card before grace_ends_at.
Backlink verified
{
"id": "5f7a9c1e-3b5d-4f7a-9c1e-3b5d7f9a1c50",
"type": "backlink.verified",
"created_at": "2026-10-19T06:00:41Z",
"account_id": "8b0d3c4e-2f6a-4f1b-9d3e-5a7c1e2b9f40",
"site_id": "6f1c2a9e-4b7d-4e2a-8c3f-1d9e5b7a2c64",
"data": {
"placement": {
"id": "6a8c0e2f-4b6d-4e8a-9c1e-3f5b7d9a1c34",
"direction": "inbound",
"status": "live",
"target_url": "https://northwind.example/features/retention",
"anchor_used": "cohort retention tool",
"host_url": "https://partner.example/blog/saas-churn-playbook",
"credits": 2,
"live_at": "2026-10-19T06:00:00Z"
}
}
}Set up a webhook
- Build an endpoint that accepts
POSTrequests overhttps, reads the raw body, verifies the signature and answers2xxwithin 10 seconds. - Register it with the event types you want:
curl -X POST https://rankbox.xyz/api/agent/v1/webhooks \
-H "Authorization: Bearer $RANKBOX_AGENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://agent.northwind.example/hooks/rankbox",
"events": ["article.finished", "article.published", "article.generation_failed", "credits.low", "billing.payment_failed"],
"description": "Atlas"
}'- Save
webhook.secret(whsec_…) from the response in your secret store. It isn't shown again. - Send a test event and check that your endpoint returns
200:
curl -X POST https://rankbox.xyz/api/agent/v1/webhooks/5e3c1a9f-7b2d-4f8e-a1c3-6d4b2e9f8a17/test \
-H "Authorization: Bearer $RANKBOX_AGENT_KEY"Use "events": ["*"] to receive every type, including ones added later. An account can have 10 webhook endpoints. Each endpoint belongs to the agent key or OAuth connection that created it: revoking that credential disables its endpoints.
Delivery format
Each delivery is a POST with the event object as its JSON body and these headers:
| Header | Example | Meaning |
|---|---|---|
Content-Type | application/json | |
User-Agent | Rankbox-Webhooks/1.0 | |
Rankbox-Event-Id | 4c1f7a0e-3b5d-4e9a-8c2f-7d6e5b4a3c21 | Same as the body's id |
Rankbox-Event-Type | article.finished | Same as the body's type |
Rankbox-Delivery-Attempt | 1 | 1 for the first try, higher on retries |
Rankbox-Signature | t=1759418731,v1=5d41402abc4b2a76… | Timestamp and HMAC signature |
One delivery carries one event.
Verify signatures
Every delivery is signed with the endpoint's secret, so you can reject requests that didn't come from Rankbox. The Rankbox-Signature header has two parts, t (Unix seconds when Rankbox signed it) and v1 (the signature):
- Split the header on
,and each part on the first=, to gettandv1. - Build the signed payload:
t, a period, then the raw request body exactly as received, bytes unchanged. - Compute HMAC-SHA256 of the signed payload, using the whole secret string,
whsec_prefix included, as the key. Encode it as lowercase hex. - Compare it with
v1in constant time. - Reject the request if
tis more than 300 seconds from your clock, to stop replays.
Parse the body only after the signature checks out. Frameworks that parse JSON first change the bytes and break the signature.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyRankboxSignature(
rawBody: string,
header: string | null,
secret: string,
toleranceSeconds = 300,
): boolean {
if (!header) return false;
const parts = new Map(
header.split(",").map((part) => {
const i = part.indexOf("=");
return [part.slice(0, i).trim(), part.slice(i + 1).trim()] as const;
}),
);
const t = Number(parts.get("t"));
const v1 = parts.get("v1") ?? "";
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
// Next.js App Router: app/hooks/rankbox/route.ts
export async function POST(request: Request) {
const raw = await request.text();
const ok = verifyRankboxSignature(
raw,
request.headers.get("rankbox-signature"),
process.env.RANKBOX_WEBHOOK_SECRET!,
);
if (!ok) return new Response("invalid signature", { status: 400 });
const event = JSON.parse(raw) as { id: string; type: string; data: unknown };
// Record event.id first, then hand the work to a queue and answer at once.
return new Response(null, { status: 204 });
}import hashlib
import hmac
import time
def verify_rankbox_signature(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
if not header:
return False
try:
parts = dict(part.strip().split("=", 1) for part in header.split(","))
timestamp = int(parts["t"])
signature = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# Flask
from flask import Flask, request
import json, os
app = Flask(__name__)
@app.post("/hooks/rankbox")
def rankbox_webhook():
raw = request.get_data()
if not verify_rankbox_signature(raw, request.headers.get("Rankbox-Signature"), os.environ["RANKBOX_WEBHOOK_SECRET"]):
return "invalid signature", 400
event = json.loads(raw)
# Record event["id"], queue the work, answer at once.
return "", 204Responses, retries and disabling
Rankbox counts a delivery as received when your endpoint answers with any 2xx status within 10 seconds. Anything else (a 3xx, 4xx or 5xx, a timeout, a TLS or connection error) is a failure, and Rankbox tries again:
| Attempt | After the previous one |
|---|---|
| 1 | Immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours |
After 8 failed attempts, about 45 hours, Rankbox stops retrying that event; it stays readable in GET /events. Redirects aren't followed, so register the final URL.
If every delivery to an endpoint has failed for 3 days in a row, Rankbox disables it: enabled becomes false and disabled_reason says why. The owner gets an email. Fix the endpoint, then turn it back on with PATCH /webhooks/{webhook_id} and { "enabled": true }, and read what you missed from GET /events.
Answer quickly and do the work afterwards. A handler that writes an article summary to a chat before answering times out on a slow day.
Ordering, duplicates and idempotency
- At least once. An event can arrive more than once, for example when your
2xxwas lost. Store eachidyou've handled and skip repeats. - No ordering guarantee. Retries and parallel deliveries can arrive out of order:
article.publishedcan arrive beforearticle.finished. Usecreated_atto order events, and the resource's ownupdated_atto decide which version is newer. - Events are notifications. When you need the current state, read it from the API (
GET /sites/{site_id}/articles/{article_id}) rather than trusting an older event's payload. - Your actions trigger events too. An agent that generates an article receives
article.generatedandarticle.finishedfor it. Don't react to your own events by generating again.
Poll the event stream
GET /events returns events oldest first, with a next_cursor that always points just after the last event returned. Start with since once, then always pass cursor:
const BASE = "https://rankbox.xyz/api/agent/v1";
const headers = { Authorization: `Bearer ${process.env.RANKBOX_AGENT_KEY}` };
// Load the cursor you saved last time; on the very first run, start from a timestamp.
let cursor: string | null = await loadCursor();
let url = cursor ? `${BASE}/events?cursor=${cursor}` : `${BASE}/events?since=2026-10-02T14:00:00Z`;
for (;;) {
const res = await fetch(url, { headers });
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 5) * 1000));
continue;
}
const page = (await res.json()) as { events: { id: string; type: string }[]; next_cursor: string; has_more: boolean };
for (const event of page.events) await handle(event); // skip ids you've already handled
await saveCursor(page.next_cursor);
if (!page.has_more) break;
url = `${BASE}/events?cursor=${page.next_cursor}`;
}Poll every 1 to 5 minutes; more often only spends your request budget. Filter with types and site_id to fetch less. A cursor older than 30 days has nothing left to return, so start again from since.
Who receives which events
Events belong to the account. Every agent key and OAuth connection on the account can read every event with GET /events, whichever agent or person caused it. A webhook receives the types it subscribed to, for every site on the account; filter on site_id in your handler if you only care about one site.
Troubleshooting
- Every signature fails. You are hashing the parsed and re-serialized JSON instead of the raw body, or using the secret without its
whsec_prefix, or the secret of another endpoint. - Some signatures fail. Your server's clock is off. Sync it with NTP; the check allows 300 seconds.
- No deliveries arrive. Check
last_delivery_statusinGET /webhooks, and that the endpoint isenabled. Send a test event. Endpoints behind a login, a firewall that blocks unknown IPs, or a redirect never receive deliveries. - The endpoint was disabled. It failed for 3 days. Fix it, re-enable it with
PATCH, and catch up fromGET /events. - Events stopped after a key rotation. Endpoints belong to the credential that created them. Create the endpoints again with the new key before revoking the old one.
Related
- Agent API reference: the webhook and event endpoints.
- Quickstart for AI agents: subscribing in a new setup.
- Billing, credits and limits: what
credits.*andbilling.*events mean. - Permissions and guardrails:
agent_access.*events and the activity log. - Agent playbooks: an event-driven weekly loop.