API docs

Webhooks

Webhooks push changes to your server as they happen. The shop owner adds an endpoint under Settings → Developers → Webhooks:

  • A URL.
  • The events to send.
  • Optionally: store locations to limit it to, whether to include dates of birth, and a custom auth header.

Each endpoint gets a signing secret (whsec_…) that you use to verify deliveries. A shop can have up to 20 endpoints.

Event catalog

Event Sent when data.object
product.created A product is added (counter, BigCommerce import or API) product with inventory
product.updated Name, price, cost, details or photos change product with inventory
product.deleted A product is deleted { object, id, deleted: true, sku, name }
inventory.changed On-hand or available quantity changes at a location: sale, receiving, count, adjustment, transfer, void, return, or a serialized unit held or released (qty_delta 0, reason hold) inventory level + sku, name, qty_delta, reason
customer.created A customer is added (counter, online, API, or with an API order) customer
customer.updated A customer's details change customer
customer.deleted A customer is deleted { object, id, deleted: true }
order.created An order is created through the API order (completed or held)
sale.completed A sale is paid; firearm sales arrive with status awaiting_transfer order
sale.voided A sale is voided order
sale.refunded Part or all of a sale is refunded order
return.created A return is processed return
purchase_order.created A purchase order is created purchase order
purchase_order.submitted A purchase order is sent to the vendor purchase order
purchase_order.cancelled A purchase order is cancelled through the API purchase order
receiving.posted Stock is received and posted { object: "receiving", id, number, location_id, vendor_id, purchase_order_id, reference, status, lines[{product_id, qty, unit_cost_cents, purchase_order_line_id}], posted_at } (never serial numbers)
gift_certificate.issued A gift certificate is issued (at the register, online or through the API) gift certificate
gift_certificate.updated A certificate's balance or status changes: redeemed, reversed, reloaded, adjusted, disabled, enabled, expired gift certificate
store_credit.changed A customer's store credit balance changes: issued, spent at the register or online, refunded, adjusted store credit (customer_id, balance_cents, the latest entry)
invoice.created An invoice or monthly statement is issued to a house account invoice
invoice.updated An invoice is paid, part-paid, credited, voided or written off invoice
account.payment_received Money is received on a house account (at the counter, by pay link or through the API) account payment
membership.created Someone joins (register, online, back office or API) membership
membership.updated A membership changes: renewed, past due, frozen, cancelled, lapsed, members or billing changed. change and detail say what happened. membership + change, detail
membership.payment_failed Dues on a saved card are declined; the shop retries on its schedule membership + change, detail
coupon.created / coupon.updated / coupon.deleted Coupon changes coupon (or { id, deleted: true })
location.created / location.updated Location changes location
range.reservation.booked A lane or class booking is confirmed reservation
range.reservation.cancelled A confirmed booking is cancelled reservation
range.reservation.checked_in The party checks in reservation
range.reservation.completed The visit ends reservation
range.reservation.no_show Marked as a no-show reservation
range.rental.checked_out A rental gun goes out to a customer range_rental
range.rental.checked_in A rental gun comes back; includes last_checkout (condition, rounds fired) range_rental
range.rental.out_of_service A rental gun is taken out of service (cleaning, repair) range_rental
range.rental.returned_to_service A rental gun goes back on the wall range_rental
  • Range events exist only on Iron Register shops with the Range add-on.
  • Shapes. Object shapes match the API resources, so a sale.completed payload is the same object GET /v1/orders/{id} returns.
  • Customers on orders. Webhook order payloads always embed the full customer.
  • Nothing federal. No event carries bound-book, 4473, NICS or FFL-transfer data.

Payload

POST /webhooks/ironregister HTTP/1.1
Content-Type: application/json
User-Agent: IronRegister-Webhooks/1.0 (+https://ironregister.com/developers)
IronRegister-Event: inventory.changed
IronRegister-Event-Id: evt_5b1e0f7c2d9a44e18c3f6a20
IronRegister-Delivery-Id: 48121
IronRegister-Attempt: 1
IronRegister-Signature: t=1790300831,v1=0f05247b1952235692cbce5788b8f80597979dc5a7ffdfda8f3e68c83c943b8f

{
  "id": "evt_5b1e0f7c2d9a44e18c3f6a20",
  "object": "event",
  "type": "inventory.changed",
  "api_version": "2026-09-24",
  "created_at": "2026-09-25T01:48:03Z",
  "livemode": true,
  "data": {
    "object": {
      "object": "inventory_level",
      "product_id": 2062,
      "location_id": 93,
      "qty_on_hand": 38,
      "qty_reserved": 0,
      "qty_available": 38,
      "reorder_point": 12,
      "bin_location": "A-3",
      "updated_at": "2026-09-25T01:48:03Z",
      "sku": "AMM-9-115-50",
      "name": "Blazer Brass 9mm 115gr FMJ (50)",
      "qty_delta": -2,
      "reason": "sale"
    }
  }
}
  • The payload is a snapshot taken when the event happened. If you need the latest state, fetch the resource.
  • livemode is false on the sandbox.
  • A ping event (data.object.object = "ping") is sent when the owner presses Send test.
Header Meaning
IronRegister-Event The event type
IronRegister-Event-Id The event id; the same on every retry and resend. Dedupe on this.
IronRegister-Delivery-Id This delivery; a manual resend gets a new one
IronRegister-Attempt 1 for the first try, 2+ for retries
IronRegister-Signature t=<unix seconds>,v1=<hex HMAC-SHA256> (see below)
(custom) If the endpoint has an auth header (e.g. Authorization: Bearer … or X-Api-Token: …), it's sent on every delivery

Dates of birth are removed from every payload unless the owner ticked Include date of birth on that endpoint.

Verifying signatures

The signature is an HMAC-SHA256, keyed with your endpoint's secret, over "{t}.{raw request body}". To verify a delivery:

  1. Read the raw body bytes, before any JSON parsing.
  2. Split the header on ,. Take t and every v1.
  3. Compute HMAC_SHA256(secret, t + "." + body) as lowercase hex.
  4. Accept the delivery if it equals any v1, using a constant-time comparison.
  5. Reject it if t is more than 5 minutes from your clock, to stop replays.

While the owner is rotating the secret, deliveries carry two v1 values, one per secret, for the grace period they chose (up to 7 days). Accepting any matching v1 lets you switch secrets without dropping events.

PHP

$secret = getenv('IRONREGISTER_WEBHOOK_SECRET');
$body   = file_get_contents('php://input');
$header = $_SERVER['HTTP_IRONREGISTER_SIGNATURE'] ?? '';

$t = null; $sigs = [];
foreach (explode(',', $header) as $part) {
    [$k, $v] = array_pad(explode('=', $part, 2), 2, '');
    if ($k === 't')  { $t = (int) $v; }
    if ($k === 'v1') { $sigs[] = $v; }
}
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
$valid = $t !== null && abs(time() - $t) <= 300
      && array_filter($sigs, fn ($s) => hash_equals($expected, $s)) !== [];

if (!$valid) { http_response_code(400); exit; }
http_response_code(200);           // acknowledge first…
$event = json_decode($body, true); // …then queue $event for processing

Node.js (Express)

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.IRONREGISTER_WEBHOOK_SECRET;

app.post('/webhooks/ironregister', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('IronRegister-Signature') || '';
  let t = null; const sigs = [];
  for (const part of header.split(',')) {
    const [k, v] = part.split('=');
    if (k === 't') t = Number(v);
    if (k === 'v1') sigs.push(v);
  }
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(req.body).digest('hex');
  const ok = t !== null && Math.abs(Date.now() / 1000 - t) <= 300 &&
    sigs.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
  if (!ok) return res.sendStatus(400);
  res.sendStatus(200);
  queue.add(JSON.parse(req.body.toString('utf8'))); // process asynchronously
});

Python (Flask)

import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["IRONREGISTER_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/ironregister")
def ironregister():
    body = request.get_data()  # raw bytes
    t, sigs = None, []
    for part in request.headers.get("IronRegister-Signature", "").split(","):
        k, _, v = part.partition("=")
        if k == "t": t = int(v)
        if k == "v1": sigs.append(v)
    expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    if t is None or abs(time.time() - t) > 300 or not any(hmac.compare_digest(expected, s) for s in sigs):
        abort(400)
    enqueue(request.get_json())  # process asynchronously
    return "", 200

Responding, retries and ordering

  • Respond with any 2xx within 10 seconds. Do the work afterwards, in a queue. Redirects aren't followed; a 3xx counts as a failure, so give the final URL.
  • Retries. A non-2xx response, timeout or connection error is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, then every 10 hours: 9 attempts over about 37 hours. After that, the delivery is marked failed. The owner can resend any delivery from the endpoint's delivery log.
  • Auto-disable. An endpoint with 30 failed attempts in a row and no success for 24 hours is turned off. Its pending retries are cancelled and the shop's owners and admins get an email. Once the receiver is fixed, the owner turns the endpoint back on and resends what was missed, or you catch up from /v1/events.
  • Delivered at least once. The same event can arrive more than once. Make handlers idempotent and dedupe on IronRegister-Event-Id.
  • Order isn't guaranteed. Retries can arrive after newer events. Compare created_at, or updated_at on the object, before overwriting newer data, or re-fetch the resource.
  • Events are recorded in the same database transaction as the change. A sale that fails and rolls back never announces itself.

Endpoint URL rules

  • Must use https://, with no credentials in the URL.
  • Must resolve only to public addresses. Private, loopback, link-local, CGNAT and cloud-metadata addresses are refused, both when saving and on every delivery.
  • The destination is re-resolved and pinned for each attempt, which blocks DNS-rebinding tricks.
  • For authentication beyond the signature, use the endpoint's custom auth header setting.

Rotating the secret

Rotate secret issues a new whsec_… secret. For the grace period the owner picks (0–168 hours, default 24), deliveries carry signatures from both the old and new secrets. Deploy the new secret within that window.