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 quantity changes at a location: sale, receiving, count, adjustment, void, return inventory level + sku, name, qty_delta, reason, and lot (the lot that moved, or null)
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 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, lot_number, expires_on}], posted_at } (no serial numbers)
gift_certificate.issued A gift certificate is issued (register, online or 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)
coupon.created / coupon.updated / coupon.deleted Coupon changes coupon (or { id, deleted: true })
location.created / location.updated Location changes location
  • 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 private. No event carries customer ID documents, staff notes or staff accounts.

Payload

POST /webhooks/blackbeach HTTP/1.1
Content-Type: application/json
User-Agent: BlackBeachPOS-Webhooks/1.0 (+https://blackbeachpos.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": 640,
      "location_id": 18,
      "qty_on_hand": 34,
      "qty_reserved": 0,
      "qty_available": 34,
      "reorder_point": 12,
      "bin_location": "Shelf B",
      "updated_at": "2026-09-25T01:48:03Z",
      "sku": "SOAP-LAV",
      "name": "Lavender Goat Milk Soap",
      "qty_delta": -2,
      "reason": "sale",
      "lot": { "object": "inventory_lot", "id": 62, "product_id": 640, "location_id": 18, "lot_number": "LAV-2510-A", "expires_on": "2026-10-01", "qty_on_hand": 8, "status": "expiring", "days_to_expiry": 7, "cost_cents": 180, "created_at": "2026-09-01T14:00:00Z", "updated_at": "2026-09-25T01:48:03Z" }
    }
  }
}
  • 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('BLACKBEACH_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.BLACKBEACH_WEBHOOK_SECRET;

app.post('/webhooks/blackbeach', 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["BLACKBEACH_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/blackbeach")
def blackbeach():
    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.