API Reference
Webhooks
PayLio sends a signed POST request to your callback URL when a payment is completed.
How delivery works#
| Property | Value |
|---|---|
| Method | POST with a JSON body |
| URL | The callback you passed to Create Checkout |
| Signature | X-PayLio-Signature: hex HMAC-SHA256 of the raw body |
| Signing key | The payment's ipn_token |
| Timeout | 10 seconds |
| Success | Any 2xx response |
| Redirects | Not followed. Point the callback at the final URL. |
Webhooks are sent once and not retried
If your endpoint is down, slow or returns an error, the event is not sent again. Use Payment Status to reconcile open orders, for example with a scheduled job.Events#
| Event | When |
|---|---|
payment.paid | The payment was received. For card payments, the USDC has also been sent to your wallet. |
payment.forwarded | Direct crypto only: the funds were forwarded to your wallet. |
No event is sent for underpaid or canceled payments. Check them in the dashboard or via the API.
Payload#
payment.paid
POST /paylio/webhook HTTP/1.1
Content-Type: application/json
User-Agent: PayLio-Webhook/1.0
X-PayLio-Signature: 5d41402abc4b2a76b9719d911017c592...
{
"event": "payment.paid",
"id": "3f6c2a1e-8b4d-4c7a-9e21-5d0b7f9a1c34",
"paymentId": "3f6c2a1e-8b4d-4c7a-9e21-5d0b7f9a1c34",
"status": "paid",
"amount": "54.34",
"originalAmount": "49.99",
"customerFeeAmount": "4.35",
"passFeeToCustomer": true,
"currency": "USD",
"coin": "polygon_usdc",
"receivedAmount": "53.9",
"forwardedAmount": "49.6",
"txidIn": "0xa22a82b4...",
"txidOut": "0x94c2c3e8...",
"note": "Order #1042",
"timestamp": 1791983126
}Keys in webhook bodies use camelCase, unlike the snake_case API responses.
eventstringpayment.paid or payment.forwarded.paymentIdstringThe PayLio payment ID.
id carries the same value.statusstringAlways
paid for this event.amount / originalAmount / customerFeeAmountstring | nullCharged amount, requested amount and passed-through fee.
coin / receivedAmount / forwardedAmountstring | nullSettlement asset and amounts.
txidIn / txidOutstring | nullOn-chain transaction hashes.
notestring | nullThe note you set on the payment.
timestampnumberUnix time (seconds) when the event was sent.
payment.forwarded#
{
"event": "payment.forwarded",
"paymentId": "3f6c2a1e-8b4d-4c7a-9e21-5d0b7f9a1c34",
"forwardTxid": "0x7be1...",
"amountForwarded": "98.5",
"toWallet": "0xYourWallet",
"timestamp": 1791983311
}Verify and handle a webhook#
- Read the raw request body before parsing it.
- Compute HMAC-SHA256 of the raw body with the stored
ipn_tokenand compare it toX-PayLio-Signaturein constant time. - Confirm the payment with
GET /payment-status. - Fulfil the order only once, even if the same event reaches you twice.
- Respond with
200within 10 seconds and do slow work afterwards.
import crypto from "node:crypto";
// Use the raw body: the signature is computed over the exact bytes.
app.post("/paylio/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const event = JSON.parse(req.body.toString("utf8"));
const order = await findOrderByPaylioId(event.paymentId);
if (!order) return res.status(404).send("unknown payment");
const expected = crypto
.createHmac("sha256", order.paylioIpnToken)
.update(req.body)
.digest("hex");
const received = String(req.get("X-PayLio-Signature") || "");
if (received.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
return res.status(403).send("invalid signature");
}
// Never trust the webhook alone: confirm with the API.
const url = `https://paylio.org/api/v1/payment-status?payment_id=${encodeURIComponent(event.paymentId)}`;
const payment = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.PAYLIO_API_KEY}` },
}).then((r) => r.json());
if (payment.status === "paid" && !order.paid) {
await markOrderPaid(order.id, payment.txid_out);
}
res.status(200).send("ok");
});Callback URL requirements#
| Rule | Why |
|---|---|
| Must use https:// | Webhooks are never sent over plain HTTP. |
| Must resolve to a public IP | localhost, private networks and cloud metadata hosts are blocked. |
| No user:password@ in the URL | Credentials in URLs are rejected. |
| Max 2048 characters | Longer URLs fail validation. |
Put an order reference in the path or query of the callback (e.g.
/paylio/webhook?order=1042) to find the order faster. Allow PayLio's requests through your firewall, WAF or bot protection.