Skip to content

Developers

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, localhost and 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.test event and shows what your endpoint answered.
  • Up to 10 endpoints per organization, each with its own events and secret.

02

Events and payload

Events
TypeWhen
story.submittedA 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.approvedA person approved the current version of a story. Sent to every enabled endpoint subscribed to story.approved, signed per Standard Webhooks.
story.rejectedA person sent a story back with a note. Sent to every enabled endpoint subscribed to story.rejected, signed per Standard Webhooks.
story.publishedA story was published. Sent to every enabled endpoint subscribed to story.published, signed per Standard Webhooks.
story.unpublishedA 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.

A delivery
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.

Headers
HeaderMeaning
webhook-idThe event id. Delivery is at least once: dedupe on it.
webhook-timestampUnix seconds when this attempt was signed. Reject outside +-5 minutes.
webhook-signaturev1,<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.
TypeScript
// 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
# 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
// 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-id values 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.

Retry schedule
AttemptWhen
Attempt 1When the story changes
Attempts 2 to 8After 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h
ThenThe delivery is marked failed (about 11 hours after the event)
Three failed deliveries in a rowThe 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 write