Webhooks
Have us call you when work finishes, instead of polling for it — signed, retried, and auditable.
Polling /v1/jobs/{kind}/{id} works, but it makes every integration carry a timer,
and it scales badly once you track twenty domains. A webhook endpoint gets called the moment a job finishes.
Create one in the app under Settings → Webhooks, or over the API.
Events
| Type | When |
|---|---|
job.completed | A rank run, crawl, site-optimization analysis or AI-visibility scan finished successfully. |
job.failed | One of those ended in an error. data.job.error_detail says why. |
The live list is at GET /v1/webhook-events, unauthenticated — which events exist is part of the
contract.
Leave event_types empty to receive everything, including types added later. An
endpoint that lists types explicitly will not start receiving new ones, which is usually what you want for a
narrowly-scoped integration and occasionally a surprise.
Payload
POST https://example.com/hooks/picorank
Content-Type: application/json
Picorank-Signature: t=1754051400,v1=6f2c…
Picorank-Event-Id: 8f3c1e7a-…
Picorank-Event-Type: job.completed
Picorank-Delivery-Attempt: 1
{
"id": "8f3c1e7a-…",
"type": "job.completed",
"created_at": "2026-08-01T14:30:00Z",
"data": {
"job": {
"id": "b17d…",
"kind": "rank_run",
"status": "done",
"domain_id": "a342…",
"error_detail": null,
"finished_at": "2026-08-01T14:29:58Z"
}
}
} Events carry identifiers, not results. Fetch what you need with the API when one arrives — that keeps payloads small and stable, and means a webhook can never hand you data your key would not be allowed to read.
Verifying the signature
Every request carries Picorank-Signature: t=<unix timestamp>,v1=<hex>. The signature is
HMAC-SHA256 over <timestamp>.<raw body> using your endpoint's signing secret.
The timestamp is inside the signed string on purpose. Signing the body alone would let anyone who captured one request replay it forever and still verify. Check that the timestamp is recent — five minutes is a reasonable tolerance — and compare signatures in constant time.
import crypto from "node:crypto";
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
} Verify against the raw request body, before any JSON parsing. Re-serialising changes whitespace and key order, and the signature will not match.
# Python
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
try:
ts = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(int(time.time()) - ts) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", "")) Responding
- Return any 2xx to acknowledge. Anything else is a failure and will be retried.
- Respond within 10 seconds. Acknowledge first and do the work afterwards.
- Redirects are not followed — a redirect is treated as a failed delivery, not as a delivery elsewhere.
Retries and failure
A failed delivery is retried after roughly 1 minute, 5 minutes, 30 minutes, 2 hours, then 6 hours — six attempts spanning about eight hours, which survives an overnight outage without hammering a receiver that is merely down.
An endpoint that fails 20 times consecutively is disabled, with the reason recorded and shown in the app. A dead endpoint quietly consuming retries helps nobody. Any single success resets the count, so an endpoint that recovers is not punished for failures it has already come back from.
GET /v1/webhook-deliveries and the app's Webhooks page show recent attempts with the status code and
error we actually saw — the first thing to check when a receiver insists it heard nothing.
Handle duplicates
Deliveries are at-least-once. A receiver that times out after processing a request will be sent it
again, because we cannot tell that outcome apart from one that never arrived. Deduplicate on
Picorank-Event-Id, which is stable across retries of the same event.
Scope
An endpoint is either account-wide or bound to one domain. A domain-scoped API key can only create endpoints for its own domain — an account-wide endpoint would forward events for domains that key cannot read, which would be a scope escape dressed up as configuration.
The signing secret
Shown once, when the endpoint is created. It is stored in a form nobody can read back, so if it is lost the endpoint has to be recreated. Same bargain as an API key, for the same reason.
Testing
curl -X POST https://api.picorank.com/v1/webhooks/{id}/test \
-H "Authorization: Bearer $PICORANK_KEY" The test event goes through the same delivery path as a real one — same signature, same retries — so a green test proves the real thing rather than a simplified version of it.