API reference
Webhooks
A webhook is a request that TigerPay sends to your server when something happens, such as a customer paying. It is the most reliable way to know a payment succeeded, even if the customer closes their browser.
Set up an endpoint#
Add webhook endpoints in your TigerPay merchant dashboard. For each one you choose:
- URL. It must be
httpsand reachable from the internet.localhost, private IP addresses and internal host names are refused. - Events. Which of the events below you want.
- Mode. Test or live. A test endpoint only gets test events. A live endpoint only gets live events.
You can have up to 5 endpoints. When you create an endpoint, the dashboard shows its signing secret once. It starts with whsec_. Save it somewhere safe, for example in an environment variable. To change a secret, create a new endpoint and delete the old one.
Events#
| Event | Sent when | data.object |
|---|---|---|
payment.succeeded | The customer paid. | Payment |
payment.failed | The payment failed. | Payment |
payment.cancelled | The payment was cancelled. | Payment |
refund.succeeded | A refund finished. | Refund |
refund.failed | A refund did not go through. | Refund |
charge.dispute.created | A customer's bank has raised a dispute (chargeback) on one of your payments. See Disputes. | Dispute |
webhook.test | You press the test button for an endpoint in the dashboard. | A short test message |
There is no webhook for a payment that expired. To catch those, check pending payments with GET /v1/payments/{id}. Ignore event types you do not know, and answer them with 200, so new events never break your handler.
The payload#
Every webhook is a POST with a JSON body:
| Field | Details |
|---|---|
idstring | The event id, starts with evt_. Use it to spot repeats. |
typestring | The event name, such as payment.succeeded. |
createdinteger | When the event was created, as a Unix time in seconds. |
livemodeboolean | true for live events. |
data.objectobject | The Payment, Refund or Dispute as it was when the event happened. |
payment.succeeded#
{
"id": "evt_yrNktBXtnjfObINf5AjxvUlK",
"type": "payment.succeeded",
"created": 1792490551,
"livemode": false,
"data": {
"object": {
"id": "pay_r3QA7YeEEBY3ABp3e2zS8iq9",
"object": "payment",
"status": "succeeded",
"amount": "340.00",
"amount_refunded": "0.00",
"currency": "AED",
"fee": "7.65",
"net": "332.35",
"reference": "order-1001",
"description": "Blue mug",
"customer_email": "buyer@example.com",
"metadata": { "cart_id": "42" },
"checkout_url": null,
"card": { "brand": "VISA", "last4": "4242", "country": "AE" },
"failure_message": null,
"livemode": false,
"created_at": "2026-10-20T10:00:00.000Z",
"paid_at": "2026-10-20T10:02:31.000Z"
}
}
}
refund.succeeded#
{
"id": "evt_siC47wqaMl9Xvq2ZG4MzAOUQ",
"type": "refund.succeeded",
"created": 1792494012,
"livemode": false,
"data": {
"object": {
"id": "re_y7AjzQHb6BAEcn6zJ4A3DdvH",
"object": "refund",
"payment": "pay_r3QA7YeEEBY3ABp3e2zS8iq9",
"amount": "100.00",
"currency": "AED",
"status": "succeeded",
"reason": null,
"livemode": false,
"created_at": "2026-10-20T11:00:00.000Z"
}
}
}
charge.dispute.created#
{
"id": "evt_Qm8cVt2LrXa5JkP0wZyN4DhB",
"type": "charge.dispute.created",
"created": 1792490400,
"livemode": false,
"data": {
"object": {
"id": "dp_Hk3tYb9QxLm2WcVn7RzP5SaD",
"object": "dispute",
"payment": "pay_r3QA7YeEEBY3ABp3e2zS8iq9",
"amount": "340.00",
"currency": "AED",
"reason": "FRAUD",
"status": "open",
"livemode": false,
"disputed_at": "2026-10-21T07:53:20.000Z",
"created_at": "2026-10-21T08:00:00.000Z"
}
}
}
webhook.test#
{
"id": "evt_…",
"type": "webhook.test",
"created": 1792494012,
"livemode": false,
"data": {
"object": { "object": "test", "message": "This is a test webhook from TigerPay." }
}
}
Disputes (chargebacks)#
A dispute, also called a chargeback, is when a customer asks their own bank to take a card payment back. They might say they did not make the purchase, did not receive the goods, or were charged the wrong amount. The bank takes the money from the payment while it looks into the claim, and it asks for your side of the story through the card networks.
When that happens on one of your payments, TigerPay sends you the charge.dispute.created event. It is sent once per dispute, even if our system receives the bank's notice more than once. A dispute is not a refund: you did not choose to give the money back, so there is no refund object and no refund.succeeded event.
What to do about a dispute#
- Find the order.
data.object.paymentis the id of the payment. Look up your order with it. - Reply with proof, quickly. Send TigerPay support proof of delivery or receipt, for example a tracking number and delivery confirmation, a signed receipt, the customer's messages, or a log showing that a digital item was used. Include the dispute id (
dp_…) and the payment id. Banks give only a short time to answer, so do not wait. - Pause anything still to ship. If the order has not gone out yet, hold it.
- Do not refund the payment and also fight the dispute. Refunding while a dispute is open can mean the customer gets the money twice. Ask TigerPay support first.
The customer's bank makes the final decision. A fee applies for each chargeback case, as listed on the Pricing page.
Keep your proof ready
Store delivery confirmations, receipts and customer messages against each order. Then you can answer a dispute within minutes instead of hunting for them.
The dispute object#
| Field | Details |
|---|---|
idstring | The dispute id. Starts with dp_. |
objectstring | Always "dispute". |
paymentstring | The id of the payment that is being disputed. Starts with pay_. |
amountstring | The disputed amount in AED with two decimals. It can be less than the payment amount. |
currencystring | The currency of the dispute, such as "AED". |
reasonstring | The type of case, as given by the customer's bank, such as "FRAUD". Treat it as free text, because new values can appear. |
statusstring | One of open, won, lost, accepted or closed. In the charge.dispute.created event it is open. |
livemodeboolean | true for a live dispute, false for a test one. |
disputed_atstring or null | When the bank raised the dispute. ISO 8601, UTC. null if the bank did not say. |
created_atstring | When TigerPay recorded the dispute. ISO 8601, UTC. |
There are no API endpoints for disputes yet. Use the webhook, and save the dispute id from it.
What your endpoint must do#
- Answer with any
2xxstatus within 10 seconds. That tells TigerPay the webhook arrived. - Redirects (
3xx) are not followed. They count as a failure. - Keep the response body small (a few words is plenty). TigerPay ignores it.
- Do the slow work after you answer. Save the event, reply
200, then process it.
Each request has these headers:
Content-Type | application/json |
TigerPay-Signature | t=1792490551,v1=<64 hex characters> (see below) |
User-Agent | TigerPay-Webhooks/1.0 |
Verify the signature#
Anyone can send a request to your webhook URL. Check the signature on every request, and reject the ones that fail.
The TigerPay-Signature header looks like this:
TigerPay-Signature: t=1792490551,v1=50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c
tis the time the request was sent, as a Unix time in seconds.v1is a hex HMAC-SHA256. The key is your endpoint's signing secret (the wholewhsec_…string). The signed text is thetvalue, a dot, then the raw request body.
v1 = hex( HMAC_SHA256( key = whsec_…, message = "<t>.<raw body>" ) )
To verify:
- Read the raw body exactly as it arrived. Do not parse it and turn it back into JSON, because the bytes would change and the check would fail.
- Split the header into
tandv1. - Work out the expected signature with your secret.
- Compare it with
v1using a constant-time comparison. - Check that
tis within 5 minutes of your clock. This stops someone replaying an old request.
If anything fails, answer 400 and do nothing else.
import crypto from "node:crypto";
import express from "express";
const app = express();
const WEBHOOK_SECRET = process.env.TIGERPAY_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300; // 5 minutes
// rawBody must be a Buffer holding the bytes exactly as received.
function verifySignature(rawBody, header, secret) {
if (!header) return false;
let t = null;
const signatures = [];
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i < 0) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === "t") t = value;
if (key === "v1") signatures.push(value);
}
if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
// Reject old or far-future timestamps.
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest();
return signatures.some((sig) => {
const got = Buffer.from(sig, "hex");
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}
// express.raw() keeps the body as a Buffer. Do not use express.json() on this route.
app.post(
"/webhooks/tigerpay",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verifySignature(req.body, req.get("TigerPay-Signature"), WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
// …handle the event (see "Handle events safely" below)…
res.sendStatus(200);
}
);
app.listen(3000);
<?php
// webhook.php
$secret = getenv('TIGERPAY_WEBHOOK_SECRET'); // whsec_…
$tolerance = 300; // 5 minutes
function tigerpay_verify(string $payload, string $header, string $secret, int $tolerance): bool
{
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
if ($pair[0] === 't') {
$t = $pair[1];
} elseif ($pair[0] === 'v1') {
$sigs[] = strtolower($pair[1]);
}
}
if ($t === null || !ctype_digit($t) || count($sigs) === 0) {
return false;
}
// Reject old or far-future timestamps.
if (abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) {
return true;
}
}
return false;
}
// The raw body, exactly as sent. Do not use $_POST and do not re-encode.
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_TIGERPAY_SIGNATURE'] ?? '';
if (!tigerpay_verify($payload, $header, $secret, $tolerance)) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($payload, true);
// …handle the event (see "Handle events safely" below)…
http_response_code(200);
echo 'OK';
Test and live secrets are different
A test endpoint has its own whsec_ secret, and so does a live endpoint. If a check fails only in one mode, make sure you are using the secret from the endpoint of that mode.
Handle events safely#
TigerPay may send the same event more than once, and events can arrive out of order. Your handler must be safe to run twice.
- Event ids repeat. A retry is the same event with the same
id. Store the ids you have handled and skip repeats. - Check the order, not only the event. Look up your order with
data.object.reference(or your own value inmetadata). Make sure the amount and currency match before you fulfil it. If the order is already paid, do nothing. - Save the event id and update the order in one database transaction, so a crash cannot leave one without the other.
- Do not rely on order. If you are unsure, fetch the payment with
GET /v1/payments/{id}and use its current status. - Answer
200for events you do not need, including unknown event types.
async function handleEvent(event) {
// Safe to run twice: the unique constraint on event_id stops a second run.
await db.transaction(async (tx) => {
const firstTime = await tx.insertIgnore("processed_events", { event_id: event.id });
if (!firstTime) return; // already handled
if (event.type === "payment.succeeded") {
const payment = event.data.object;
const order = await tx.findOrderByReference(payment.reference);
if (!order) return;
if (order.status === "paid") return;
if (payment.currency !== "AED" || payment.amount !== order.totalAed) {
await tx.flagForReview(order.id, payment.id); // amount does not match
return;
}
await tx.markOrderPaid(order.id, payment.id);
}
if (event.type === "payment.failed" || event.type === "payment.cancelled") {
// Let the customer try again. Do not delete the order.
}
});
}
The helper names above (db, insertIgnore, findOrderByReference and so on) are placeholders for your own code.
Retries#
If your endpoint does not answer with a 2xx within 10 seconds, or cannot be reached, TigerPay tries again. There are 7 attempts in total. Each wait is counted from the failed attempt before it:
| Attempt | Sent | Time since the first attempt |
|---|---|---|
| 1 | Straight away | 0 |
| 2 | 1 minute after attempt 1 failed | about 1 minute |
| 3 | 5 minutes after attempt 2 failed | about 6 minutes |
| 4 | 30 minutes after attempt 3 failed | about 36 minutes |
| 5 | 2 hours after attempt 4 failed | about 2 hours 36 minutes |
| 6 | 6 hours after attempt 5 failed | about 8 hours 36 minutes |
| 7 | 24 hours after attempt 6 failed | about 32 hours 36 minutes |
If attempt 7 also fails, TigerPay stops, and that delivery is marked failed. Timings are close, not to the second.
- Every retry sends the same event, with the same
idand the same body. Only the signature timetchanges. - If your server was down for a long time, catch up with
GET /v1/payments, orGET /v1/payments/{id}for orders that are still waiting. - Return a non-2xx status on purpose (for example
503) if you cannot process an event yet and want a retry. Returning200means "done".
Troubleshooting#
| Problem | Check |
|---|---|
| Signature never matches | You must hash the raw bytes. Frameworks that parse JSON first (express.json(), $_POST) change the body. Also check you use the secret of this endpoint, in the same mode. |
| Timestamp check fails | Your server clock is wrong by more than 5 minutes. Turn on automatic time sync. |
| Nothing arrives | The URL must be public https. Firewalls, password protection and maintenance pages block webhooks. Check you chose the right events and the right mode. |
| Test locally | Localhost is refused. Use a tunnel that gives you a public https address, and register that as a test endpoint. |