Skip to content
Docs

01API

API v1For developers

Webhooks

A webhook is an address on your server that Rollmark calls when something happens. You do not poll; you are told. Each call is a POST of JSON, signed, so your server can tell it is from Rollmark.

Set one up

  • In the dealer app: Settings, Webhooks, Add endpoint. Or with the API (webhooks:manage): POST /webhooks.
  • Give an https:// address, pick the events, and copy the signing secret. It is shown once. If you lose it, rotate it.
  • Press Send test event. Within seconds your server gets a ping; the delivery log shows whether it worked.

A store has up to 10 endpoints. An endpoint made with an API key can only subscribe to events that key could read (order.* needs orders:read; distributor_order.* needs distributor_orders:read), and it is switched off for good if that key is revoked.

The address must be public

The address must be https://, on the standard port (443), with no user name, and name a public host. Rollmark refuses an address that is, or resolves to, a private, loopback, link-local (including the cloud metadata address), carrier-grade-NAT, reserved or multicast address, in any spelling, IPv6 included. It checks when you register and again at every delivery, connects only to the address it checked, and never follows a redirect (a 3xx is a failed delivery). During development use a public tunnel to your machine, not localhost.

The events

EventWhendata
order.createdAn order is made (API, FFL Checkout or staff)order_id, intake_ref, source, status
order.matchedA pending order is matched to the store's own orderorder_id, store_order_number, status
order.cancelledAn order is cancelledorder_id, status
order.readyAn order is marked ready to shiporder_id
order.shippedAn order is marked shippedorder_id, tracking, shipped_at
order.deliveredAn order is finished as deliveredorder_id
distributor_order.status_changedA distributor order changes statusdistributor_order_id, po_number, from, to, distributor, confirmation_number, tracking, order_ids
pingYou pressed Send test eventwebhook_id

The full payloads are in the events reference.

A payload is small on purpose. It holds ids and statuses. It never holds the shopper's name, email, phone or address, a serial number, or the store's cost. When you need the order, read it: GET /orders/{order_id} with your orders:read key. This keeps a customer's details out of your logs and your receiver's retries.

Every event has the same envelope:

JSON
{
  "id": "evt_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
  "type": "order.shipped",
  "created_at": "2026-10-16T14:02:11Z",
  "api_version": "v1",
  "data": { "order_id": "…", "tracking": "…", "shipped_at": "2026-10-16T14:02:10Z" }
}

What Rollmark sends

POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: Rollmark-Webhooks/1
Rollmark-Event-Id: evt_0a1b2c3d…
Rollmark-Delivery-Id: dlv_9f8e7d6c…
Rollmark-Attempt: 1
Rollmark-Timestamp: 1789567331
Rollmark-Signature: v1=3f2a…

Verify the signature

The signature is HMAC-SHA256 with the endpoint's secret over the text <timestamp>.<raw body>, as lowercase hex, in Rollmark-Signature: v1=<hex>. The timestamp (Rollmark-Timestamp, unix seconds) is inside the signed text, so a captured request cannot be replayed later: reject a timestamp more than 5 minutes from your clock. Verify against the raw body bytes as received, before you parse the JSON. Compare in constant time.

While you rotate a secret, Rollmark signs with the new and the old for 24 hours: Rollmark-Signature: v1=<new>,v1=<old>. Accept any v1= value that matches any secret you hold.

A test vector to check your code against: secret whsec_test_secret, timestamp 1700000000, body {"id":"evt_test"}, signature 13941114bb88ac44a76abcfddea5b92aa6182a4b63d8be3aae908a616083bd7e.

Node

Node
import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody: the exact text received. headers: lowercase names. secrets: [current, previous?]
export function verify(rawBody, headers, secrets, nowSeconds = Date.now() / 1000) {
  const timestamp = headers['rollmark-timestamp'];
  const header = headers['rollmark-signature'] ?? '';
  if (!timestamp || Math.abs(nowSeconds - Number(timestamp)) > 300) return false;
  const given = header
    .split(',')
    .map((p) => p.trim())
    .filter((p) => p.startsWith('v1='))
    .map((p) => p.slice(3));
  for (const secret of secrets) {
    const want = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
    for (const g of given) {
      if (g.length === want.length && timingSafeEqual(Buffer.from(g), Buffer.from(want))) return true;
    }
  }
  return false;
}

In Express, read the body as text, not parsed JSON: app.post('/rollmark', express.text({ type: 'application/json' }), (req, res) => { if (!verify(req.body, req.headers, [SECRET])) return res.sendStatus(400); … res.sendStatus(200); }).

PHP

PHP
<?php
// $rawBody: file_get_contents('php://input'). $headers: getallheaders(). $secrets: [current, previous?]
function rollmark_verify(string $rawBody, array $headers, array $secrets, ?int $now = null): bool {
    $headers = array_change_key_case($headers, CASE_LOWER);
    $timestamp = $headers['rollmark-timestamp'] ?? '';
    $header = $headers['rollmark-signature'] ?? '';
    if ($timestamp === '' || abs(($now ?? time()) - (int) $timestamp) > 300) {
        return false;
    }
    $given = [];
    foreach (explode(',', $header) as $part) {
        $part = trim($part);
        if (strncmp($part, 'v1=', 3) === 0) {
            $given[] = substr($part, 3);
        }
    }
    foreach ($secrets as $secret) {
        $want = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
        foreach ($given as $g) {
            if (hash_equals($want, $g)) {
                return true;
            }
        }
    }
    return false;
}

Python

Python
import hashlib
import hmac
import time

def verify(raw_body: bytes, headers: dict, secrets: list, now=None) -> bool:
    """raw_body: the exact bytes received. headers: lowercase names. secrets: [current, previous?]"""
    timestamp = headers.get("rollmark-timestamp", "")
    header = headers.get("rollmark-signature", "")
    try:
        age = abs((now if now is not None else time.time()) - int(timestamp))
    except ValueError:
        return False
    if age > 300:
        return False
    given = [p.strip()[3:] for p in header.split(",") if p.strip().startswith("v1=")]
    for secret in secrets:
        want = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(want, g) for g in given):
            return True
    return False

Answer, and be ready for repeats

  • Answer with any 2xx within 10 seconds, before you do slow work: verify, store the event, answer, then process.
  • Anything else, a timeout, a connection error, a TLS error or a redirect, is a failure and is tried again.
  • Delivery is at least once. The same event can arrive more than once (a retry after your answer was lost, or a person pressing Resend). Deduplicate on id (the evt_ value, also Rollmark-Event-Id): it is the same on every retry and every resend. Keep the ids you have handled for a few days.
  • Events are not guaranteed to arrive in order. Use created_at, and read the order itself (GET /orders/{id}) for its current state.

Retries

Eight attempts over about two days: at once, then 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours after the previous failure (each wait varies by up to 10%). After the eighth failure the delivery is dead: it stays in the log with a Resend button. An endpoint whose deliveries fail 20 times in a row is switched off (disabled_failing) with the reason shown; fix it, then switch it on. A delivery that waited more than 72 hours is dropped, not sent in a flood.

The log

Settings, Webhooks, Recent deliveries, or GET /webhooks/{id}/deliveries: each delivery's event, time, number of tries, the HTTP status or a short reason, and its state (pending, retrying, delivered, dead). The payload is not shown. Delivered deliveries are kept 30 days, dead ones 90.

Secrets

Keep the secret on your server, never in a web page or a repository. Rotate it with Rotate secret or POST /webhooks/{id}/rotate-secret: you get a new secret (shown once) and both sign for 24 hours, so you can switch without a gap.

Rollmark is made by Illumina Studios LLC. It was built at Ironcrest Arms LLC. Sample dealers, license numbers and costs in these docs are made up. Questions: [email protected].