Pre-launch

Webhooks

Know the moment money moves.

Webhooks push events to your server as they happen, so you never have to poll. Each one is signed, retried until you acknowledge it and safe to process more than once.

◷ Sandbox opens to waitlist developers first. Live keys follow once TujuPay is licensed.

Set up an endpoint

  1. 1

    Add your URL

    In the dashboard, open Developers, then Webhooks, and add a public HTTPS URL on your server.

  2. 2

    Choose events

    Pick the events you need, or subscribe to all of them while you build.

  3. 3

    Copy the signing secret

    Each endpoint has its own secret, starting with whsec_. Store it like a password.

Verify the signature

Every request has a TujuPay-Signature header with a timestamp and an HMAC-SHA256 signature of the timestamp and the raw request body. Compute the same signature with your secret and compare. Reject events older than five minutes to stop replays.

Header format
TujuPay-Signature: t=1791536400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Node.js
import crypto from "node:crypto";

export function verifyWebhook(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const age = Date.now() / 1000 - Number(parts.t);
  if (age > 300) throw new Error("Event too old");

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
  if (!ok) throw new Error("Bad signature");
  return JSON.parse(rawBody);
}
PHP
<?php
function verify_webhook(string $rawBody, string $header, string $secret): array {
    parse_str(str_replace(',', '&', $header), $parts);
    if (time() - (int) $parts['t'] > 300) {
        throw new Exception('Event too old');
    }
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    if (!hash_equals($expected, $parts['v1'])) {
        throw new Exception('Bad signature');
    }
    return json_decode($rawBody, true);
}

Retries

If your server does not return a 2xx within 10 seconds, we retry with growing gaps for up to three days. You can also resend any event from the dashboard.

  1. 11 minute
  2. 25 minutes
  3. 330 minutes
  4. 42 hours
  5. 56 hours
  6. 6Then every 12 hours, up to 3 days

Events

EventSent when
payment.succeededThe customer paid and the money is confirmed
payment.failedThe bank declined or the customer abandoned checkout
payment.expiredThe payment was not completed within 30 minutes
refund.succeededA refund reached the customer
refund.failedA refund could not be completed
payout.scheduledToday's payout was calculated and is about to be sent
payout.paidThe payout reached your bank
payout.heldA payout was paused. The event explains why

Best practices

  • Return 200 straight away and do slow work, such as sending emails, in a background job.
  • Store each event ID and skip ones you have already handled. Retries can deliver an event twice.
  • Always verify the signature. Never trust the amount or status in a redirect URL.
  • Fetch the payment from the API if you need the very latest state; events can arrive out of order.