Skip to content

Webhooks

POS sends signed HTTPS POSTs to your endpoints when things change.

Registering

  • Application endpoints: Developer console → your app → Webhooks (or POST /api/v1/developer/applications/{id}/webhooks). You receive events for every business that connected your app and granted the matching read scope (product.* needs products:read, inventory.* needs inventory:read, order.*/sale.*/refund.* need orders:read, customer.* needs customers:read).
  • Business endpoints: Business → Integrations → Webhooks (or POST /api/v1/businesses/{id}/webhooks with webhooks:manage).

URLs must be https and must not resolve to private addresses. The signing secret (whsec_…) is shown once.

Events

product.created, product.updated, product.archived, inventory.changed, order.created, order.updated, order.cancelled, sale.completed, refund.created, customer.created, business.updated, integration.disconnected, and webhook.test (sent on demand).

Envelope

{ "id": "evt_2f6c…", "type": "inventory.changed", "apiVersion": "2026-11-01", "createdAt": "2026-11-05T10:15:00.000Z",
  "businessId": "cm…", "data": { … } }

Events are written in the same database transaction as the change (an outbox), so an event exists if and only if the change does.

Headers

Header Value
TechturPos-Signature t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
TechturPos-Event-Id the event id
TechturPos-Delivery-Attempt 1, 2, …

Verifying

import { verifyWebhook } from "@techtur-pos/sdk/webhooks";

app.post("/hooks/techtur-pos", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await verifyWebhook({ payload: req.body.toString("utf8"), signature: req.header("techturpos-signature"), secret: process.env.TPS_WEBHOOK_SECRET! });
  } catch { return res.sendStatus(400); }
  if (await seen(event.id)) return res.sendStatus(200);   // duplicate delivery
  await queue(event);                                      // do the work asynchronously
  res.sendStatus(200);
});

Rules: verify against the raw body (not re-serialised JSON); reject timestamps more than 5 minutes off (replay protection; the SDK does this); de-duplicate on id; reply 2xx within 10 seconds and process asynchronously.

Python:

import hmac, hashlib, time
def verify(raw: bytes, header: str, secret: str, tolerance=300):
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > tolerance: raise ValueError("stale")
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts["v1"]): raise ValueError("bad signature")

PHP:

[$t, $v1] = (function ($h) { parse_str(str_replace(',', '&', $h), $p); return [(int)$p['t'], $p['v1']]; })($_SERVER['HTTP_TECHTURPOS_SIGNATURE']);
$raw = file_get_contents('php://input');
if (abs(time() - $t) > 300 || !hash_equals(hash_hmac('sha256', "$t.$raw", $secret), $v1)) { http_response_code(400); exit; }

Retries

A non-2xx response, a timeout (10 s) or a redirect is a failure. Retries back off at roughly 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h — eight attempts over about two days — then the delivery is DEAD. After 50 consecutive failures the endpoint is disabled and the business is notified. Inspect and retry deliveries in the console; re-enable an endpoint by editing it.