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.completedpayload is the same objectGET /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.
livemodeisfalseon the sandbox.- A
pingevent (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:
- Read the raw body bytes, before any JSON parsing.
- Split the header on
,. Taketand everyv1. - Compute
HMAC_SHA256(secret, t + "." + body)as lowercase hex. - Accept the delivery if it equals any
v1, using a constant-time comparison. - Reject it if
tis 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, orupdated_aton 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.