Webhooks
Receive signed HTTPS notifications when payments, refunds and deposits change, with automatic retries.
Webhooks tell your server when something happens on your account: a payment succeeds, a refund completes, a deposit is paid. They're the reliable way to learn about a payment's result, because they arrive even if the customer closes the browser before returning to your site.
Set up an endpoint
An endpoint is a URL on your server that accepts POST requests. Live-mode endpoints must use https:// (an http:// URL fails with 422 validation_error on url); test-mode endpoints may also use http://. The URL must be reachable from the internet: addresses on private or reserved networks are refused.
From the dashboard: open Developers → Webhooks → Add endpoint, enter the URL, pick the events and save. Copy the signing secret (whsec_…) that is shown.
From the API:
curl https://api.teyra.co/v1/webhook-endpoints \
-H "Authorization: Bearer $TEYRA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://shop.example.com/hooks/teyra",
"events": ["payment.succeeded", "payment.failed", "refund.succeeded"]
}'{
"id": "we_01JBX7C3HZ",
"object": "webhook_endpoint",
"url": "https://shop.example.com/hooks/teyra",
"events": ["payment.succeeded", "payment.failed", "refund.succeeded"],
"active": true,
"created_at": "2026-10-28T14:07:00.000Z",
"secret": "whsec_..."
}The secret is returned only in the create response. Store it with your other secrets. You need it to verify signatures.
Use ["*"] to receive every event type. List endpoints with GET /v1/webhook-endpoints and delete one with DELETE /v1/webhook-endpoints/:id. Test-mode and live-mode endpoints are separate: register your production URL with a live key.
Event types
| Event | Sent when | data.object |
|---|---|---|
payment.succeeded | A payment is captured. Fulfil the order. | Payment |
payment.failed | A payment is declined or errors. | Payment |
payment.canceled | The customer cancels at checkout. | Payment |
payment.expired | A payment is not paid before expires_at. | Payment |
refund.succeeded | A refund completes. | Refund |
refund.failed | A refund fails. | Refund |
deposit.paid | A payout is sent to your bank account. | Deposit |
Payload
Every webhook is a POST with a JSON Event body:
{
"id": "evt_01JBX8A2NM",
"object": "event",
"type": "payment.succeeded",
"livemode": false,
"created_at": "2026-10-28T14:08:13.000Z",
"data": {
"object": {
"id": "pay_01JBX4T9W2",
"object": "payment",
"livemode": false,
"amount": 12500,
"currency": "KWD",
"amount_refunded": 0,
"status": "succeeded",
"reference": "1042",
"payment_method": "knet",
"metadata": { "order_id": "1042" },
"paid_at": "2026-10-28T14:08:12.000Z"
}
}
}data.object is the full object (shortened here) as it was when the event happened.
Headers sent with every request:
| Header | Value |
|---|---|
Content-Type | application/json |
Teyra-Signature | t=1730124420,v1=5f2b…. See Verify signatures. |
Teyra-Event-Id | The event ID, same as id in the body. Use it to ignore duplicates. |
User-Agent | TeyraPay-Webhooks/1.0 |
Handle an event
- Read the raw request body.
- Verify the signature. Reject the request with
400if it doesn't match. - If you've already processed this
Teyra-Event-Id, return200and stop. - Return
2xxquickly (within 10 seconds). Do slow work, like sending emails, in a background job. - Act on
type. For payments, it's still good practice to look up your order bydata.object.referenceordata.object.idand checkamountandcurrency.
switch (event.type) {
case 'payment.succeeded':
await markOrderPaid(event.data.object.reference, event.data.object.id); // idempotent
break;
case 'payment.failed':
case 'payment.canceled':
// Not final: the customer can still pay at the same checkout_url until expires_at.
await noteFailedAttempt(event.data.object.reference, event.data.object.failure_message);
break;
case 'payment.expired':
await markOrderUnpaid(event.data.object.reference); // never overwrite an order that is already paid
break;
case 'refund.succeeded':
await recordRefund(event.data.object.payment_id, event.data.object.amount);
break;
default:
// Ignore event types you don't use. New types may be added.
break;
}Retries
A delivery succeeds when your endpoint answers with any 2xx status within 10 seconds. Anything else (a 3xx, 4xx or 5xx status, a timeout, a connection or TLS error) is a failure, and Teyra Pay retries with increasing delays:
| Attempt | Delay after the previous attempt |
|---|---|
| 1 | Immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours |
After 8 failed attempts (about 45 hours), the delivery is marked failed. You can still retry it from the dashboard.
Redirects are not followed: point the endpoint at the final URL. Each attempt is signed with a fresh timestamp.
Order and duplicates
Events can arrive out of order and, rarely, more than once. A payment can also send payment.failed and later payment.succeeded when the customer tries again. Use Teyra-Event-Id to skip duplicates, and when order matters, fetch the latest state with GET /v1/payments/:id.
Testing from the dashboard
In Developers → Webhooks, open an endpoint and click Send test event. Teyra Pay sends a signed payment.succeeded event to the URL, so you can check your signature verification and response.
The Deliveries list on the same page shows each attempt: event type, status (pending, succeeded, failed), number of attempts, the HTTP status your server returned, the start of your response body and the next retry time. Click Retry to send a delivery again right away.
To receive webhooks on your laptop during development, expose your local server with a tunnelling tool (for example ngrok http 3000) and register the public HTTPS URL as a test-mode endpoint. Then make test payments: each one sends real payment.* events.
Rotating the secret
In the dashboard, open the endpoint and click Roll secret. The new secret takes effect immediately, so update your server's configuration right away.