TigerPayDevelopers API v1 tigerpay.app

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 https and 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#

EventSent whendata.object
payment.succeededThe customer paid.Payment
payment.failedThe payment failed.Payment
payment.cancelledThe payment was cancelled.Payment
refund.succeededA refund finished.Refund
refund.failedA refund did not go through.Refund
charge.dispute.createdA customer's bank has raised a dispute (chargeback) on one of your payments. See Disputes.Dispute
webhook.testYou 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:

FieldDetails
id
string
The event id, starts with evt_. Use it to spot repeats.
type
string
The event name, such as payment.succeeded.
created
integer
When the event was created, as a Unix time in seconds.
livemode
boolean
true for live events.
data.object
object
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#

  1. Find the order. data.object.payment is the id of the payment. Look up your order with it.
  2. 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.
  3. Pause anything still to ship. If the order has not gone out yet, hold it.
  4. 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#

FieldDetails
id
string
The dispute id. Starts with dp_.
object
string
Always "dispute".
payment
string
The id of the payment that is being disputed. Starts with pay_.
amount
string
The disputed amount in AED with two decimals. It can be less than the payment amount.
currency
string
The currency of the dispute, such as "AED".
reason
string
The type of case, as given by the customer's bank, such as "FRAUD". Treat it as free text, because new values can appear.
status
string
One of open, won, lost, accepted or closed. In the charge.dispute.created event it is open.
livemode
boolean
true for a live dispute, false for a test one.
disputed_at
string or null
When the bank raised the dispute. ISO 8601, UTC. null if the bank did not say.
created_at
string
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 2xx status 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-Typeapplication/json
TigerPay-Signaturet=1792490551,v1=<64 hex characters> (see below)
User-AgentTigerPay-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
  • t is the time the request was sent, as a Unix time in seconds.
  • v1 is a hex HMAC-SHA256. The key is your endpoint's signing secret (the whole whsec_… string). The signed text is the t value, a dot, then the raw request body.
v1 = hex( HMAC_SHA256( key = whsec_…, message = "<t>.<raw body>" ) )

To verify:

  1. 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.
  2. Split the header into t and v1.
  3. Work out the expected signature with your secret.
  4. Compare it with v1 using a constant-time comparison.
  5. Check that t is 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 in metadata). 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 200 for 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:

AttemptSentTime since the first attempt
1Straight away0
21 minute after attempt 1 failedabout 1 minute
35 minutes after attempt 2 failedabout 6 minutes
430 minutes after attempt 3 failedabout 36 minutes
52 hours after attempt 4 failedabout 2 hours 36 minutes
66 hours after attempt 5 failedabout 8 hours 36 minutes
724 hours after attempt 6 failedabout 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 id and the same body. Only the signature time t changes.
  • If your server was down for a long time, catch up with GET /v1/payments, or GET /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. Returning 200 means "done".

Troubleshooting#

ProblemCheck
Signature never matchesYou 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 failsYour server clock is wrong by more than 5 minutes. Turn on automatic time sync.
Nothing arrivesThe URL must be public https. Firewalls, password protection and maintenance pages block webhooks. Check you chose the right events and the right mode.
Test locallyLocalhost is refused. Use a tunnel that gives you a public https address, and register that as a test endpoint.