Verify, always
Treat unsigned or wrong-signature payloads as a security incident — log, alert, drop.
Webhooks push events to your server the moment they happen — no polling, no missed events, no rate-limit pressure. Every delivery is HMAC-SHA256 signed so you can trust the payload came from us.
Nine event types, grouped by domain:
| Event | Fires when |
|---|---|
article.created | A new article record is created (draft or published) |
article.updated | Any field on an article changes |
article.published | Article transitions to PUBLISHED status |
article.deleted | Article is hard-deleted |
keyword.created | A keyword is added to a project |
keyword.ranked | New SERP ranking data lands for a tracked keyword |
generation.started | A generation job picks up from the queue |
generation.completed | A generation finishes successfully (article is ready) |
generation.failed | A generation fails — payload includes error details |
The full live list is also at GET /api/public/v1/webhooks/events if you want to enumerate them programmatically.
Expose a public HTTPS endpoint that accepts POST requests. For local development use ngrok or similar — webhook URLs must be reachable from the internet.
Register the webhook with the events you care about:
curl -X POST https://api.autoranq.ai/api/public/v1/webhooks \ -H "Authorization: Bearer $AUTORANQ_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/webhooks/autoranq", "events": ["generation.completed", "generation.failed"] }'const response = await fetch( 'https://api.autoranq.ai/api/public/v1/webhooks', { method: 'POST', headers: { Authorization: `Bearer ${process.env.AUTORANQ_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://your-app.example.com/webhooks/autoranq', events: ['generation.completed', 'generation.failed'], }), });const { data } = await response.json();// data.secret is shown only on creation — store it nowconsole.log('Webhook secret:', data.secret);import os, requests
response = requests.post( "https://api.autoranq.ai/api/public/v1/webhooks", headers={ "Authorization": f"Bearer {os.environ['AUTORANQ_KEY']}", "Content-Type": "application/json", }, json={ "url": "https://your-app.example.com/webhooks/autoranq", "events": ["generation.completed", "generation.failed"], },)data = response.json()["data"]# data["secret"] is shown only hereprint("Webhook secret:", data["secret"])Save the secret from the response. It’s a 64-character hex string used to verify every delivery. Like API keys, it’s shown only once — if you lose it, rotate via POST /webhooks/:id/rotate-secret.
Every event is delivered as POST to your URL with this body shape:
{ "id": "clxwhk123def456ghi789", "event": "generation.completed", "created_at": "2026-05-26T14:32:11.000Z", "data": { "generation_id": "clxgen123def456ghi789", "project_id": "clxprj123def456ghi789", "article_id": "clxart123def456ghi789", "keyword": "how to start a blog", "status": "COMPLETED" }}The data payload varies per event type. See the API Reference once it lands for per-event schemas.
Every delivery includes these headers:
| Header | Purpose |
|---|---|
X-Webhook-Event | Event type, e.g. generation.completed |
X-Webhook-Delivery-Id | Unique delivery ID — use for idempotency |
X-Webhook-Timestamp | ISO 8601 timestamp of the delivery attempt |
X-Webhook-Signature | sha256=<hex> — HMAC signature of the raw body |
Content-Type | Always application/json |
User-Agent | AutoRanq-Webhook/1.0 |
Always verify the signature before processing a webhook. Without verification, anyone who guesses your URL can submit fake events.
The signature is HMAC-SHA256(secret, raw_body), prefixed with sha256=. Compare using a constant-time function — never ==.
import express from 'express';import crypto from 'node:crypto';
const app = express();// Capture raw body for signature verificationapp.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); }}));
const SECRET = process.env.AUTORANQ_WEBHOOK_SECRET;
app.post('/webhooks/autoranq', (req, res) => { const signature = req.get('X-Webhook-Signature') || ''; const expected = 'sha256=' + crypto .createHmac('sha256', SECRET) .update(req.rawBody) .digest('hex');
const valid = crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected), );
if (!valid) return res.sendStatus(401);
// Idempotency: skip if you've seen this delivery ID before const deliveryId = req.get('X-Webhook-Delivery-Id'); console.log('Verified delivery', deliveryId, 'event', req.body.event);
res.sendStatus(200);});
app.listen(3000);import hmac, hashlib, osfrom flask import Flask, request, abort
app = Flask(__name__)SECRET = os.environ["AUTORANQ_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/autoranq")def webhook(): signature = request.headers.get("X-Webhook-Signature", "") expected = "sha256=" + hmac.new( SECRET, request.get_data(), hashlib.sha256 ).hexdigest()
if not hmac.compare_digest(signature, expected): abort(401)
# Idempotency: skip if you've seen this delivery ID before delivery_id = request.headers.get("X-Webhook-Delivery-Id") event = request.json["event"] print(f"Verified delivery {delivery_id}, event {event}")
return "", 200package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os")
var secret = []byte(os.Getenv("AUTORANQ_WEBHOOK_SECRET"))
func webhook(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) defer r.Body.Close()
mac := hmac.New(sha256.New, secret) mac.Write(body) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(r.Header.Get("X-Webhook-Signature")), []byte(expected)) { http.Error(w, "invalid signature", http.StatusUnauthorized) return }
deliveryId := r.Header.Get("X-Webhook-Delivery-Id") event := r.Header.Get("X-Webhook-Event") _ = deliveryId _ = event // process the payload from body w.WriteHeader(http.StatusOK)}If your endpoint returns anything other than a 2xx, or doesn’t respond within 30 seconds, we retry with exponential backoff:
| Attempt | Delay after previous |
|---|---|
| 1 (initial) | — |
| 2 | 5 seconds |
| 3 | 25 seconds |
| Final state | After 3 failed attempts, delivery is marked FAILED |
Each retry includes the same X-Webhook-Delivery-Id — your idempotency check should treat them as one logical delivery.
Inspect delivery history per webhook:
curl https://api.autoranq.ai/api/public/v1/webhooks/<id>/deliveries \ -H "Authorization: Bearer $AUTORANQ_KEY"Each delivery shows status (DELIVERED, FAILED, RETRYING), HTTP code received, and response body for debugging.
Use the POST /webhooks/:id/test endpoint to fire a sample payload at your URL without waiting for a real event:
curl -X POST https://api.autoranq.ai/api/public/v1/webhooks/<id>/test \ -H "Authorization: Bearer $AUTORANQ_KEY"The test payload uses the event type generation.completed with placeholder IDs but a real signature — useful for verifying your verification code before going live.
Verify, always
Treat unsigned or wrong-signature payloads as a security incident — log, alert, drop.
Idempotency keys
Use X-Webhook-Delivery-Id to dedupe — retries reuse the same ID, your queue insertions shouldn’t.
Respond fast
Acknowledge 2xx in <5 seconds. Push real work to a background queue.
Monitor failures
Periodically GET /webhooks/:id/deliveries?status=FAILED and alert on unexpected drops.
autoranq listen --forward-to localhost:3000/webhook for local-dev forwarding (coming in Phase 3)