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"
}
}
| Field | Description |
|---|---|
type | payout.updated, or payout.test (see below) |
id | The event id. De-duplicate on this |
data | The 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.testYour 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.
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:
- Take the
tandv1values from theX-Luxfintech-Payout-Signatureheader. - Compute
HMAC-SHA256(secret, "<t>.<raw request body>")— the raw bytes, not re-serialised JSON. - 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
2xxcounts as delivered. Redirects are not followed: a3xxcounts as a failed attempt, so configure the final URL. - The URL must be
httpsand resolve to a public address. Each retry is re-signed with a fresht; the body andidnever change. - Answer
2xxquickly and do your work afterwards.
📡 Quick start
- Node.js
- Python
- PHP
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"
});
import hashlib, hmac, json, os, time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
PAYOUT_WEBHOOK_SECRET = os.environ["PAYOUT_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 5 * 60
def verify_payout_signature(raw_body: bytes, header: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts.get("t", ""))
except ValueError:
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
PAYOUT_WEBHOOK_SECRET, f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/payout-webhook")
async def payout_webhook(request: Request):
body = await request.body()
sig = request.headers.get("X-Luxfintech-Payout-Signature", "")
if not verify_payout_signature(body, sig):
raise HTTPException(status_code=403, detail="Invalid signature")
event = json.loads(body)
if event["type"] == "payout.updated": # ignore payout.test
pass # queue the work: de-duplicate on event["id"]; event["data"]["status"] is "paid"
return {"status": "ok"}
<?php
$secret = getenv('PAYOUT_WEBHOOK_SECRET');
$header = $_SERVER['HTTP_X_LUXFINTECH_PAYOUT_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
$parts = [];
foreach (explode(',', $header) as $p) {
[$k, $v] = array_pad(explode('=', trim($p), 2), 2, '');
$parts[$k] = $v;
}
$t = (int)($parts['t'] ?? 0);
$v1 = $parts['v1'] ?? '';
if (!$t || abs(time() - $t) > 300) {
http_response_code(403);
exit('Stale timestamp');
}
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
if (!hash_equals($expected, $v1)) {
http_response_code(403);
exit('Invalid signature');
}
http_response_code(200);
$event = json_decode($body, true);
if (($event['type'] ?? '') === 'payout.updated') {
// queue the work: de-duplicate on $event['id']; $event['data']['status'] is 'paid'
}