Every article Ranket generates is delivered to your CMS via a single signed HTTP webhook. This page is the complete contract — payload shape, signing scheme, retry behaviour, and example verifiers.
Endpoint requirements
Your endpoint must:
- Be reachable over HTTPS
- Accept
POSTrequests withContent-Type: application/json - Return a 2xx status to acknowledge receipt (we don’t read the response body, just the status)
- Respond within 30 seconds
Anything else (path, auth bearer tokens, header keys) is configurable — we send what you ask us to send.
Signing scheme
Every request is signed with HMAC SHA-256 using the secret generated when you registered the endpoint.
Headers:
X-Ranket-Signature: sha256=<hex digest>
X-Ranket-Timestamp: 2026-05-15T09:00:00Z
X-Ranket-Delivery-Id: evt_8f3b2c1a9d4e5f6789012345
X-Ranket-Event: article.published
Content-Type: application/json
Signature payload:
HMAC_SHA256(
secret,
timestamp + "." + raw_body
)
Always verify the signature before parsing the body to avoid trusting unauthenticated input.
The timestamp prefix prevents replay attacks. We recommend rejecting deliveries where X-Ranket-Timestamp is more than 5 minutes old.
Payload
{
"event": "article.published",
"delivery_id": "evt_8f3b2c1a9d4e5f6789012345",
"timestamp": "2026-05-15T09:00:00Z",
"brand": {
"id": "uuid",
"domain": "your-site.com",
"name": "Your Brand"
},
"article": {
"id": "uuid",
"title": "Real Estate HDR Photography: A Complete Guide for 2026",
"metaTitle": "Real Estate HDR Photography Guide 2026",
"metaDescription": "Master HDR photography for real estate listings. ...",
"slug": "real-estate-hdr-photography-guide",
"excerpt": "Step-by-step guide to capturing professional HDR ...",
"bodyMarkdown": "## Why HDR matters for real estate\n\n...",
"bodyHtml": "<h2>Why HDR matters for real estate</h2>\n...",
"faq": [
{ "question": "What is HDR photography?", "answer": "..." },
{ "question": "How many exposures do I need?", "answer": "..." }
],
"images": [
{
"url": "https://cdn.fal.media/files/.../hero.png",
"alt": "Real estate agent capturing HDR photo at sunset",
"prompt": "...",
"position": "hero"
},
{
"url": "https://cdn.fal.media/files/.../inline-1.png",
"alt": "...",
"position": "inline",
"insertAfterH2": "Step 1: Set up the camera"
}
],
"externalLinks": [
{ "url": "https://...", "title": "...", "type": "youtube", "reason": "..." },
{ "url": "https://...", "title": "...", "type": "reddit", "reason": "..." },
{ "url": "https://...", "title": "...", "type": "authority", "reason": "..." }
],
"internalLinks": [
{ "url": "https://your-site.com/features/photo-editor", "anchor": "AI photo editor" }
],
"backlinks": [
{
"url": "https://other-brand.com/blog/...",
"anchor": "...",
"type": "exchange"
}
],
"jsonLd": {
"@context": "https://schema.org",
"@graph": [
{ "@type": "BlogPosting", ... },
{ "@type": "FAQPage", ... },
{ "@type": "Person", ... },
{ "@type": "Organization", ... },
{ "@type": "WebApplication", ... },
{ "@type": "WebPage", ... },
{ "@type": "BreadcrumbList", ... },
{ "@type": "HowTo", ... }
]
},
"meta": {
"canonicalUrl": "https://your-site.com/blog/real-estate-hdr-photography-guide",
"ogTitle": "...",
"ogDescription": "...",
"ogImage": "https://cdn.fal.media/files/.../hero.png",
"ogType": "article",
"ogUrl": "https://your-site.com/blog/real-estate-hdr-photography-guide",
"ogArticlePublishedTime": "2026-05-15T09:00:00Z",
"ogArticleModifiedTime": "2026-05-15T09:00:00Z",
"twitterCard": "summary_large_image",
"twitterTitle": "...",
"twitterDescription": "...",
"twitterImage": "https://cdn.fal.media/files/.../hero.png",
"readTimeLabel": "16 min read"
},
"wordCount": 3214,
"readTimeMinutes": 16,
"targetKeyword": "real estate hdr photography",
"primaryKeyword": "real estate hdr photography"
}
}
bodyHtml is pre-rendered from bodyMarkdown for receivers that don’t want to run a Markdown parser. Most receivers should prefer bodyMarkdown and render it with their own parser to match site styling.
Verifier examples
import crypto from 'node:crypto';
function verifyRanketSignature(req, secret) {
const signature = req.headers['x-ranket-signature'];
const timestamp = req.headers['x-ranket-timestamp'];
if (!signature || !timestamp) return false;
// Reject deliveries older than 5 minutes (replay protection).
const ageMs = Date.now() - new Date(timestamp).getTime();
if (ageMs > 5 * 60 * 1000) return false;
const rawBody = req.rawBody; // depends on framework; see notes below
const payload = `${timestamp}.${rawBody}`;
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(payload).digest('hex');
// Timing-safe comparison — never use ===.
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
} import hmac
import hashlib
from datetime import datetime, timezone
def verify_ranket_signature(headers, raw_body, secret):
signature = headers.get("X-Ranket-Signature")
timestamp = headers.get("X-Ranket-Timestamp")
if not signature or not timestamp:
return False
age = datetime.now(timezone.utc) - datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
if age.total_seconds() > 300:
return False
payload = f"{timestamp}.{raw_body}".encode()
expected = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected) function verify_ranket_signature($headers, $raw_body, $secret) {
$signature = $headers['X-Ranket-Signature'] ?? null;
$timestamp = $headers['X-Ranket-Timestamp'] ?? null;
if (!$signature || !$timestamp) return false;
$age = time() - strtotime($timestamp);
if ($age > 300) return false;
$payload = "{$timestamp}.{$raw_body}";
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature);
} package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"time"
)
func verifyRanketSignature(r *http.Request, rawBody []byte, secret string) bool {
sig := r.Header.Get("X-Ranket-Signature")
ts := r.Header.Get("X-Ranket-Timestamp")
if sig == "" || ts == "" {
return false
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil || time.Since(t) > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "." + string(rawBody)))
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
} Important: all examples use the raw body bytes, not a parsed JSON object. If your framework parses JSON before exposing the body, you need to capture the raw bytes earlier in the request lifecycle. In Express, use express.raw({ type: 'application/json' }); in FastAPI, use await request.body(); in Laravel, use $request->getContent(); in Go, read request.Body before any middleware consumes it.
Retry policy
Failed deliveries (non-2xx response or 30-second timeout) are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
After 4 failures the delivery is marked failed and surfaced in the dashboard. The article remains ready and can be manually redelivered.
We do not retry on 2xx responses, even if the receiver returns a body like { "error": "..." }. Return a 4xx/5xx if you want a retry.
Idempotency
Every delivery has a unique X-Ranket-Delivery-Id (also in the body). The same article can be redelivered after a failure (which produces a NEW delivery ID for that attempt), or manually retried from the dashboard (also a new delivery ID).
To deduplicate at your CMS:
- Use
delivery_idas an idempotency key on your insert - Or use
article.idif you want to upsert by article identity (replacing on redelivery)
We never reuse a delivery_id. Two POSTs with the same ID = same network event (typically caused by a load balancer retry on your side, not ours).
Testing your endpoint
In the dashboard, click Send test on any registered endpoint. This sends a static sample payload with valid HMAC signing. Use it to:
- Confirm your verifier accepts our signature
- Test your CMS mapping with a known-good shape
- Reproduce edge cases (long content, many internal links, no FAQ block)
Test deliveries are clearly flagged with "event": "article.test" and a fixture article. Don’t publish them.
Disabling a webhook
In brand settings → CMS webhooks, click Disable on any endpoint. Disabled endpoints stop receiving new deliveries but retain their delivery history. Re-enabling resumes deliveries for new articles only — articles already delivered or failed during the disabled window are not re-attempted automatically.
Multiple endpoints per brand
You can register more than one endpoint per brand. Common patterns:
- One endpoint → your CMS (publishes the article)
- Another endpoint → Slack / Notion / a logging service (audit trail)
- Another endpoint → a staging environment for QA before promoting to prod
Each endpoint has its own secret and delivery log. The article is generated once and delivered to each endpoint in parallel.