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 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.type tells you what kind of object the event carries (payment_details, refund_details, dispute_details, mandate_details, payout_details, subscription_details or api_key_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​

Every event your endpoint can receive. event_type carries the name; content.object carries the object the event is about.

EventGroupSent when
payment_processingPaymentThe payment was submitted and its outcome is not final yet.
payment_succeededPaymentThe payment succeeded. Fulfil the order on this event.
payment_failedPaymentThe payment failed. The payment object carries the error code.
action_requiredPaymentThe payment is waiting for an action before it can complete, usually the customer's approval.
payment_authorizedPaymentThe payment was authorized and is waiting for you to capture it.
payment_partially_authorizedPaymentThe payment was authorized for less than the amount you requested.
payment_capturedPaymentPart of an authorized payment was captured.
payment_cancelledPaymentThe payment was cancelled before it was captured.
payment_cancelled_post_capturePaymentThe payment was cancelled after it was captured. Treat it like a refund, not like payment_cancelled.
payment_expiredPaymentThe payment was not completed before it expired.
refund_succeededRefundThe refund completed.
refund_failedRefundThe refund failed.
dispute_openedDisputeA new dispute. Respond before its challenge_required_by deadline. See Disputes.
dispute_challengedDisputeYou submitted evidence and the dispute is awaiting an outcome.
dispute_acceptedDisputeYou accepted the dispute and the funds are returned to the customer.
dispute_expiredDisputeThe response window passed without a challenge.
dispute_cancelledDisputeThe dispute was withdrawn or cancelled.
dispute_wonDisputeThe dispute was resolved in your favour.
dispute_lostDisputeThe dispute was resolved against you and the funds are returned to the customer.
mandate_activeMandateA saved payment method can now be charged again. See Recurring and saved methods.
mandate_revokedMandateA saved payment method can no longer be charged.
invoice_paidSubscriptionA subscription invoice was paid.
payout_initiatedRefund payoutA refund returned to the payer as a payout was created. See Methods without a refund API.
payout_processingRefund payoutThe refund payout was sent and its outcome is not final yet.
payout_successRefund payoutThe refund payout succeeded.
payout_failedRefund payoutThe refund payout failed, so the payer was not paid.
payout_cancelledRefund payoutThe refund payout was cancelled before it was sent.
payout_expiredRefund payoutThe refund payout expired before it was sent.
payout_reversedRefund payoutA refund payout that had succeeded was returned.
key_emergency_revokedAccount and securityAn 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:

  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.

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

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.

RetryWait after the previous attemptTime after the event
11 minute1 minute
2 and 35 minutes6 and 11 minutes
4 to 810 minutes21 minutes to 1 hour 1 minute
9 to 131 hour2 hours 1 minute to 6 hours 1 minute
14 to 166 hours12 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).

  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.