PayLioDocs

API Reference

Webhooks

PayLio sends a signed POST request to your callback URL when a payment is completed.

How delivery works#

PropertyValue
MethodPOST with a JSON body
URLThe callback you passed to Create Checkout
SignatureX-PayLio-Signature: hex HMAC-SHA256 of the raw body
Signing keyThe payment's ipn_token
Timeout10 seconds
SuccessAny 2xx response
RedirectsNot 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#

EventWhen
payment.paidThe payment was received. For card payments, the USDC has also been sent to your wallet.
payment.forwardedDirect 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.

eventstring
payment.paid or payment.forwarded.
paymentIdstring
The PayLio payment ID. id carries the same value.
statusstring
Always paid for this event.
amount / originalAmount / customerFeeAmountstring | null
Charged amount, requested amount and passed-through fee.
coin / receivedAmount / forwardedAmountstring | null
Settlement asset and amounts.
txidIn / txidOutstring | null
On-chain transaction hashes.
notestring | null
The note you set on the payment.
timestampnumber
Unix 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#

  1. Read the raw request body before parsing it.
  2. Compute HMAC-SHA256 of the raw body with the stored ipn_token and compare it to X-PayLio-Signature in constant time.
  3. Confirm the payment with GET /payment-status.
  4. Fulfil the order only once, even if the same event reaches you twice.
  5. Respond with 200 within 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#

RuleWhy
Must use https://Webhooks are never sent over plain HTTP.
Must resolve to a public IPlocalhost, private networks and cloud metadata hosts are blocked.
No user:password@ in the URLCredentials in URLs are rejected.
Max 2048 charactersLonger 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.