Handle webhooks
Local rails complete asynchronously: the customer approves the collection after they leave
your checkout. Webhooks are how you reliably learn the outcome. Treat a payment as paid only
when you receive a success webhook (or a retrieve returns status: "succeeded"), never just
because the customer returned to your return_url.
Setup
- Set your webhook URL (an HTTPS endpoint on your server) in the dashboard under Developers → Webhooks.
- Get your signing secret from the same place. You use it to verify that an incoming webhook genuinely came from TensorRail. The secret can be rotated from the dashboard; treat rotation like key rotation and deploy the new secret before rotating.
For local development, expose your dev machine through any HTTPS tunnel and point your webhook
URL at it; test-mode events deliver exactly like live ones. Your account has one webhook URL for
test and live, so a tunnel is for building before you go live: once you take live payments it
would receive live events too. A payment event's content.object.livemode is false for a test
payment.
To check delivery end to end without writing code, switch the dashboard to test mode and press
Send test payment under Developers → Webhooks. It creates a small test payment that
succeeds straight away, so its real payment_succeeded event, signed with your current secret,
is delivered to your saved endpoint and appears in the delivery log.
The event payload
TensorRail POSTs a JSON body to your URL:
{
"merchant_id": "your_merchant_id",
"event_id": "evt_9f2c…",
"event_type": "payment_succeeded",
"timestamp": "2026-07-14T09:03:11Z",
"content": {
"type": "payment_details",
"object": {
"payment_id": "pay_N5cPeGw6uS2QIMjnsjVF",
"status": "succeeded",
"amount": 50000,
"currency": "INR",
"metadata": { "order_id": "12345" },
"created": "2026-07-14T09:00:00Z"
}
}
}
content.typetells you what kind of object the event carries (payment_details,refund_details,dispute_details,mandate_details,payout_details,subscription_detailsorapi_key_details), andcontent.objectis that object: for payment events, the payment in the same shape the REST API returns. Yourmetadatais echoed there, which is how you join an event back to your order.- Key your logic off
content.object.status(succeeded/failed) rather than the exactevent_typestring. New event types can appear as the platform grows; unknown types should be acknowledged with a2xxand ignored, not treated as errors.
Event types
Every event your endpoint can receive. event_type carries the name; content.object carries the
object the event is about.
| Event | Group | Sent when |
|---|---|---|
payment_processing | Payment | The payment was submitted and its outcome is not final yet. |
payment_succeeded | Payment | The payment succeeded. Fulfil the order on this event. |
payment_failed | Payment | The payment failed. The payment object carries the error code. |
action_required | Payment | The payment is waiting for an action before it can complete, usually the customer's approval. |
payment_authorized | Payment | The payment was authorized and is waiting for you to capture it. |
payment_partially_authorized | Payment | The payment was authorized for less than the amount you requested. |
payment_captured | Payment | Part of an authorized payment was captured. |
payment_cancelled | Payment | The payment was cancelled before it was captured. |
payment_cancelled_post_capture | Payment | The payment was cancelled after it was captured. Treat it like a refund, not like payment_cancelled. |
payment_expired | Payment | The payment was not completed before it expired. |
refund_succeeded | Refund | The refund completed. |
refund_failed | Refund | The refund failed. |
dispute_opened | Dispute | A new dispute. Respond before its challenge_required_by deadline. See Disputes. |
dispute_challenged | Dispute | You submitted evidence and the dispute is awaiting an outcome. |
dispute_accepted | Dispute | You accepted the dispute and the funds are returned to the customer. |
dispute_expired | Dispute | The response window passed without a challenge. |
dispute_cancelled | Dispute | The dispute was withdrawn or cancelled. |
dispute_won | Dispute | The dispute was resolved in your favour. |
dispute_lost | Dispute | The dispute was resolved against you and the funds are returned to the customer. |
mandate_active | Mandate | A saved payment method can now be charged again. See Recurring and saved methods. |
mandate_revoked | Mandate | A saved payment method can no longer be charged. |
invoice_paid | Subscription | A subscription invoice was paid. |
payout_initiated | Refund payout | A refund returned to the payer as a payout was created. See Methods without a refund API. |
payout_processing | Refund payout | The refund payout was sent and its outcome is not final yet. |
payout_success | Refund payout | The refund payout succeeded. |
payout_failed | Refund payout | The refund payout failed, so the payer was not paid. |
payout_cancelled | Refund payout | The refund payout was cancelled before it was sent. |
payout_expired | Refund payout | The refund payout expired before it was sent. |
payout_reversed | Refund payout | A refund payout that had succeeded was returned. |
key_emergency_revoked | Account and security | An emergency revoke stopped one of your API keys and issued a replacement, so an automated consumer learns its credential is dead from the event rather than from the next 401. See Emergency key revoke. |
Two of these are easy to read as their neighbours and are not. payment_cancelled_post_capture
is a cancellation after the money was captured, which is a refund-shaped event, not the
ordinary payment_cancelled. payment_partially_authorized means the rail authorized
less than you asked for: the payment is live, for a smaller amount than your order.
Verify the signature
Every webhook carries a TensorRail-Signature header. Verify it before trusting the
event. The header value is:
TensorRail-Signature: t=<unix_seconds>,v1=<hex>
where v1 is HMAC-SHA512(your_signing_secret, "<t>.<raw_request_body>"), hex-encoded
lowercase. To verify:
- Split the header into
tandv1. - Compute
HMAC-SHA512(secret, t + "." + rawBody)and hex-encode it. - Compare it to
v1in constant time. - Reject if
tis more than 5 minutes old (replay protection).
Compute the HMAC over the exact bytes you received, before any JSON parse or re-serialize. Re-encoding can change whitespace or ordering and break the signature. This is the single most common cause of "signature never verifies".
- Node
- Python
- Notes
const crypto = require("crypto");
function verifyTensorRailWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("="))
);
const signed = `${parts.t}.${rawBody}`;
const expected = crypto
.createHmac("sha512", secret)
.update(signed)
.digest("hex");
const match =
expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return match && fresh;
}
Make sure your framework gives you the raw body (for example
express.raw({ type: "*/*" })), not a parsed object.
import hashlib
import hmac
import time
def verify_tensorrail_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in signature_header.split(","))
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha512).hexdigest()
match = hmac.compare_digest(expected, parts["v1"])
fresh = abs(time.time() - int(parts["t"])) < 300
return match and fresh
Pass the raw request body bytes exactly as received.
The signature is computed over "<t>.<raw_body>" with HMAC-SHA512, keyed on your
signing secret, and hex-encoded. There is a single scheme (no dual signature).
Reject any event whose timestamp t is more than 5 minutes from now.
Delivery semantics: retries, duplicates, ordering
Design your handler around three facts about delivery:
- Retries. Return
2xxquickly (do heavy work asynchronously). A non-2xx response or a timeout is a failed delivery, and a failed delivery is retried on a fixed schedule: 16 retries over about 24 hours (see Retry schedule). You can inspect deliveries and their attempts, and re-drive a delivery, from the dashboard under Developers → Webhooks. - Duplicates. Because deliveries retry, an event can arrive more than once (for
example if you processed it but your
2xxwas lost). Make your handler idempotent: de-duplicate onevent_id, or oncontent.object.payment_idpluscontent.object.status. "Fulfil the order once" must be enforced by your handler, not assumed from delivery. - Ordering. Deliveries and their retries are independent, so do not assume strict
ordering: a retried
payment_processingcan land afterpayment_succeeded. Two safe patterns:- Only act on terminal states (
succeeded,failed) and treat non-terminal events as informational. - If you need current state at handling time, call
GET /payments/{payment_id}; the API is always the authoritative present, the event is a fact about the past.
- Only act on terminal states (
Never let a webhook move an order backwards (for example from paid to pending because a
stale processing event arrived late).
Retry schedule
The first delivery is attempted as soon as the event happens. If it fails, the retries follow
a schedule fixed in advance: the wait steps up from 1 minute to 6 hours as shown below, and does
not depend on how your endpoint responded. The first 2xx stops the schedule.
| Retry | Wait after the previous attempt | Time after the event |
|---|---|---|
| 1 | 1 minute | 1 minute |
| 2 and 3 | 5 minutes | 6 and 11 minutes |
| 4 to 8 | 10 minutes | 21 minutes to 1 hour 1 minute |
| 9 to 13 | 1 hour | 2 hours 1 minute to 6 hours 1 minute |
| 14 to 16 | 6 hours | 12 hours 1 minute to 24 hours 1 minute |
Times are approximate: a retry starts when it is picked up after its due time, so it can run a little late, never early. If the 16th retry also fails, automatic delivery of that event stops. It stays in the delivery log, and you can re-drive it once your endpoint is healthy (see Retry a delivery).
Recommended flow
- Receive the webhook and read the raw body.
- Verify the
TensorRail-Signature. If it fails, respond400and stop. - Check
event_idagainst your processed-events store; if seen, respond2xxand stop. - Look up the order by
content.object.metadata/content.object.payment_id. - On
content.object.status == "succeeded", fulfil the order once, idempotently; on"failed", mark the attempt failed and let checkout offer a new payment. - Record
event_idas processed and respond2xx. Queue any slow work.
A complete runnable endpoint following this flow (Express and Flask) is in the Quickstart.
If webhooks stop arriving
Check, in order: your endpoint's TLS and reachability from the public internet; the delivery
log and attempt history in the dashboard (each attempt shows the response your server gave);
and whether your handler is rejecting valid signatures after a secret rotation. While you
investigate, GET /payments/{payment_id} remains the reliable way to reconcile outcomes, and
missed deliveries can be re-driven from the dashboard once your endpoint is healthy.
Next steps
Your handler verifies signatures and fulfils idempotently. Take it to production:
- Testing and sandbox: drive real signed deliveries and duplicate events at your endpoint before you go live.
- Error intelligence: branch correctly on the codes a failed payment carries.
- Go live: re-point the webhook and watch one real collection land end to end.
TensorRail, Limassol, Cyprus.