Skip to main content

Payout webhooks

Webhooks are optional. They are not needed to be approved, to switch payouts on, or to send payouts — from the dashboard or from the API. Without one, poll Get payout — the browser returning to your return_url proves nothing and is not a substitute.

Your administrator sets one up after approval, in the dashboard under Send payouts → Settings → API integration (optional). Saving an endpoint shows its signing secret once; Send test event delivers a payout.test to it; Stop sending removes it and discards the secret.

📬 When we send one​

Only when a payout is paid. Each payout produces at most one payout.updated event, with data.status of paid, sent to the URL your administrator configures.

Every other outcome — expired, cancelled, failed, review_required, and the move to processing — is not pushed. To learn about those, poll Get payout or List payouts. In particular, a payout that expires or fails returns its money to your balance without a webhook: if you hold anything on your side for an open payout, poll until it reaches a final status.

POST /your/endpoint
X-Luxfintech-Payout-Signature: t=1759036800,v1=3f2a…
Content-Type: application/json

📦 Event body​

{
"type": "payout.updated",
"id": "evt_5b1e9c2a7d4f8e3a6c0b9d12",
"data": {
"payout_id": "po_9f3c1d7b2a8e4c15d0b6a291",
"merchant_reference": "refund-ORD-10231",
"customer_id": null,
"status": "paid",
"status_version": 4,
"amount": "25.00",
"fee": "0.50",
"total_reserved": "25.50",
"currency": "USD",
"delivery": "link",
"funds_released": false,
"receipt_reference": "rcpt_4f2a8c1d9b3e5a7c6d0f",
"created_at": "2026-09-28T09:14:22Z",
"updated_at": "2026-09-28T09:33:41Z"
}
}
FieldDescription
typepayout.updated, or payout.test (see below)
idThe event id. De-duplicate on this
dataThe payout at the moment it was paid

data is a subset of the merchant view. For fields it does not carry, call Get payout.

payout.test

Your administrator can send a test event from the dashboard. It has "type": "payout.test", an id starting with evt_test_, and no payout behind it — no money is reserved. Acknowledge it with 2xx and do not act on it. Always check type before reading data.payout_id.

Different from checkout webhooks

This is a different secret and a different header from the checkout webhook (X-Luxfintech-Signature). A verifier written for that one will not work here.

🔐 Signature verification​

Verify before trusting the body:

  1. Take the t and v1 values from the X-Luxfintech-Payout-Signature header.
  2. Compute HMAC-SHA256(secret, "<t>.<raw request body>") — the raw bytes, not re-serialised JSON.
  3. Compare in constant time, and reject a timestamp more than 5 minutes old.

🔁 Delivery and retries​

  • De-duplicate on id. Delivery is at-least-once: a delivery that succeeds but loses the connection before we read your response is indistinguishable from one that failed, and we retry.
  • Five attempts — after 30 seconds, 5 minutes, 1 hour and 23 hours — then we stop and keep the event.
  • Only a 2xx counts as delivered. Redirects are not followed: a 3xx counts as a failed attempt, so configure the final URL.
  • The URL must be https and resolve to a public address. Each retry is re-signed with a fresh t; the body and id never change.
  • Answer 2xx quickly and do your work afterwards.

📡 Quick start​

import crypto from 'crypto';
import express from 'express';

const PAYOUT_WEBHOOK_SECRET = process.env.PAYOUT_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;

function verifyPayoutSignature(secret, rawBody, header) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('=', 2))
);
const t = Number(parts.t);
const v1 = parts.v1 || '';
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

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

const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();
app.post('/payout-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.get('X-Luxfintech-Payout-Signature') || '';
if (!verifyPayoutSignature(PAYOUT_WEBHOOK_SECRET, req.body, sig)) {
return res.status(403).send('Invalid signature');
}
res.sendStatus(200); // answer quickly
const event = JSON.parse(req.body.toString('utf8'));
if (event.type !== 'payout.updated') return; // e.g. payout.test
// queue the work: de-duplicate on event.id; event.data.status is "paid"
});