Webhooks
Receive a signed HTTPS callback, or a Slack or Discord message, the moment a report or scheduled run finishes, instead of polling. Covers events, destinations, test deliveries, signature verification, retries and security.
Webhooks push an event to your server the moment something finishes, so you
don’t have to poll GET /v1/jobs/{id} in a loop. You register an HTTPS URL,
subscribe it to one or more events, and Rank Prompt POSTs a small signed JSON
payload to that URL whenever a matching event fires.
The endpoint CRUD lives under
/v1/brands/{brand_id}/webhooks in the API
reference. This page explains how delivery, signing and retries actually work.
Events
| Event | Fires when |
|---|---|
report.completed | A one-off report finished its analysis run. |
report.failed | A one-off report run failed. |
schedule.run.completed | A scheduled run finished (every region in the batch). |
schedule.run.failed | A scheduled run failed. |
The payload’s data block is intentionally minimal: it carries the ids and
a few headline counts so you can route the event, then you GET the full
resource for the detail. The shapes:
// report.completed
{
"report_id": "8a72d9f1-…",
"brand_id": "3f9c7e8a-…",
"scheduled_report_id": null, // set when the report came from a schedule
"status": "completed",
"total_prompts_count": 30,
"ranked_prompts_count": 17
}
// report.failed (no counts; carries a failure_reason instead)
{
"report_id": "8a72d9f1-…",
"brand_id": "3f9c7e8a-…",
"scheduled_report_id": null,
"status": "failed",
"failure_reason": "…"
}
// schedule.run.completed / schedule.run.failed
{
"scheduled_run_id": "…", // the batch id; filter reports with ?batch_id=
"scheduled_report_id": "…",
"brand_id": "3f9c7e8a-…",
"status": "completed", // or "failed"
"completed_regions_count": 3,
"total_regions_count": 3,
"failure_reason": "…" // present only on a failed run
}
On Agency Plus, the dashboard also registers account endpoints for the
lead widget (lead.created, lead.report_ready, lead.report_failed,
lead.claimed). They are managed in the app, not under /v1, and they use
the same delivery request, signature, retries and replay described below.
Their payloads are listed in
Leads and Webhooks.
Register an endpoint
Creating an endpoint needs the write:webhooks scope; reading needs
read:webhooks. The response includes the signing secret exactly once,
store it now.
curl -X POST https://api.rankprompt.com/v1/brands/{brand_id}/webhooks \
-H "Authorization: Bearer rp_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: webhook-report-pipeline" \
-d '{
"url": "https://api.yourbrand.com/hooks/rankprompt",
"events": ["report.completed", "report.failed"],
"description": "Prod report pipeline"
}'
The URL must be a public https:// address. Private, loopback and otherwise
non-routable targets are rejected, both at create time and again on every
delivery (a DNS-rebinding guard).
Post to Slack or Discord
Set destination_type to slack or discord and give the incoming webhook URL
of that platform (https://hooks.slack.com/services/… or
https://discord.com/api/webhooks/…; any other host is refused with
400 validation_error). Each event then arrives as a readable message, a Slack
Block Kit card or a Discord embed, with the brand, the headline values and a
link to open it, written in the brand owner’s language. The default,
generic, posts the signed JSON envelope described below. A PATCH can change
the type, as long as the URL matches the new type’s host.
curl -X POST https://api.rankprompt.com/v1/brands/{brand_id}/webhooks \
-H "Authorization: Bearer rp_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: webhook-slack-reports" \
-d '{
"destination_type": "slack",
"url": "https://hooks.slack.com/services/T000/B000/XXXX",
"events": ["report.completed", "schedule.run.failed"]
}'
Send a test
POST /v1/brands/{brand_id}/webhooks/{id}/test-deliveries with
{"event": "report.completed"} (an event the endpoint subscribes to) sends a
sample right away, through the same signer and client as a real delivery. The
envelope carries "test": true and an id that starts with evt_test_. The
answer says how your endpoint replied:
{
"object": "webhook.test_delivery",
"delivery_id": "0b4c…",
"event_id": "evt_test_9f1e…",
"event_type": "report.completed",
"status": "delivered",
"response_status": 200,
"duration_ms": 184,
"error": null
}
A test is logged with is_test: true, is never retried, never counts toward
auto-disable and cannot be replayed (send a new one instead). Up to 10 tests a
minute per endpoint; the next gets 429 rate_limit_exceeded.
The delivery request
Each delivery is a POST with a JSON body and these headers:
| Header | Meaning |
|---|---|
X-RP-Event | The event type, e.g. report.completed. |
X-RP-Event-Id | Stable id for the logical event (evt_…). Same across retries; use it to dedupe. |
X-RP-Delivery-Id | Id of this delivery. Stable across the automatic retries of the same delivery (a manual replay creates a new delivery with a new id and keeps the event id). |
X-RP-Signature | The HMAC signature (see below). |
User-Agent | RankPrompt-Webhook/1.0. |
The body is the event envelope:
{
"id": "evt_3f2a…",
"type": "report.completed",
"created_at": "2026-06-28T11:42:11.117421+00:00",
"api_version": "v1",
"data": { "report_id": "8a72d9f1-…", "brand_id": "3f9c7e8a-…", "status": "completed", "...": "..." }
}
Respond with any 2xx status as soon as you’ve accepted the event. Do the real
work asynchronously: only your response status line is read (the body is ignored),
and a slow handler eats into the delivery timeout and triggers a retry.
Verify the signature
Every delivery is signed so you can prove it came from Rank Prompt and wasn’t
tampered with. The X-RP-Signature header looks like:
t=1719573731,v1=2b8f…hex
v1 is HMAC-SHA256(secret, "{t}.{raw_request_body}"), hex-encoded, where t
is the unix timestamp in the same header. To verify:
- Split the header into
tand thev*signatures (v1, andv0during a rotation). - Recompute the HMAC over
"{t}.{body}"using your endpoint secret and the raw request body (verify before any JSON parsing or re-serialization, which would change the bytes). - Constant-time compare your computed value against each
v*value and accept if any matches. During a secret rotation the header carries bothv1(new secret) andv0(old secret); computing with your current secret matches whichever applies, so verification never breaks mid-rotation. - Reject deliveries whose
tis too old (e.g. more than 5 minutes) to blunt replay attacks.
import hashlib
import hmac
import time
def verify(secret: str, signature_header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
ts = parts.get("t")
if not ts:
return False
if abs(time.time() - int(ts)) > tolerance:
return False # stale; likely a replay
expected = hmac.new(secret.encode(), f"{ts}.{raw_body.decode()}".encode(), hashlib.sha256).hexdigest()
# Accept ANY v* signature: v1 = current secret, v0 = previous secret during the
# 24h rotation overlap. Whichever your current secret produces is the one that matches.
return any(
hmac.compare_digest(expected, val) for key, val in parts.items() if key.startswith("v")
)
Rotating the secret
POST /v1/brands/{brand_id}/webhooks/{id}/secret-rotations issues a new secret
(returned once) and keeps the previous one valid for a 24-hour overlap.
During that window deliveries are signed with both: v1 uses the new secret and
v0 uses the old one. Accept a delivery if either matches, deploy your new
secret, and the old one expires on its own. No downtime.
Retries and failures
A non-2xx response, a timeout, or a connection error is a failed attempt. We
retry with a fixed backoff: 30s, 2m, 10m, 1h, 6h, 24h (the initial attempt
plus six retries). A delivery that never succeeds ends up dropped.
After 20 consecutive failures the endpoint is auto-disabled: its
is_active flips to false, no further events are dispatched to it, and the
brand owner gets an in-app and email notification. Fix your handler, then
re-enable it:
curl -X PATCH https://api.rankprompt.com/v1/brands/{brand_id}/webhooks/{id} \
-H "Authorization: Bearer rp_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"is_active": true}'
Re-enabling resets the failure counter.
Inspect and replay deliveries
Every attempt is logged. List the recent history for an endpoint to debug:
curl https://api.rankprompt.com/v1/brands/{brand_id}/webhooks/{id}/deliveries \
-H "Authorization: Bearer rp_live_YOUR_KEY"
Each row carries status, attempt, response_status, error, is_test and
duration_ms (the round trip of the attempt, when timed). To force
another attempt of a past delivery (e.g. after fixing a bug), replay it:
curl -X POST \
https://api.rankprompt.com/v1/brands/{brand_id}/webhooks/{id}/deliveries/{delivery_id}/replays \
-H "Authorization: Bearer rp_live_YOUR_KEY" \
-H "Idempotency-Key: $(uuidgen)"
A replay is a new delivery with a new X-RP-Delivery-Id and the same X-RP-Event-Id and body as the first
delivery, so a receiver that dedupes on the event id skips an event it already processed. To run it again,
dedupe on the delivery id instead.
Delivery logs are retained for 90 days.
Limits
- 5 active endpoints per brand; up to 10 events per endpoint.
- 10 test deliveries a minute per endpoint.
- Delivery is rate-limited per endpoint and per brand: bursts past the cap are deferred and delivered shortly after, never dropped.
- Deliveries for one endpoint are sent in order; different endpoints are delivered in parallel.
What’s next
- Authentication: scopes (
read:webhooks,write:webhooks) and key setup. - Webhooks reference: every endpoint, request and response shape.
- Errors: the canonical error envelope.