Custom webhook

Connect your own app: the setup prompt for Cursor or Claude Code, the signing secret and rotation, the headers, the JSON body and the answer we expect.

For your own app or site. RankHabit sends each article as signed JSON to an endpoint on your site, and your site shows it on its blog.

Connect in four steps

  • Article settings → Publishing → Connect → Webhook.
  • Copy prompt for Cursor or Claude Code and paste it into your AI tool. It builds the endpoint, the signature check, a health check and the blog pages at your Blog path.
  • Check the endpoint. It starts as https://yoursite.com/api/rankhabit; change it only if you used a different path. It must be https.
  • Create the secret, add it where your site is hosted as RANKHABIT_SECRET, and redeploy.
  • Press Test connection. The connection is saved only when your endpoint answers the signed sample with a 2xx.

What the setup prompt asks for

  • A public POST route at your endpoint that reads the raw body and checks the signature.
  • Storage for posts keyed by article_id, and the delivery ids it has seen. If your project shares a database, new tables go in a new migration that only adds things, and the tool asks before applying it.
  • Blog pages at your Blog path (Article settings → Publishing), with each post's title, description, canonical and share tags.
  • GET /api/health that says whether rankhabitSecret is set (ok or missing), so you can check before testing.

The secret and rotating it

Each site has one secret, starting with rh_sec_. Only the workspace owner can create it. It's shown in full once, when it's made: copy it then. After that the sheet shows only its last 4 characters.

Rotate secret (you're asked first) makes a new one. We sign with the new secret at once, and for 1 hour we also send a signature made with the old one in X-RankHabit-Signature-Previous, so your site keeps working while you update RANKHABIT_SECRET and redeploy. After that the old secret stops working.

What each request looks like

HeaderValue
X-RankHabit-Signaturesha256= + the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your secret
X-RankHabit-TimestampUnix seconds when we signed it
X-RankHabit-Eventpublish, update or test
X-RankHabit-DeliveryThe delivery id (the body's id), the same on every retry
X-RankHabit-Signature-PreviousOnly for 1 hour after a rotation: the same signature with the old secret

There's no bearer token: the signature is the only proof. Accept a request when either signature header verifies, and refuse a timestamp more than 5 minutes old or ahead. This check runs as is in Node 18+, Deno, Bun and edge runtimes:

async function verifyRankHabitSignature(rawBody, timestampHeader, signatureHeader, secret) {
  // 1. The timestamp: whole seconds, at most 300 seconds (5 minutes) from now, so an old request can't be replayed.
  if (!/^\d{1,12}$/.test(timestampHeader || "")) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestampHeader)) > 300) return false;
  // 2. The signature: "sha256=" + hex HMAC-SHA256 of `${timestamp}.${rawBody}` with the secret.
  const match = /^sha256=([0-9a-f]{64})$/.exec(signatureHeader || "");
  if (!match) return false;
  const signature = new Uint8Array(32);
  for (let i = 0; i < 32; i++) signature[i] = parseInt(match[1].slice(i * 2, i * 2 + 2), 16);
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
  // crypto.subtle.verify compares in constant time (never compare signatures with ===).
  return crypto.subtle.verify("HMAC", key, signature, encoder.encode(timestampHeader + "." + rawBody));
}

The JSON body (an update or a test has the same fields; a test has "test": true):

{
  "event": "publish",
  "id": "5f0c6a8e-6f1e-4c55-9d1a-2b7f9c3e1a42",
  "test": false,
  "sent_at": "2026-10-23T09:00:04.000Z",
  "article_id": "b8a1d2c3-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
  "title": "Free Linktree alternatives",
  "slug": "free-linktree-alternatives",
  "html": "<p>…the article as HTML…</p>",
  "markdown": "…the same article as Markdown…",
  "excerpt": "Compare free link-in-bio tools.",
  "meta": {
    "title": "Free Linktree alternatives (2026)",
    "description": "Compare free link-in-bio tools.",
    "canonical": "https://example.com/blog/free-linktree-alternatives",
    "og_title": "Free Linktree alternatives (2026)",
    "og_description": "Compare free link-in-bio tools.",
    "og_image": "https://…/hero.png",
    "og_type": "article"
  },
  "cover_url": "https://…/hero.png",
  "cover_alt": "Free Linktree alternatives",
  "images": [{ "src": "https://…/body-1.png", "alt": "A diagram of the steps" }],
  "published_at": "2026-10-23T09:00:04.000Z",
  "site": { "id": "…", "url": "https://example.com", "name": "Example" },
  "blog_path": "/blog",
  "url": "https://example.com/blog/free-linktree-alternatives",
  "fields": [
    { "id": "category", "name": "Category", "type": "one", "value": "Guides" },
    { "id": "tags", "name": "Tags", "type": "many", "value": ["Creators", "Digital products"] }
  ]
}

What your endpoint answers

  • A test ("test": true): 200 and store nothing.
  • A delivery id you've already handled: 200 with the post's URL, and change nothing.
  • Otherwise insert or update the post by article_id (a slug that belongs to another article gets 409), then answer 200 with {"url": "<the post's public address>"}. Without a url we assume your site + Blog path + slug.
  • Answer within 15 seconds. 5xx, 429, timeouts and dropped connections are retried with the same delivery id; other 4xx answers and redirects are not.