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.

  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_••••••••••••••••" }

See the API reference

The full webhook management surface (fetch/rotate signing secret, list deliveries, retry failed ones) lives in the API Reference under the Webhooks tag.

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
refresh.candidateArticle ranking dropped past threshold — refresh recommended
refresh.completedContent refresh finished and republished

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.