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

EventSent whendata.object
payment.succeededA payment is captured. Fulfil the order.Payment
payment.failedA payment is declined or errors.Payment
payment.canceledThe customer cancels at checkout.Payment
payment.expiredA payment is not paid before expires_at.Payment
refund.succeededA refund completes.Refund
refund.failedA refund fails.Refund
deposit.paidA 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:

HeaderValue
Content-Typeapplication/json
Teyra-Signaturet=1730124420,v1=5f2b…. See Verify signatures.
Teyra-Event-IdThe event ID, same as id in the body. Use it to ignore duplicates.
User-AgentTeyraPay-Webhooks/1.0

Handle an event

  1. Read the raw request body.
  2. Verify the signature. Reject the request with 400 if it doesn't match.
  3. If you've already processed this Teyra-Event-Id, return 200 and stop.
  4. Return 2xx quickly (within 10 seconds). Do slow work, like sending emails, in a background job.
  5. Act on type. For payments, it's still good practice to look up your order by data.object.reference or data.object.id and check amount and currency.
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:

AttemptDelay after the previous attempt
1Immediately
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 hours
824 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.