Webhooks

Webhooks let SEOLadders push event notifications to your service in real time — article readiness, batch completion, optimization results. 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.

  1. Fetch your signing secret. Call GET /api/v1/webhook-secret with your bearer token. The secret is generated on first call and returned the same way on subsequent calls — store it alongside your API key.
  2. Pick a delivery destination. Pass webhookUrl on a per-batch basis (e.g., in POST /api/v1/articles/batch) and we'll deliver events for that batch's articles to that URL. Must be HTTPS in production.
  3. Verify on receipt. Every delivery includes the X-SeoLadders-Signature header. 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_••••••••••••••••" }

Or do it from the dashboard

The Developers page reveals and rotates the signing secret, and lists recent deliveries with a retry on the failed ones — the same operations, without the calls below.

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.

bash
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.

bash
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 fetch
article.failedArticle generation failed after retries
batch.completedAll items in a batch finished (some may have failed individually)
batch.partialBatch reached terminal state with some items failed
optimization.completedArticle optimization finished, score and breakdown ready

Sample payload

Every event has the same envelope: id, type, createdAt, and a typed data payload that varies by event:

json
{
  "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

http
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.0
  • X-SeoLadders-Signature — HMAC signature, format t=<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

Verify against the raw, unparsed request body. If you parse JSON first and re-serialize, key ordering and whitespace will differ and the signature won't match.

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 createdAt in the payload as your source of truth.
  • Idempotency — store the X-SeoLadders-Delivery header. 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

Use 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.