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 the test webhook URL at it; test-mode events deliver exactly like live ones.
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), 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 by lifecycle:
| Group | Types |
|---|---|
| Payment | payment_processing, payment_succeeded, payment_failed, payment_cancelled, payment_authorized, payment_captured, payment_expired, action_required |
| Refund | refund_succeeded, refund_failed |
| Dispute | dispute_opened, dispute_challenged, dispute_won, dispute_lost, dispute_accepted, dispute_expired, dispute_cancelled |
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.
Using the SDK
The official SDKs ship a verify helper that implements exactly this scheme:
- Node:
verify(rawBody, signatureHeader, secret) - Python:
verify(raw_body, signature_header, secret)
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 failed deliveries are retried with exponential backoff. 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).
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.