Blog webhook reference

For developers: the request AIM sends for each article, every field and what to save, signatures, retries, and example code.

This page is for the developer who builds the endpoint that receives articles from AIM and publishes them on your site. For the settings in AIM itself, see Website blog.

On WordPress? You do not need to build anything. Install the AIM Blog Publisher plugin, which does everything on this page for you.

When Send finished articles to my website is on under Integrations → Website blog, AIM sends each finished article to the address you entered there as an HTTPS POST. Your endpoint decides what to do with it: save it as a draft, publish it, or put it in your own review queue.

The request #

POST https://your-site.com/your-endpoint
Content-Type: application/json
X-API-Key: <the key you entered in AIM>
X-AIM-Timestamp: 1791792000
X-AIM-Signature: sha256=5f2c…           (only when you created a signing secret)
X-AIM-Delivery-Id: 2f0b6c0e-…           (the same for every attempt at one article)
X-AIM-Attempt: 1
{
  "event": "article.publish",
  "test": false,
  "article": {
    "id": 812,
    "title": "How to keep sourdough fresh",
    "slug": "keep-sourdough-fresh",
    "excerpt": "Four ways to keep a loaf fresh for five days.",
    "markdown": "# How to keep sourdough fresh\n\n…",
    "html": "<h1>How to keep sourdough fresh</h1>…",
    "meta_title": "Keep sourdough fresh",
    "meta_description": "Four ways to keep a loaf fresh.",
    "primary_keyword": "keep sourdough fresh",
    "category": "Guides",
    "word_count": 1320,
    "cover_image_url": "https://…/cover.png",
    "schema_jsonld": "{ … }",
    "sources": [{ "title": "…", "url": "https://…" }]
  },
  "workspace": { "id": 12, "name": "Maple & Rye" },
  "sent_at": "2026-10-12T14:00:00+00:00"
}

The body is UTF-8 JSON. Slashes and non-English letters are not escaped, so read it as UTF-8. Allow at least 2 MB.

The fields, and what to save #

Top level #

Field Type What it is
event string article.publish for a real article, article.test for the test button. Ignore any other value.
test boolean true only for the test button. Never publish a test.
article object The article. See below.
workspace object id (integer) and name (string) of the AIM workspace that sent it. Useful when one endpoint serves several workspaces. Not sent with a test.
sent_at string When this attempt was sent, ISO 8601 with an offset (2026-10-12T14:00:00+00:00). It changes on a retry, so do not use it as the publish date of a retried article.

article #

"Always" means the field is present and not null on every real article. Every other field is present but may be null, so make those columns nullable.

Field Type Always What it is What to do with it
id integer yes AIM's id for the article. It never changes, including on a retry or when the article is sent again after an edit. Save it, with a unique index. It is how you recognize an article you already have.
title string, up to 250 characters yes The headline. Save as the post title and the page's <h1>.
slug string yes Lowercase letters, digits, and hyphens (keep-sourdough-fresh). Use as the URL path. Check it against your own posts first: AIM does not know your existing slugs.
excerpt string, about 600 characters at most no A one or two sentence summary. Save for list pages, cards, and feeds.
markdown string yes The whole article as Markdown: headings, lists, links, a FAQ section. Save it if you render Markdown yourself or want to edit the article later.
html string yes The same article rendered to HTML. Raw HTML and unsafe links have been removed, so it holds no <script>, <iframe>, or inline styles. Save it if you want to place the article straight into a page. You need markdown or html, not both.
meta_title string, up to 60 characters no The title for search results. <title> and og:title. Fall back to title.
meta_description string, up to 155 characters no The description for search results. <meta name="description"> and og:description. Fall back to excerpt.
primary_keyword string no The search phrase the article was written for. Optional. Useful as a tag or for your own reporting.
category string no A suggested category, such as Guides. It is free text, not one of your categories. Map it to one of your own categories, or ignore it.
word_count integer no The number of words in the article. Optional. Reading time is about word_count / 200 minutes.
cover_image_url string (URL) no A cover image hosted by AIM. Download it and store your own copy. Do not link to AIM's address from your page: it may not stay there. Use the copy as the header image and og:image.
schema_jsonld string no Structured data for search engines, as a JSON-LD document encoded as a string. Check that it parses as JSON, then print it in the page inside <script type="application/ld+json">. Skip it if it does not parse.
sources array yes (may be empty) What the article drew on. Each entry is usually { "title": "…", "url": "https://…" }. An article written from a short suggestion can carry plain strings (headlines) instead. Optional. Show them as a references list, and handle both shapes.

Two things about the body:

  • markdown and html begin with the article's headline as a level-one heading. It is usually the same as title and can differ by a few words. If your page template prints title as its own <h1>, remove the first <h1> from the body so the page does not have two.
  • Links inside the body point to pages on the workspace's own website and to the sources. They are ordinary links; nothing needs rewriting.

AIM may add fields later. Ignore any field you do not know instead of rejecting the request.

What to keep #

At the least, keep id, title, slug, the body (markdown or html), and the X-AIM-Delivery-Id header. Keep excerpt, meta_title, meta_description, category, schema_jsonld, sources, and your own copy of the cover image when your site has a place for them.

When an article.id you already have arrives again, update that article instead of adding a second one. That covers both a retry and an article the workspace edited and sent again.

The test request #

The "Save and send a test" button sends event: "article.test" and test: true. Its article holds only id (always 0), title, slug, excerpt, markdown, and html, and there is no workspace. It is signed like any other request. Answer 2xx and save nothing.

Your answer #

You answer AIM does
Any 2xx Marks the article as sent. If the body is JSON with a url, AIM links to that page: { "url": "https://your-site.com/blog/keep-sourdough-fresh" }
408, 425, 429, any 5xx, or no answer within 20 seconds Tries again later (see below)
Anything else, including 401, 403, 404, and redirects Stops. The article goes back to review in AIM with the reason

Answer quickly. If publishing takes time, store the article, answer 202, and publish afterwards.

Retries, and not publishing twice #

When your site is down or busy, AIM tries again after 5 minutes, 30 minutes, 2 hours, and 6 hours: five attempts in about eight and a half hours. After the last one the article returns to review in AIM, and the workspace is emailed.

A retry can arrive after your site already saved the article (for example, your answer was lost on the way). Every attempt for one article carries the same X-AIM-Delivery-Id, and article.id does not change. Store one of them and answer 200 without publishing again when you have seen it before.

Checking that a request is from AIM #

There are two checks. Use both.

  1. The key. Compare the X-API-Key header with the key you entered in AIM.
  2. The signature. Create a signing secret under Integrations → Website blog. It is shown once; store it as a server-side secret. From then on every request carries X-AIM-Signature.

The signature is an HMAC-SHA256, in hex, of the timestamp, a dot, and the request body exactly as it was received:

signature = HMAC_SHA256(secret, X-AIM-Timestamp + "." + raw body)
header    = "sha256=" + signature

To verify:

  1. Read the raw body, before any JSON parsing. A parsed and re-encoded body will not match.
  2. Reject the request when X-AIM-Timestamp is more than 5 minutes from your server's time. This stops an old, captured request from being sent again.
  3. Compute the HMAC and compare it to the header with a constant-time comparison.

JavaScript (Node.js and Express) #

import crypto from "node:crypto";
import express from "express";

const API_KEY = process.env.AIM_API_KEY;               // the key you entered in AIM
const SIGNING_SECRET = process.env.AIM_SIGNING_SECRET; // whsec_… from AIM, shown once

function verifyAimRequest({ rawBody, timestamp, signature, secret, toleranceSeconds = 300 }) {
  if (!timestamp || !signature || !signature.startsWith("sha256=")) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;

  const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const given = signature.slice("sha256=".length);

  if (given.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(given, "hex"), Buffer.from(expected, "hex"));
}

const app = express();
const seen = new Set(); // use your database in production

// express.raw keeps the body as bytes, which is what was signed.
app.post("/api/aim/articles", express.raw({ type: "application/json", limit: "2mb" }), async (req, res) => {
  const rawBody = req.body.toString("utf8");

  if (req.get("X-API-Key") !== API_KEY) return res.status(401).json({ error: "bad key" });

  const signed = verifyAimRequest({
    rawBody,
    timestamp: req.get("X-AIM-Timestamp"),
    signature: req.get("X-AIM-Signature"),
    secret: SIGNING_SECRET,
  });
  if (!signed) return res.status(401).json({ error: "bad signature" });

  const { event, test, article } = JSON.parse(rawBody);
  if (test || event !== "article.publish") return res.status(200).json({ ok: true });

  // The same delivery id means a retry of something already handled.
  const deliveryId = req.get("X-AIM-Delivery-Id");
  if (seen.has(deliveryId)) return res.status(200).json({ ok: true, duplicate: true });
  seen.add(deliveryId);

  const url = await publishToYourBlog(article); // your code
  res.status(201).json({ url });
});

Replacing the secret #

"Replace secret" in AIM creates a new one and shows it once. Requests are signed with the new secret from that moment, so requests fail the check until your site has it. To switch without a gap, have your endpoint accept either secret for a short time:

const secrets = [process.env.AIM_SIGNING_SECRET, process.env.AIM_SIGNING_SECRET_OLD].filter(Boolean);
const signed = secrets.some((secret) => verifyAimRequest({ rawBody, timestamp, signature, secret }));

Deploy that with the new secret, replace it in AIM, then remove the old one.

Checklist #

  • The endpoint is https and reachable from the internet, with no redirect.
  • It compares X-API-Key.
  • It verifies X-AIM-Signature against the raw body and rejects old timestamps.
  • It ignores a X-AIM-Delivery-Id it has already handled.
  • It accepts article.test without publishing or saving.
  • It saves article.id with a unique index and updates the same row when that id arrives again.
  • It accepts null in every optional field and ignores fields it does not know.
  • It stores its own copy of the cover image.
  • It answers within 20 seconds with a 2xx, and returns { "url": "…" } when it knows the page address.