Tranzak Docs
tranzak.co Dashboard
Docs  /  Webhooks

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 to localhost, 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 2xx quickly. Do heavy work after you respond. A non-2xx response 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_id plus event, 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 with GET /payments/{transaction_id}.
  • No webhook is guaranteed for an expired payment. A transaction that stays processing for 15 minutes is marked failed, 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.