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.
| Field | Notes |
|---|---|
id | Our article id. Stable across retries — dedupe on this. |
event | content.published |
title, slug, status | status is publish or draft |
excerpt, keyword | |
metaTitle, metaDescription | The in-app Meta tags editor's values |
contentHtml, contentMarkdown | The same article, both ways |
jsonLd | Ready-to-embed <script type="application/ld+json">, or "". Its own field rather than spliced into contentHtml, because it almost always belongs in <head> |
sentAt | ISO 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 ontitleorslug(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.