API publishing
Publish articles to your own site or CMS through signed API requests.
Papigrows sends signed POST requests to the HTTPS receiver you host. Your receiver saves and publishes the article, then replies with its public URL.
Configure API publishingConnect your publishing endpoint
Build a receiver that saves and publishes articles on your site. Add its endpoint and shared secret in CMS settings. Test and save sends connection.test with empty data; respond with HTTP 200 and ok: true. Only owners and admins can change this connection.
Use a public HTTPS endpoint without query parameters, fragments or embedded credentials.
A random secret is ready to use. You can replace it with your own 32 to 256 character secret. Copy it to your receiver and keep it on the server.
{
"version": 1,
"event": "connection.test",
"id": "<event-id>",
"data": {}
}
HTTP/1.1 200 OK
Content-Type: application/json
{ "ok": true }Let your receiver accept both the old and new secrets while you test and save. Papigrows keeps using the saved secret until the new connection passes its test and is saved. Retire the old secret after in-flight deliveries finish.
Request headers
These headers accompany both the connection test and article deliveries. Header names are case-insensitive.
| Header name | Example value | Meaning |
|---|---|---|
Content-Type | application/json | JSON request body. |
Accept | application/json | A JSON acknowledgement is expected. |
X-Papigrows-Event-Id | <event-id> | Matches the envelope id. |
X-Papigrows-Timestamp | <unix-seconds> | Unix time in seconds for this attempt. |
X-Papigrows-Signature | v1=<hex-hmac-sha256> | v1= followed by the hex HMAC-SHA256 of timestamp + '.' + the exact raw body. |
Idempotency-Key | <event-id> | The same stable envelope id across retries. |
Verify every request
Verify the v1 HMAC-SHA256 signature over timestamp + a dot + the exact raw body before parsing JSON. Compare signatures in constant time, reject timestamps more than five minutes old or in the future, and keep your secret on the server. HTTPS and a signature do not replace duplicate protection.
Request example
POST /your-publishing-endpoint
Content-Type: application/json
X-Papigrows-Event-Id: <event-id>
X-Papigrows-Timestamp: <unix-seconds>
X-Papigrows-Signature: v1=<hex-hmac-sha256>
Idempotency-Key: <event-id>
{
"version": 1,
"event": "article.upsert",
"id": "<event-id>",
"data": {
"id": "<permanent-article-id>",
"version": 7,
"title": "Example",
"html": "<p>...</p>",
"slug": "example",
"excerpt": "...",
"coverImageUrl": "https://example.com/cover.jpg",
"locale": "en",
"hreflangAlternates": [
{ "locale": "sv", "url": "https://example.com/sv/blog/example" }
]
}
}Body fields
These fields describe article.upsert. For connection.test, data is empty. The envelope protocol version is separate from the article content version.
| Field name | Value type | Included | Meaning |
|---|---|---|---|
version | integer | Always | Envelope protocol version: 1 for the original contract or 2 after an explicit receiver upgrade. |
event | string | Always | The event type: article.upsert or connection.test. |
id | string | Always | Stable delivery ID shared with both identity headers. Store it to prevent duplicate writes. |
data | object | Always | Event payload. Empty for connection.test. |
data.id | string | Always | Permanent ID for this one language variant. Upsert using this key. |
data.version | integer | Always | Stored content revision, a non-negative integer. Reject older revisions. |
data.title | string | Always | Article title. |
data.html | string | Always | HTML to publish, including available hreflang links. |
data.slug | string | When available | Suggested URL slug. Keep a stable public URL on later edits. |
data.excerpt | string | When available | Article excerpt, when available. |
data.coverImageUrl | string | When available | Cover-image URL, when available. |
data.locale | string | When available | Language code for this article variant. |
data.hreflangAlternates | array | When available | Other published language variants, when available. |
data.hreflangAlternates[].locale | string | For each alternate | Language code of the linked variant. |
data.hreflangAlternates[].url | string | For each alternate | Public URL of that published variant. |
Version 2 and compatibility
Version 2 includes plain text, workspace and audience IDs, summaries, sections and tags. Your receiver must explicitly confirm version 2 during its connection test. Existing receivers keep version 1 until you choose a different version and successfully test and save it.
{
"version": 2,
"event": "connection.test",
"id": "<event-id>",
"data": {}
}
HTTP/1.1 200 OK
Content-Type: application/json
{ "ok": true, "version": 2 }POST /your-publishing-endpoint
Content-Type: application/json
X-Papigrows-Event-Id: <event-id>
X-Papigrows-Timestamp: <unix-seconds>
X-Papigrows-Signature: v1=<hex-hmac-sha256>
Idempotency-Key: <event-id>
{
"version": 2,
"event": "article.upsert",
"id": "<event-id>",
"data": {
"id": "<permanent-article-id>",
"groupId": "<permanent-article-group-id>",
"workspaceId": "<owning-workspace-id>",
"primaryAudienceId": "<saved-audience-id>",
"businessAreaId": "<saved-business-area-id>",
"text": "...",
"summary": "...",
"section": "Advice",
"tags": ["Families"],
"version": 7,
"title": "Example",
"html": "<p>...</p>",
"slug": "example",
"excerpt": "...",
"coverImageUrl": "https://example.com/cover.jpg",
"locale": "en",
"hreflangAlternates": [
{ "locale": "sv", "url": "https://example.com/sv/blog/example" }
]
}
}Version 2 keeps the original article fields and adds these required fields. Nullable identities remain null for older articles without saved targeting.
| Field name | Value type | Meaning |
|---|---|---|
data.groupId | UUID | Stable group shared by language variants. |
data.workspaceId | UUID | Stored owning workspace ID. |
data.primaryAudienceId | UUID | null | Saved primary audience ID, or null for an untargeted older article. |
data.businessAreaId | UUID | null | Saved business area ID, or null when unavailable. |
data.text | string | Plain text derived from sanitized article HTML, not original Markdown. Links are not fetched. |
data.summary | string | null | Stored article summary, or null when absent. |
data.section | string | null | Section captured for this publication, or null. |
data.tags | string[] | Tags captured for this publication; an empty array means no tags. |
Native public feed
Enable the Papigrows API connection to expose published articles. Request version 2 explicitly; requests without a version retain the original response. Private workspaces and unpublished articles remain unavailable.
/api/content/feed/<workspace-slug>?version=2&locale=en&limit=20Each article includes its stored IDs, content version, plain text and summary. Section and tags come only from the captured native publishing destination. Tags are null when that evidence is unavailable. Keep version=2 and locale on every cursor request.
In a native feed, text is null when conversion is unavailable. textUnavailable is body_too_large above 1 MiB or conversion_failed for a conversion error; otherwise it is null. HTML and pagination remain intact.
Confirm publication
After the article is actually public, return HTTP 200 or 201 with its matching ID, status published and public HTTPS URL. A queue acknowledgement, HTTP 202 or 204, redirect, draft or invalid response does not count as publication. Do not acknowledge success before saving and publishing.
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "<permanent-article-id>",
"status": "published",
"url": "https://example.com/blog/example"
}Delivery and retries
An article.upsert request creates or updates one language variant using data.id as its permanent key. Store data.version and the event ID atomically with the write; duplicate events return the saved acknowledgement and older versions must never overwrite newer content. Delivery makes up to three attempts, each with a five-second deadline, retrying network failures, HTTP 429 and HTTP 5xx after 250 ms and 1 second. The body and event ID stay the same; each attempt has a fresh timestamp and signature.
Delivery bodies are limited to 1 MiB and JSON responses to 16 KiB. Bound the raw request body before parsing it.
What this integration covers
Articles only, including edits published again from Papigrows. Topic events, deletion events, background delivery queues and reading remote edits back are not included. Reply-to-edit cannot safely update a webhook destination; edit and publish in Papigrows instead. Existing plan, quality and automatic-publishing settings still apply. After an uncertain failure, check the receiver before a manual retry; there is no endless retry loop.
Signature verification example
This Node.js helper verifies authenticity only. Your receiver must also limit request size, validate the event schema, handle duplicates and versions in durable storage, publish the article, and return the acknowledgement below.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(headers, rawBody, secret) {
const timestamp = headers.get("x-papigrows-timestamp") ?? "";
const signature = headers.get("x-papigrows-signature") ?? "";
if (!/^\d{10}$/.test(timestamp)) return false;
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (age < 0 || age > 300) return false;
if (!/^v1=[a-f0-9]{64}$/.test(signature)) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + ".").update(rawBody).digest();
const received = Buffer.from(signature.slice(3), "hex");
if (received.length !== expected.length
|| !timingSafeEqual(received, expected)) return false;
try {
const event = JSON.parse(rawBody.toString());
return typeof event.id === "string"
&& /^[a-f0-9]{64}$/.test(event.id)
&& headers.get("x-papigrows-event-id") === event.id
&& headers.get("idempotency-key") === event.id;
} catch { return false; }
}