Webhook events
Tranzak sends a signed HTTPS request to your server when a payment succeeds or fails. Events, payload, headers, retries and best practices.
A webhook is an HTTPS request that Tranzak sends to your server when something happens to a payment. It is the primary way to learn that a payment is final.
Set up
Each website has a webhook URL and a webhook secret, both in the dashboard under Developer → Websites.
- The URL must be
https://on a public domain name. Addresses that point tolocalhost, private networks, cloud-metadata addresses or raw IP addresses are refused when you register them, and any webhook aimed at a non-public address is blocked when it is sent. - Keep the secret in an environment variable (
TRANZAK_WEBHOOK_SECRET). You need it to verify the signature.
Events
| Event | Sent when |
|---|---|
payment.success |
The payment is completed. |
payment.failed |
The payment is failed: refused, abandoned or expired. |
The request
Tranzak sends a POST with a JSON body and these headers:
Content-Type: application/json
X-Tranzak-Signature: <hex hmac-sha256>
X-Tranzak-Event: payment.success
X-Tranzak-Timestamp: 1791122041
User-Agent: Tranzak-Webhook/1.0
The body:
{
"event": "payment.success",
"timestamp": 1791122041,
"data": {
"transaction_id": "17843270387081",
"amount": "250.00",
"currency": "HTG",
"status": "completed",
"payment_method": "natcash",
"reference": "ORDER-4821",
"description": null,
"customer": { "name": "Jean Dupont", "email": null, "phone": null },
"created_at": "2026-10-04T14:00:00.000000Z",
"completed_at": "2026-10-04T14:03:10.000000Z",
"failed_at": null,
"failure_reason": null,
"metadata": null
}
}
data has the same shape as a retrieved payment. For lakaypay_card recurring payments, metadata.subscription_id carries the subscription identifier.
Reliability rules
- Answer
2xxquickly. Do heavy work after you respond. A non-2xxresponse or a timeout (15 seconds) is retried, up to 3 attempts, with growing delays. - Deduplicate. The same event can arrive more than once. Key on
data.transaction_idplusevent, and make the order update idempotent. - Do not reject old timestamps. Retries reuse the original
timestamp. Rejecting "old" events would drop legitimate retries. Your idempotency is your replay protection. For extra certainty, read the payment again withGET /payments/{transaction_id}. - No webhook is guaranteed for an expired payment. A transaction that stays
processingfor 15 minutes is markedfailed, and you may not get an event. Reconcile pending orders on a timer. - Exclude the route from CSRF and login middleware. The webhook is authenticated by its signature, not by a session.
- Never log the secret or the full signature header.
Example handler
This handler checks the signature on the raw body, answers quickly, and ignores duplicates. See Verify the signature for the check in more languages.
const express = require('express');
const crypto = require('crypto');
const app = express();
// Use the raw body on this route: the signature is computed on the exact bytes received.
app.post('/webhooks/tranzak', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-Tranzak-Signature') || '';
const expected = crypto.createHmac('sha256', process.env.TRANZAK_WEBHOOK_SECRET)
.update(req.body).digest('hex');
const valid = signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!valid) return res.status(403).send('Invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // answer first
if (event.event === 'payment.success') {
// Idempotent: mark the order paid only the first time for this transaction_id
}
});
import hmac, hashlib, json, os
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/tranzak")
def tranzak_webhook():
raw = request.get_data() # the exact bytes received
signature = request.headers.get("X-Tranzak-Signature", "")
expected = hmac.new(os.environ["TRANZAK_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return "Invalid signature", 403
event = json.loads(raw)
if event["event"] == "payment.success":
pass # Idempotent: mark the order paid only the first time for this transaction_id
return "", 200
$raw = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_TRANZAK_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw, getenv('TRANZAK_WEBHOOK_SECRET'));
if (! hash_equals($expected, $signature)) {
http_response_code(403);
exit('Invalid signature');
}
$event = json_decode($raw, true);
http_response_code(200);
if ($event['event'] === 'payment.success') {
// Idempotent: mark the order paid only the first time for this transaction_id
}
Test your handler
Build a signed request yourself: compute the HMAC of a sample body with a test secret and send it to your endpoint. See Sandbox and test mode.