Webhooks
Armage calls your URL the moment a story is sent for review, approved, sent back, published or taken down. Each call is signed, so your endpoint can prove it came from Armage.
01
Add an endpoint
Coming Webhook delivery is built and not switched on yet. This page describes exactly how it will behave; the label changes when it is live.
- Where: the API page of the Armage workspace (app.arm-age.com/api), by an owner or admin, after a two-step sign-in code from the last 12 hours or single sign-on.
- The URL:
https://, a public host name, the default port. IP addresses,localhostand private names (.local,.internal) are refused. Redirects are not followed. - The secret: a
whsec_…signing secret is shown once, when you add the endpoint. Store it with your other secrets. - Test it: "Send test event" posts a signed
webhook.testevent and shows what your endpoint answered. - Up to 10 endpoints per organization, each with its own events and secret.
02
Events and payload
| Type | When |
|---|---|
story.submitted | A story was sent for review: it is now in the approval queue. Sent to every enabled endpoint subscribed to story.submitted, signed per Standard Webhooks. |
story.approved | A person approved the current version of a story. Sent to every enabled endpoint subscribed to story.approved, signed per Standard Webhooks. |
story.rejected | A person sent a story back with a note. Sent to every enabled endpoint subscribed to story.rejected, signed per Standard Webhooks. |
story.published | A story was published. Sent to every enabled endpoint subscribed to story.published, signed per Standard Webhooks. |
story.unpublished | A published story was taken down. Sent to every enabled endpoint subscribed to story.unpublished, signed per Standard Webhooks. |
The body is JSON. data.story is the story as it reads when the request is made (null if it is no longer there); fetch the full body with GET /v1/stories/{id}. Payloads are never stored: Armage rebuilds each one from the story.
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Armage-Webhooks/1 (+https://arm-age.com/developers/webhooks)
webhook-id: 3f6d2c1e-8a4b-4f0e-9d7a-2b5c6e1f0a93
webhook-timestamp: 1791450000
webhook-signature: v1,<base64 HMAC-SHA256 of "{webhook-id}.{webhook-timestamp}.{body}">
{
"id": "3f6d2c1e-8a4b-4f0e-9d7a-2b5c6e1f0a93",
"object": "event",
"type": "story.published",
"created_at": "2026-10-08T09:00:00.000Z",
"data": {
"story_id": "0b8e…",
"story": {
"object": "story",
"id": "0b8e…",
"type": "insight",
"slug": "what-changes-in-2027",
"status": "published",
"title": "What changes in 2027",
"summary": "…",
"version": 2,
"topics": ["healthcare"],
"published_at": "2026-10-08T09:00:00.000Z",
"created_at": "2026-10-01T14:12:00.000Z",
"updated_at": "2026-10-08T09:00:00.000Z"
}
}
}All fields: WebhookEvent in the reference.
03
Verify every request
Requests are signed per Standard Webhooks, whose published libraries also verify them. Check the signature on the raw body, before parsing it, and reject anything that fails.
| Header | Meaning |
|---|---|
webhook-id | The event id. Delivery is at least once: dedupe on it. |
webhook-timestamp | Unix seconds when this attempt was signed. Reject outside +-5 minutes. |
webhook-signature | v1,<base64 HMAC-SHA256 of "{webhook-id}.{webhook-timestamp}.{raw body}"> keyed with the base64-decoded part of the endpoint secret after whsec_. Space-separated when several are sent. |
// Node 20+, Deno, Bun or Cloudflare Workers (WebCrypto). No dependency.
export async function verifyArmageWebhook(
secret: string, // whsec_…, shown once when you added the endpoint
headers: Headers,
rawBody: string, // the body exactly as received, before JSON.parse
): Promise<boolean> {
const id = headers.get('webhook-id');
const timestamp = headers.get('webhook-timestamp');
const signatures = headers.get('webhook-signature');
if (!id || !timestamp || !signatures || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const raw = Uint8Array.from(atob(secret.slice('whsec_'.length)), (c) => c.charCodeAt(0));
const key = await crypto.subtle.importKey(
'raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const mac = await crypto.subtle.sign(
'HMAC', key, new TextEncoder().encode(`${id}.${timestamp}.${rawBody}`),
);
const expected = `v1,${btoa(String.fromCharCode(...new Uint8Array(mac)))}`;
return signatures.split(' ').some((candidate) => {
if (candidate.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= candidate.charCodeAt(i) ^ expected.charCodeAt(i);
return diff === 0;
});
}# Python 3.9+, standard library only.
import base64, hashlib, hmac, time
def verify_armage_webhook(secret: str, headers, raw_body: bytes) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature") or ""
if not msg_id or not timestamp or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
digest = hmac.new(key, signed, hashlib.sha256).digest()
expected = "v1," + base64.b64encode(digest).decode()
return any(hmac.compare_digest(expected, s) for s in signatures.split(" "))<?php
// PHP 7.4+. In a WordPress REST route: $request->get_header('webhook-id'),
// $request->get_body() for the raw body.
function armage_verify_webhook(string $secret, string $id, string $timestamp,
string $signatures, string $raw_body): bool {
if ($id === '' || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(substr($secret, strlen('whsec_')), true);
if ($key === false) {
return false;
}
$expected = 'v1,' . base64_encode(hash_hmac('sha256', "$id.$timestamp.$raw_body", $key, true));
foreach (explode(' ', $signatures) as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}- Answer fast: return 2xx within 10 seconds, then do the slow work.
- Deduplicate: delivery is at least once; the same event can arrive twice. Keep the
webhook-idvalues you have handled. - Order: events can arrive out of order after a retry; compare
created_at, or read the story's current status. - Changing the secret: add a second endpoint with the same URL, let your receiver accept either secret, then turn the old endpoint off. Both endpoints send the same event id, so your deduplication drops the copy.
04
Retries and turning off
Anything other than a 2xx answer within 10 seconds counts as a failed attempt: an error status, a timeout, a redirect, a refused connection.
| Attempt | When |
|---|---|
| Attempt 1 | When the story changes |
| Attempts 2 to 8 | After 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h |
| Then | The delivery is marked failed (about 11 hours after the event) |
| Three failed deliveries in a row | The endpoint turns off; turn it on again on the API page |
05
The delivery log
Every delivery is recorded: event, endpoint, status (pending while retries remain, delivered, failed), attempts, your last HTTP status and a short error. The API page of the workspace shows the latest; a key with webhooks:read reads them all from GET /v1/webhook_deliveries, and the endpoints from GET /v1/webhook_endpoints (never their secrets).
Build against a sandbox workspace
Or writehello@arm-age.com