Skip to main content

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

  1. Set your webhook URL (an HTTPS endpoint on your server) in the dashboard under Developers → Webhooks.
  2. 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.type tells you what kind of object the event carries (payment_details, refund_details, dispute_details), and content.object is that object — for payment events, the payment in the same shape the REST API returns. Your metadata is 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 exact event_type string. New event types can appear as the platform grows; unknown types should be acknowledged with a 2xx and ignored, not treated as errors.

Event types by lifecycle:

GroupTypes
Paymentpayment_processing, payment_succeeded, payment_failed, payment_cancelled, payment_authorized, payment_captured, payment_expired, action_required
Refundrefund_succeeded, refund_failed
Disputedispute_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:

  1. Split the header into t and v1.
  2. Compute HMAC-SHA512(secret, t + "." + rawBody) and hex-encode it.
  3. Compare it to v1 in constant time.
  4. Reject if t is more than 5 minutes old (replay protection).
Use the raw body

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".

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.

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 2xx quickly (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 2xx was lost). Make your handler idempotent: de-duplicate on event_id, or on content.object.payment_id plus content.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_processing can land after payment_succeeded. Two safe patterns:
    1. Only act on terminal states (succeeded, failed) and treat non-terminal events as informational.
    2. 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.

Never let a webhook move an order backwards (for example from paid to pending because a stale processing event arrived late).

  1. Receive the webhook and read the raw body.
  2. Verify the TensorRail-Signature. If it fails, respond 400 and stop.
  3. Check event_id against your processed-events store; if seen, respond 2xx and stop.
  4. Look up the order by content.object.metadata / content.object.payment_id.
  5. On content.object.status == "succeeded", fulfil the order once, idempotently; on "failed", mark the attempt failed and let checkout offer a new payment.
  6. Record event_id as processed and respond 2xx. 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.