U Ranket
⌘K

Webhooks

How Ranket delivers articles to your CMS. Payload spec, HMAC verification, retry policy, and verifier examples in JS / Python / PHP.

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 POST requests with Content-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:

AttemptDelay
1immediate
21 minute
35 minutes
430 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_id as an idempotency key on your insert
  • Or use article.id if 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.

Was this page helpful?