Webhooks
Receive signed notifications when payments and payouts complete, fail or are reversed.
TeePay sends an HTTPS POST to your server when a payment or payout reaches a final state. Webhooks
are the recommended way to learn results; combine them with status reads
for orders whose notification has not arrived.
Set up
Add your webhook URL
Open Payment profiles in the dashboard and set the webhook URL on your default profile (or on
the profile you use for a checkout). It must be a public https:// URL.
Create the signing secret
Open Developers → Webhooks and create the signing secret (whsec_…). Store it on your server. You
can rotate it at any time; the previous secret stops signing immediately.
Verify every request
Check the signature before trusting the body, as shown below.
Mobile money push without a profile
Webhooks are delivered to a payment profile's URL. If your account has no payment profile, no webhooks are sent; read the status with the API instead.
Events
| Event | Sent when |
|---|---|
payment.completed | A payment was verified and posted to your ledger. Fulfil the order. |
payment.failed | A payment did not complete. |
payment.voided | The customer or provider cancelled the payment. |
payment.expired | The payment request expired before it was paid. |
payout.completed | A payout was delivered and settled. |
payout.failed | A payout did not complete. |
payout.reversed | A completed payout was reversed. |
Payload
{
"id": "0f8d7c2e-5b1a-5e3f-9c4d-7a6b2e1f0c9d",
"event": "payment.completed",
"environment": "live",
"created_at": "2026-10-28T10:00:05.123456Z",
"data": {
"reference": "TP8K2M4QZ1",
"transaction_id": "pay_6b1f…",
"status": "completed",
"occurred_at": "2026-10-28T10:00:03Z",
"money": { "currency": "TZS", "amount_minor": "500000" },
"source": { "type": "checkout_session", "id": "7c0e5d2a-1b3f-4c8e-9a6d-2f4b8e1c0d9a" }
}
}data.source tells you what was paid:
source.type | source.id |
|---|---|
checkout_session | The checkout session id |
control_number | The Control Number id; source.control_number is the number |
payment_link, payment_page, invoice | The dashboard link, page or invoice |
null | Other payments, such as a mobile money push: match on data.reference |
Headers
| Header | Value |
|---|---|
X-TeePay-Event-ID | Same as id. Stable across retries: use it to de-duplicate. |
X-TeePay-Timestamp | Unix time in seconds when this attempt was signed. |
X-TeePay-Signature | v1= followed by the hex HMAC-SHA256 of <timestamp>.<raw body> with your secret. |
Verify the signature
Compute the signature over the raw request body bytes, before any JSON parsing, and compare in constant time. Reject requests older than five minutes.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyTeePayWebhook(rawBody: Buffer, headers: Record<string, string>, secret: string) {
const timestamp = headers['x-teepay-timestamp'];
const received = headers['x-teepay-signature'] ?? '';
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && timingSafeEqual(a, b);
}Respond and retry
- Respond with any
2xxstatus within a few seconds, then do slow work in the background. - Any other status, a timeout or a connection error is retried with exponential backoff starting at about 30 seconds and capped at one hour, for up to 5 attempts.
- The same event can arrive more than once. Process each
X-TeePay-Event-IDonce. - Delivery history and status codes are shown in Developers → Webhooks.