Docs

Webhook integration

We publish directly to WordPress, Ghost, EmDash and git repositories. If you run something else, a webhook covers it: we POST each finished article to an endpoint you control, and you decide what happens next. It is about twenty lines in whatever framework your site already runs.

How it fits together

Connecting is a two-step handshake, and it has to be. We verify a connection by pinging your endpoint, and your endpoint cannot verify our signature until it holds the secret. So you generate the secret in Authoricy first, deploy it to your receiver, and then connect — at which point we ping, you answer, and the connection is stored. We never store an endpoint we have not successfully reached.

Headers on every delivery

x-authoricy-signatureHex HMAC-SHA256 over `${timestamp}.${rawBody}`, keyed with your signing secret.
x-authoricy-timestampUnix seconds. Signed, not merely sent — which is what stops a captured delivery from being replayed indefinitely. Reject anything more than five minutes from your clock.
x-authoricy-idempotency-keyStable for a given article across every retry. Derived from your site and the article slug rather than randomly, so it survives a restart between our attempt and our retry.

Events

pingSent when you connect and whenever the connection is re-tested. Answer with 2xx. Return { "name": "…" } to label the connection in your dashboard.
publishA finished article that has passed every publication check. Create or update the post and return { "id", "url" }.
unpublishCarries the postId you returned. Take the post down — whether that means unpublishing or deleting is your decision. Return 404 if you do not recognise the id and we will treat it as already withdrawn.

The publish payload

html is a CMS-ready fragment: no <html>, no <body>, and no <h1> — your template renders the title. schemaJsonLd is a JSON-LD string ready to drop into a <script type="application/ld+json">; skip it if your platform generates its own, because two descriptions of one page contradict each other.

{
  "event": "publish",
  "version": 1,
  "sentAt": "2026-08-01T09:00:00.000Z",
  "site": "https://yoursite.com",
  "article": {
    "title": "What Is Answer Engine Optimisation?",
    "slug": "what-is-answer-engine-optimisation",
    "html": "<h2>The short answer</h2><p>…</p>",
    "excerpt": "One sentence, under 160 characters.",
    "status": "publish",
    "categories": [],
    "tags": ["aeo", "seo"],
    "featuredImageUrl": null,
    "featuredImageAlt": "What Is Answer Engine Optimisation?",
    "publishedAt": null,
    "schemaJsonLd": "{\"@context\":\"https://schema.org\",…}"
  }
}

A working receiver

Two details in here are easy to get wrong and invisible once wrong: reading the raw body rather than a parsed one, and comparing signatures in constant time after checking their lengths. Copy them rather than reimplementing them.

import crypto from 'crypto'
import express from 'express'

const app = express()
const SECRET = process.env.AUTHORICY_WEBHOOK_SECRET

// express.raw, not express.json — the signature is computed over the exact
// bytes we sent, and a parsed-then-restringified body is not those bytes.
app.post('/authoricy', express.raw({ type: 'application/json' }), (req, res) => {
  const body = req.body.toString('utf8')
  const ts   = Number(req.get('x-authoricy-timestamp'))
  const sig  = req.get('x-authoricy-signature') ?? ''

  // 1. Reject replays. The timestamp is inside the signed material, so a
  //    delivery captured once cannot be replayed later.
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
    return res.status(401).end()
  }

  // 2. Verify. Constant-time compare, and check the length first —
  //    timingSafeEqual throws on a mismatch.
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${ts}.${body}`)
    .digest('hex')

  const a = Buffer.from(expected)
  const b = Buffer.from(sig)
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).end()
  }

  const event = JSON.parse(body)

  // 3. Answer the connection test.
  if (event.event === 'ping') {
    return res.json({ name: 'My blog' })
  }

  if (event.event === 'unpublish') {
    unpublish(event.postId)
    return res.json({ ok: true })
  }

  // 4. Deduplicate. The same key twice is the same article — a retry after a
  //    lost response, not a second publish.
  const key = req.get('x-authoricy-idempotency-key')
  const post = upsertPost(key, event.article)

  // 5. Tell us where it landed, so we can link it and withdraw it later.
  res.json({ id: post.id, url: post.url })
})

What we require of your endpoint

  • HTTPS. The payload carries your content and a signature computed with a shared secret; over plain HTTP both are readable by anything on the path.
  • A public address. We refuse any hostname that resolves into a private or reserved range, and we do not follow redirects — following one would re-issue a signed body to a host that never passed that check.
  • An answer within 30 seconds. We retry three times on a timeout, a 429 or a 5xx, with backoff. A 4xx is taken as your decision and is not retried.

Failure behaviour

If your endpoint never accepts an article, the article is not lost — it is stored against your domain and the run is recorded as a delivery failure with the reason, so it can be retried without paying to write it again. If you return no id, publishing still succeeds but the article cannot be withdrawn automatically later, and we say so at the time rather than letting you discover it.

Generate your signing secret under Settings → Integrations → Webhook. See what gets published.