Webhooks
UnizPay sends an HTTPS POST to your endpoint each time a payment changes status. Use webhooks
to fulfil orders: they arrive even when the payer closes the payment page.
Add an endpoint
Section titled “Add an endpoint”In the dashboard, open Developers → Webhooks and add your endpoint’s URL. Copy its signing secret: it is shown once. Endpoints belong to one mode, so add one in test mode and one in live mode. Every endpoint receives every event type.
Send test event on the endpoint’s page delivers an event of type test (with empty
data) so you can check your handler.
Events
Section titled “Events”| Event | When |
|---|---|
payment.detected | A deposit was seen on-chain (not yet confirmed) |
payment.confirmed | Deposits reached the network’s confirmation depth |
payment.settled | The payout was delivered to your payout address. Fulfil the order here. |
payment.settled_raw | The conversion could not run (for example the price moved beyond your max conversion cost); the funds were delivered unconverted to your fallback address |
payment.underpaid | Confirmed deposits are below the amount due, minus your underpay tolerance |
payment.expired | Nothing was paid before the payment expired |
payment.late | A deposit arrived after the payment expired or was canceled; it is still delivered |
payment.canceled | You canceled the payment before any deposit |
Payload
Section titled “Payload”POST /webhooks/unizpay HTTP/1.1Content-Type: application/jsonUser-Agent: UnizPay-Webhooks/1.0UnizPay-Signature: t=1761912221,v1=5f2b9c0e…{ "event_id": "evt_01K…", "type": "payment.settled", "created": "2026-10-31T12:03:41.512Z", "livemode": true, "data": { "object": "payment", "id": "pay_01K…", "payment_id": "order_1001", "status": "settled", … }}data is the full payment object at the time of the event, including
its deposits and settlements.
Verify the signature
Section titled “Verify the signature”The UnizPay-Signature header is t=<unix seconds>,v1=<signature>, where
signature = hex( HMAC-SHA256( signing_secret, t + "." + raw_request_body ) )To verify a request:
- Read the raw request body, before any JSON parsing.
- Compute the signature with your signing secret and the header’s
t. - Accept the request if it equals any
v1value in the header. After you rotate the secret, events carry twov1values (new and previous secret) until the next rotation. - Reject it if
tis more than 5 minutes away from your clock.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyUnizPay(rawBody, header, secret, toleranceSeconds = 300) { let t = 0; const signatures = []; for (const part of (header ?? "").split(",")) { const i = part.indexOf("="); const key = part.slice(0, i).trim(); const value = part.slice(i + 1).trim(); if (key === "t") t = Number(value); else if (key === "v1" && value) signatures.push(value); } if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")); return signatures.some((s) => { const got = Buffer.from(s); return got.length === expected.length && timingSafeEqual(got, expected); });}
// Express: keep the raw body for this route.app.post("/webhooks/unizpay", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body.toString("utf8"); if (!verifyUnizPay(raw, req.get("UnizPay-Signature"), process.env.UNIZPAY_WEBHOOK_SECRET)) { return res.sendStatus(400); } const event = JSON.parse(raw); // Deduplicate on event.event_id, then handle event.type … res.sendStatus(200);});import hashlib, hmac, time
def verify_unizpay(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: t, signatures = 0, [] for part in (header or "").split(","): key, _, value = part.strip().partition("=") if key == "t": t = int(value or 0) elif key == "v1" and value: signatures.append(value) if not t or abs(time.time() - t) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(s, expected) for s in signatures)
# Flask@app.post("/webhooks/unizpay")def unizpay_webhook(): if not verify_unizpay(request.get_data(), request.headers.get("UnizPay-Signature"), SECRET): return "", 400 event = request.get_json() # Deduplicate on event["event_id"], then handle event["type"] … return "", 200function verify_unizpay(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool { $t = 0; $signatures = []; foreach (explode(',', $header ?? '') as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't') $t = (int) $value; elseif ($key === 'v1' && $value !== '') $signatures[] = $value; } if (!$t || abs(time() - $t) > $tolerance) return false; $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($signatures as $s) { if (hash_equals($expected, $s)) return true; } return false;}
$raw = file_get_contents('php://input');if (!verify_unizpay($raw, $_SERVER['HTTP_UNIZPAY_SIGNATURE'] ?? null, getenv('UNIZPAY_WEBHOOK_SECRET'))) { http_response_code(400); exit;}$event = json_decode($raw, true);// Deduplicate on $event['event_id'], then handle $event['type'] …http_response_code(200);Delivery and retries
Section titled “Delivery and retries”- Success is any
2xxresponse within 10 seconds. Respond first, then do slow work in the background. - Retries: a failed delivery is retried with exponential backoff (30 s, 1 min, 2 min, …, at most 6 h apart), up to 15 attempts over about 32 hours. The dashboard shows every attempt with its response code.
- At least once: the same event can arrive more than once. Store each
event_idyou handle and ignore repeats. - Order is not guaranteed. Use
data.status(or fetch the payment) as the source of truth rather than the order events arrive in.