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.*needsproducts:read,inventory.*needsinventory:read,order.*/sale.*/refund.*needorders:read,customer.*needscustomers:read). - Business endpoints: Business → Integrations → Webhooks (or
POST /api/v1/businesses/{id}/webhookswithwebhooks: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.