Developer docs
The Deepage Publish API
Deepage writes the article, then publishes it into your site. WordPress uses our plugin. For any other site, a developer adds one URL that receives articles from Deepage; the complete receiver is below, ready to copy. Publishing straight into Shopify, Webflow, Ghost, Strapi or Sanity is on the roadmap.
What Deepage sends
One POST per article. The signature covers the raw body, so verify it before parsing.
POST https://yoursite.com/api/deepage
content-type: application/json
x-deepage-timestamp: 1730000000000
x-deepage-signature: 9f2c… // HMAC-SHA256(secret, "{timestamp}.{rawBody}")
{
"schemaVersion": 1, // integer, always 1
"event": "publish", // ping | publish | update | status
"sentAt": "2026-08-31T10:00:00.000Z",
"idempotencyKey": "b3f1…", // opaque string, ALWAYS present, same key = same article
"externalId": null, // null on publish · your id on update and status
// "article" is present ONLY on publish and update.
// On ping you get "challenge" instead. On status you get only "externalId".
// A receiver that destructures body.article unconditionally crashes on the
// first ping.
"article": {
"title": "string, required",
"slug": "string, CREATE ONLY — 1-120 chars, letters/numbers/hyphens",
"html": "<p>…</p> required, ready to render",
"markdown": "string, required — same content as source",
"jsonLd": { }, // object | NULL — schema.org for your <head>
"seoTitle": "string | NULL",
"metaDescription": "string | NULL",
"language": "English", // string | NULL — display name
"languageCode": "en", // string | NULL — BCP-47
"canonicalHints": { }, // object | NULL — {mode:"new"|"improve"}, safe to ignore
"coverImageBase64": "iVBORw0…" // string | NULL — base64 PNG, no data: prefix, you host it
}
}
// Every field marked NULL really does arrive as null. Guard before you
// dereference — an unguarded jsonLd.headline is a 500, and a 5xx is retried
// three times and then the article stays unpublished.What you must send back
{ "url": "https://yoursite.com/blog/the-slug", "externalId": "123" }The URL is not optional. Deepage will not mark an article published without the real address it is served at — a URL it cannot verify is one it cannot rank-track, heal or prove. If the article is not live, return an error instead and it stays honest in your dashboard.
The four rules
- Verify the signature over the raw body, in constant time, and reject timestamps older than five minutes.
- Echo
challengeonping— that is what the “Send test” button checks. - Be idempotent on
idempotencyKey(andexternalIdfor updates). A retried delivery must never create a second post. Treat the key as an opaque string — don’t assume it is a UUID or parse it. If your slugs are unique, checking the slug before you insert is the simplest safe dedupe. - Keep your own slug on update. Deepage only sends a slug when it first creates the article; after that the URL belongs to you and Deepage follows it.
Optional: status
Implement the status event and Deepage can follow a URL you change instead of only noticing the old one broke. Answer { exists, url, status } for an id — or { "exists": false } if it is gone. Without it, Deepage falls back to fetching the page and checking that your copy of the article is on it.
Don’t want to write it yourself?
Paste this into Claude, ChatGPT, Cursor or whichever assistant your team uses. Fill in the one line about your stack and it will produce a working receiver — it contains the whole contract, including the parts people usually get wrong (verifying the signature over the raw body, idempotency, and keeping your own slug).
Build an HTTP endpoint that receives articles from Deepage (deepage.io) and saves them to my site.
MY STACK: <describe it — framework/language, database or CMS, and where blog posts are stored>
Implement this contract EXACTLY. Do not simplify it and do not leave any step as a placeholder or a TODO. If you do not know my storage API, use a real one you can see in my codebase; if there is none, write the save with SQLite or a JSON file so the endpoint actually works end to end. A receiver that returns 500 on save is the single most common way this goes wrong.
════════ 1. SECURITY — do this first, exactly ════════
Every request carries:
x-deepage-timestamp: <milliseconds since epoch, as a string>
x-deepage-signature: <lowercase hex HMAC-SHA256 of `${timestamp}.${rawBody}` with a shared secret>
- Read the RAW request body as a string BEFORE any JSON parsing or body-parser
middleware. The signature covers the exact bytes; parsing and re-serialising
changes them and every signature fails. Express: express.raw(). Next.js route
handler: await req.text(). Fastify: a raw content-type parser.
- Compute HMAC-SHA256 of `${timestamp}.${rawBody}`, hex, and compare with a
CONSTANT-TIME comparison (crypto.timingSafeEqual), never ===.
- Reject if the timestamp is more than 5 minutes from now.
- On any failure: 401 and stop. Read the secret from an environment variable.
════════ 2. THE ENVELOPE ════════
{ schemaVersion: 1, event, sentAt, idempotencyKey, externalId, article?, challenge? }
- event is one of: ping | publish | update | status
- idempotencyKey is an OPAQUE STRING, present on every event. Do not parse it.
- externalId is null on publish, and your id on update and status.
- ⚠️ "article" is present ONLY on publish and update. On ping you get
"challenge"; on status you get only "externalId". Destructuring body.article
unconditionally will crash on the very first ping.
════════ 3. THE ARTICLE — every field, with nullability ════════
REQUIRED, never null: title (string), html (string), markdown (string)
CREATE ONLY: slug (string) — absent on update. 1-120 characters,
letters/numbers/hyphens; Unicode letters are allowed so an
Arabic slug is normal. Validate it, do not rewrite it, and
NEVER overwrite your own slug on update — the URL of a live
page is mine.
NULLABLE, all of them: jsonLd (object|null — schema.org for the <head>),
seoTitle (string|null), metaDescription (string|null),
language (string|null), languageCode (string|null),
canonicalHints (object|null — {mode:"new"|"improve"}, ignorable),
coverImageBase64 (string|null — base64 PNG, NO data: prefix,
you host it)
Guard every nullable field before dereferencing. An unguarded jsonLd.headline is
a 500, and a 500 costs me a published article.
════════ 4. IDEMPOTENCY — a requirement ════════
Deepage retries. A retried delivery MUST NOT create a second article.
- On update: look the article up by externalId.
- On publish: look for an existing article with the same idempotencyKey.
- If found, UPDATE it and return the SAME externalId and url with 200.
- Do NOT match on the slug: two articles can share one, and update omits it.
════════ 5. RESPONSES — exact shapes ════════
ping -> 200 { "challenge": <the challenge, echoed unchanged> }
publish -> 200 { "url": "<real live URL>", "externalId": "<my id as a string>" }
update -> 200 { "url": "<real live URL>", "externalId": "<same id>" }
status -> 200 { "exists": true, "url": "<current URL>" } or { "exists": false }
unknown event -> 400 { "error": "unknown event: <event>" }
The url must be the real, canonical, live address. Deepage FETCHES it afterwards
to confirm the article is actually there, so a made-up or not-yet-live URL fails
the publish. If the save did not work, return an error instead of a URL.
════════ 6. ERRORS AND WHAT THEY COST ════════
- 4xx -> Deepage does NOT retry, and shows my message verbatim. Be specific:
{ "error": "article.slug must be 1-120 letters, numbers or hyphens" }
- 5xx -> retried 3 times (0s, +2s, +8s) and then the article stays UNPUBLISHED.
- Wrap the save in try/catch and return 500 with the real reason in "error".
════════ 7. WORKED SAVE PATH — follow this shape ════════
This is the part that breaks. Adapt the storage calls to my stack; keep the logic.
const art = body.article ?? {}
if (!art.title || !art.html) return send(400, { error: 'article.title and article.html are required' })
if (art.slug !== undefined && !/^[p{L}p{N}-]{1,120}$/u.test(art.slug)) {
return send(400, { error: 'article.slug must be 1-120 letters, numbers or hyphens' })
}
// find existing: externalId first, then idempotencyKey. Never the slug.
let id = body.externalId ?? (await findByIdempotencyKey(body.idempotencyKey))?.id
const existing = id ? await findById(id) : null
if (!id) id = newId()
const record = {
id,
idempotencyKey: body.idempotencyKey,
slug: existing?.slug ?? art.slug ?? id, // keep MY slug on update
title: art.title,
html: art.html,
markdown: art.markdown ?? '',
jsonLd: art.jsonLd ?? null,
seoTitle: art.seoTitle ?? art.title,
metaDescription: art.metaDescription ?? '',
language: art.language ?? null,
languageCode: art.languageCode ?? null,
coverPng: art.coverImageBase64 ?? null,
}
try { await upsert(record) } catch (err) {
return send(500, { error: `could not save: ${err.message}` })
}
return send(200, { url: `${SITE_URL}/blog/${record.slug}`, externalId: String(id) })
════════ 8. DELIVER ════════
- The complete file(s) for my stack, with no TODOs and no invented library calls.
- The environment variables I must set.
- The exact URL to paste into Deepage's Connections settings.
- A one-line curl I can run to confirm the endpoint answers a ping.Or start from a receiver that already passes our tests. example-receiver.mjs is a complete, dependency-free Node receiver with real persistence. We generated it from the prompt above and then ran the live Deepage adapter against it — ping, publish, a repeated delivery, update, status, an article with every optional field null, and a wrong secret. Eight for eight. Swap its three storage functions for your database and the contract is already handled.
Whatever it writes, check two things before you ship: the signature is verified before the body is parsed, and a repeated delivery updates rather than duplicates. Then press Send test in Deepage — it runs the real signed handshake and tells you exactly what your endpoint answered.
A complete receiver
Node, no dependencies. Point the storage lines at your CMS and you are done.
import { createServer } from 'node:http'
import { createHmac, timingSafeEqual, randomUUID } from 'node:crypto'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
const SECRET = process.env.DEEPAGE_SECRET // never hard-code this
const SITE = process.env.SITE_URL // e.g. https://yoursite.com
const FILE = './articles.json'
const REPLAY_WINDOW_MS = 5 * 60_000
// ── storage. Replace these three with your database; the contract is the same.
const load = () => (existsSync(FILE) ? JSON.parse(readFileSync(FILE, 'utf8')) : {})
const save = (all) => writeFileSync(FILE, JSON.stringify(all, null, 2))
const urlFor = (a) => `${SITE}/blog/${a.slug}`
function verify(rawBody, timestamp, signature) {
if (!signature || !timestamp) return false
if (Math.abs(Date.now() - Number(timestamp)) > REPLAY_WINDOW_MS) return false
const expected = createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(String(signature))
return a.length === b.length && timingSafeEqual(a, b)
}
createServer((req, res) => {
const chunks = []
req.on('data', (c) => chunks.push(c))
req.on('end', () => {
const send = (code, obj) => {
res.writeHead(code, { 'content-type': 'application/json' })
res.end(JSON.stringify(obj))
}
// 1. VERIFY THE RAW BYTES, BEFORE PARSING. A body parser that reads and
// re-serialises the JSON changes the bytes and every signature fails.
const rawBody = Buffer.concat(chunks).toString('utf8')
if (!verify(rawBody, req.headers['x-deepage-timestamp'], req.headers['x-deepage-signature'])) {
return send(401, { error: 'invalid signature' })
}
let body
try { body = JSON.parse(rawBody) } catch { return send(400, { error: 'body is not JSON' }) }
// 2. ping — echo the challenge. NOTE: there is no `article` on a ping.
if (body.event === 'ping') return send(200, { challenge: body.challenge })
const all = load()
// 3. status — optional, but it lets Deepage FOLLOW a url you change.
if (body.event === 'status') {
const found = all[body.externalId]
return found ? send(200, { exists: true, url: urlFor(found) }) : send(200, { exists: false })
}
if (body.event !== 'publish' && body.event !== 'update') {
return send(400, { error: `unknown event: ${body.event}` })
}
// 4. THE SAVE PATH. Everything below is where receivers actually fail.
const art = body.article ?? {}
if (!art.title || !art.html) return send(400, { error: 'article.title and article.html are required' })
// Slug arrives on CREATE only — on update the URL is yours, keep it.
// Deepage sends 1-120 chars, letters, numbers and hyphens (Unicode letters
// included, so an Arabic slug is normal). Validate, do not rewrite.
if (art.slug !== undefined && !/^[\p{L}\p{N}-]{1,120}$/u.test(art.slug)) {
return send(400, { error: 'article.slug must be 1-120 letters, numbers or hyphens' })
}
// IDEMPOTENCY, and it is a requirement. Deepage retries; a retry must never
// create a second article. Match on externalId, else on idempotencyKey.
// Never on the slug — two articles can share one, and update omits it.
let id = body.externalId ?? Object.keys(all).find((k) => all[k].idempotencyKey === body.idempotencyKey)
const existing = id ? all[id] : null
if (!id) id = randomUUID()
// EVERY NULLABLE FIELD IS GUARDED. jsonLd, seoTitle, metaDescription,
// language, languageCode, canonicalHints and coverImageBase64 all really
// do arrive as null. Dereferencing one unguarded is a 500.
const record = {
id,
idempotencyKey: body.idempotencyKey,
slug: existing?.slug ?? art.slug ?? id, // keep YOUR slug on update
title: art.title,
html: art.html,
markdown: art.markdown ?? '',
jsonLd: art.jsonLd ?? null, // put in your <head> if present
seoTitle: art.seoTitle ?? art.title,
metaDescription: art.metaDescription ?? '',
language: art.language ?? null,
languageCode: art.languageCode ?? null,
coverPng: art.coverImageBase64 ?? null, // base64, no data: prefix — you host it
updatedAt: new Date().toISOString(),
}
try {
all[id] = record
save(all)
} catch (err) {
// A 5xx is retried 3 times and then the article stays unpublished. Say
// what broke — this sentence is shown to the customer.
return send(500, { error: `could not save: ${err.message}` })
}
// 5. Answer with where it REALLY lives. Deepage fetches this URL to confirm
// the article is there; a URL that does not serve it fails the publish.
send(200, { url: urlFor(record), externalId: String(id) })
})
}).listen(3000)Errors & retries
- 5xx / timeout — Deepage makes three attempts in total (immediately, then after 2s and 8s).
- 4xx — treated as a configuration problem and not retried; the message you return is shown to the customer verbatim, so make it useful.
- Rotating the secret — Settings → Connections → Rotate signing secret. The old one stops working immediately, so update your endpoint, then press Send test.