Webhooks
Webhooks let SEO Ladders push event notifications to your service in real time — article readiness, batch completion, refresh candidates. Every delivery is signed with HMAC-SHA256 over the raw body, so you can verify authenticity without sharing secrets in URLs.
Setting up a webhook
You can manage webhooks entirely from the API. The signing secret is generated lazily on first fetch — no dashboard click-through required.
- Fetch your signing secret. Call
GET /api/v1/webhook-secretwith your bearer token. The secret is generated on first call and returned the same way on subsequent calls — store it alongside your API key. - Pick a delivery destination. Pass
webhookUrlon a per-batch basis (e.g., inPOST /api/v1/articles/batch) and we'll deliver events for that batch's articles to that URL. Must be HTTPS in production. - Verify on receipt. Every delivery includes the
X-SeoLadders-Signatureheader. Verify it server-side using the secret from step 1 — see the verification examples below.
# Fetch your signing secret
curl https://www.seoladders.com/api/v1/webhook-secret \
-H "Authorization: Bearer $SEOLADDERS_API_KEY"
# → { "secret": "whsec_••••••••••••••••" }See the API reference
Rotating the secret
Rotate the signing secret any time you suspect it's been exposed, or on a regular cadence as a hygiene measure. Coordinate with your receiver: in-flight deliveries continue to be signed with the old secret for a brief window, so your verifier should accept both old and new during the rotation.
curl -X POST https://www.seoladders.com/api/v1/webhook-secret/rotate \
-H "Authorization: Bearer $SEOLADDERS_API_KEY"
# → { "secret": "whsec_••••••new••••••" }Listing past deliveries
Useful when you want to audit what was sent, debug a missed delivery, or replay a failure. Returns deliveries newest-first with status (delivered, failed, pending) and the response your receiver sent.
curl https://www.seoladders.com/api/v1/webhook-deliveries?status=failed \
-H "Authorization: Bearer $SEOLADDERS_API_KEY"To replay a specific delivery, call POST /api/v1/webhook-deliveries/{id}/retry.
Event types
article.readySingle article finished generating, body ready to fetcharticle.failedArticle generation failed after retriesbatch.completedAll items in a batch finished (some may have failed individually)batch.partialBatch reached terminal state with some items failedoptimization.completedArticle optimization finished, score and breakdown readyrefresh.candidateArticle ranking dropped past threshold — refresh recommendedrefresh.completedContent refresh finished and republishedSample payload
Every event has the same envelope: id, type, createdAt, and a typed data payload that varies by event:
{
"id": "evt_01HXY...",
"type": "article.ready",
"createdAt": "2026-05-04T18:23:14.812Z",
"data": {
"articleId": "art_01HZ...",
"batchId": "btc_01HXY...",
"keyword": "best crm for startups",
"title": "Best CRM for Startups in 2026",
"wordCount": 3450,
"url": "https://www.seoladders.com/api/v1/articles/art_01HZ..."
}
}Headers
POST /your-webhook-endpoint HTTP/1.1
Host: yourapp.com
Content-Type: application/json
X-SeoLadders-Signature: t=1714867394,v1=a4f9d3e1...
X-SeoLadders-Event: article.ready
X-SeoLadders-Delivery: dlv_01HXY...
User-Agent: SeoLadders-Webhook/1.0X-SeoLadders-Signature— HMAC signature, formatt=<ts>,v1=<sig>X-SeoLadders-Event— event type (e.g.,article.ready)X-SeoLadders-Delivery— unique ID for this delivery attempt (use for idempotency)
Verifying signatures
Every webhook is signed with HMAC-SHA256 over the string <timestamp>.<raw-body> using your webhook signing secret. Verify before trusting the payload — and reject if the timestamp is more than 5 minutes from now to defeat replays.
import crypto from "node:crypto";
export function verifyWebhook(
rawBody: string,
signatureHeader: string,
secret: string,
toleranceSeconds = 300,
): boolean {
// Header format: "t=<timestamp>,v1=<signature>"
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=")),
);
const timestamp = Number(parts.t);
const expected = parts.v1;
if (!timestamp || !expected) return false;
// Reject replays older than the tolerance
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > toleranceSeconds) return false;
// Recompute the signature over "<timestamp>.<rawBody>"
const signed = `${timestamp}.${rawBody}`;
const computed = crypto
.createHmac("sha256", secret)
.update(signed)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(computed, "hex"),
);
}Use the raw body
Retries & ordering
- Retry policy — failed deliveries (non-2xx responses, timeouts) retry with exponential backoff: 1m, 5m, 30m, 2h, 6h. After 5 attempts the delivery is marked failed and pinged in the dashboard.
- Ordering — events are delivered in roughly chronological order, but retries may arrive out of order. Use
createdAtin the payload as your source of truth. - Idempotency — store the
X-SeoLadders-Deliveryheader. If you receive the same delivery ID twice (retries, network blips), skip the duplicate. - Timeout — endpoints have 10 seconds to respond with a 2xx status. If your handler does real work, ack quickly and process async.
Local testing
For local development, use a tunnel like ngrok or localtunnel to give your dev server a public HTTPS URL, then pass that URL aswebhookUrl on a batch request. Switch to your production URL when you ship.
Replay a delivery
POST /api/v1/webhook-deliveries/{id}/retry to replay any past delivery. Useful when you ship a fix to your webhook handler and want to re-process a missed event without waiting for a new one.