Skip to content
Enjoy 20% off for 12 months on all plans. Use promo code: HYPERGROWTHClaim it

Receive articles by webhook

Build an endpoint that receives each published article as JSON, verify our signature, and dedupe retries so one article never becomes two posts.

Updated 2026-08-22

This page is for developers building the endpoint on the other end. Everything here is enforced by tests, not aspirational: the header names are asserted against the constants we actually send, and the retry behavior against the queue's own MAX_ATTEMPTS.

The request

POST <your url>, Content-Type: application/json.

FieldNotes
idOur article id. Stable across retries — dedupe on this.
eventcontent.published
title, slug, statusstatus is publish or draft
excerpt, keyword
metaTitle, metaDescriptionThe in-app Meta tags editor's values
contentHtml, contentMarkdownThe same article, both ways
jsonLdReady-to-embed <script type="application/ld+json">, or "". Its own field rather than spliced into contentHtml, because it almost always belongs in <head>
sentAtISO timestamp of this attempt. Changes on every retry — never key on it

Verify the signature

We sign deliveries only when a signing secret is configured on the connection. Two headers arrive with each one:

  • x-sb-signature: sha256=<hex>
  • x-sb-timestamp: milliseconds since epoch

The signed string is `${timestamp}.${raw request body}`, HMAC-SHA256 with your secret. Verify against the raw body, before any JSON parse-and-restringify — key order isn't guaranteed to survive a round trip.

const expected = "sha256=" + crypto
  .createHmac("sha256", SECRET)
  .update(`${req.headers["x-sb-timestamp"]}.${rawBody}`)
  .digest("hex");
// constant-time compare, e.g. crypto.timingSafeEqual

The timestamp is inside the signed string on purpose: signing the body alone would let a captured signature be replayed onto a fresh timestamp.

Retries, and why id matters

A failed delivery is attempted up to 4 times in total — the first send plus up to 3 retries. A delivery counts as failed if we don't receive a 2xx — which includes the case where your endpoint stored the article successfully but the response was lost to a timeout or a dropped connection.

So your endpoint will sometimes receive the same article twice.

  • Dedupe on id. It's identical across every attempt for one article.
  • Treat a repeat as an update, not a new post. A re-publish after an edit also keeps the same id — that's deliberate, and it means "update the post you already have".
  • Don't key on sentAt (it changes per attempt), or on title or slug (they're content, and both can legitimately change on a re-publish).

Respond 2xx as soon as the article is stored. If you need to do slow work, store first and queue the work.

Tell us where it landed (optional)

If your response body is JSON containing id and/or url, we record them: url becomes the View published post link and is what our publish verification fetches to confirm the page is reachable.

{ "id": "your-post-id", "url": "https://example.com/blog/the-post" }

Redirects

We do not follow redirects on a signed delivery — a redirect would replay your signed body to a host you didn't configure. Configure the final URL.


Why does my endpoint receive test events?

Two sources, both expected. Pressing Test at connect time sends one, and the nightly connection-health sweep sends one to prove the endpoint is still reachable. They carry no article content, are signed like real deliveries when a secret is configured, and must get a 2xx.

How quickly do retries arrive?

Roughly an hour apart, not seconds. The publish queue drains hourly, so plan for a repeat an hour later, not a hot retry loop. A manual publish from the app is a single immediate attempt with no automatic retry.

How long do I have to respond?

15 seconds. After that the delivery counts as failed on our side even if your endpoint stored the article — exactly the case dedupe on id exists for. Store, respond 2xx, then do slow work.

Do I have to return a body?

No. Any 2xx is a complete acknowledgement — an empty body is fine. Returning JSON with id and url is purely additive: it gives you the view link and lets verification confirm the page.

Was this page helpful?

Still stuck?

Email us and the team replies from the same address. If you're signed in, Ask anything in the app answers from these guides.