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
Add your URL
In the dashboard, open Developers, then Webhooks, and add a public HTTPS URL on your server.
- 2
Choose events
Pick the events you need, or subscribe to all of them while you build.
- 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.
TujuPay-Signature: t=1791536400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
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
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.
- 11 minute
- 25 minutes
- 330 minutes
- 42 hours
- 56 hours
- 6Then every 12 hours, up to 3 days
Events
| Event | Sent when |
|---|---|
| payment.succeeded | The customer paid and the money is confirmed |
| payment.failed | The bank declined or the customer abandoned checkout |
| payment.expired | The payment was not completed within 30 minutes |
| refund.succeeded | A refund reached the customer |
| refund.failed | A refund could not be completed |
| payout.scheduled | Today's payout was calculated and is about to be sent |
| payout.paid | The payout reached your bank |
| payout.held | A 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.