Developers
Create payments from your server and get signed webhooks when they change.
Base URL https://app.luzapay.com
Authentication
Create an API key on the Developers page of your dashboard and send it as a bearer token. Use it from your server only, never in a web page or app. Each key is limited to 60 requests a minute.
curl https://app.luzapay.com/api/v1/payments/PAYMENT_ID \
-H "Authorization: Bearer YOUR_API_KEY"Create a payment
POST/api/v1/payments
Returns the payment id, the checkout link to send your buyer to, the status and your reference.
| Field | Details | |
|---|---|---|
amount | Required | String or number, from 0.01 to 100,000,000, with at most 2 decimals. |
currency | Optional | Must be "USD", which is the default. |
product_name | Required | Shown to the buyer. Up to 200 characters. |
reference | Optional | Your own order reference. String of up to 200 characters, with no control characters. |
buyer_email | Optional | Up to 200 characters. We send the buyer a receipt here once they have paid. |
redirect_url | Optional | A full https:// address of up to 2000 characters. Buyers return here after paying. |
We store reference and return it in API responses and webhooks. If you don't send one, API responses return null and webhooks use the payment id.
To retry safely, send an optional Idempotency-Key header of 1 to 255 visible ASCII characters, unique to the order. Retrying with the same key and request returns the same payment with status 200 and an Idempotent-Replayed: true header. A new payment returns 201.
curl -X POST https://app.luzapay.com/api/v1/payments \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{
"amount": "25.00",
"currency": "USD",
"product_name": "Pro plan",
"reference": "order-1042",
"buyer_email": "buyer@example.com",
"redirect_url": "https://example.com/thanks"
}'{
"id": "2b7e4c1a-93d0-4f6e-8a51-0c7d2e9b4f13",
"url": "https://checkout.example/your-payment-page",
"status": "pending",
"reference": "order-1042"
}Get a payment
GET/api/v1/payments/{id}
status is one of pending, paid, underpaid, expired or failed. method is card or crypto. method, fee_amount, net_amount and paid_at are null until the payment is settled. A buyer returning to your site doesn't mean they paid, so check the status first.
{
"id": "2b7e4c1a-93d0-4f6e-8a51-0c7d2e9b4f13",
"status": "paid",
"reference": "order-1042",
"amount": "25.00000000",
"currency": "USD",
"product_name": "Pro plan",
"url": "https://checkout.example/your-payment-page",
"method": "card",
"fee_amount": "0.97500000",
"net_amount": "24.02500000",
"created_at": "2026-10-30T11:58:12+00:00",
"paid_at": "2026-10-30T12:00:00+00:00"
}Errors
Errors return a code you can match on and a message you can read.
{
"error": "invalid",
"message": "Enter a product name of up to 200 characters."
}| Status | Error | Meaning |
|---|---|---|
400 | invalid | A field or the Idempotency-Key is not valid. |
401 | unauthorized | The API key is missing, wrong or revoked. |
403 | suspended | The account is suspended. |
403 | wrong_mode | This live or test key can't be used right now. |
404 | not_found | No payment with that id on your account. |
422 | idempotency_key_reused | The Idempotency-Key was already used for a different request. |
429 | rate_limited | More than 60 requests in a minute on this key. Try again in a minute. |
503 | unavailable | Temporary problem. Try again shortly. |
Webhooks
Add an https webhook URL on the Developers page of your dashboard. Your signing secret is shown once, when you create it. We POST this JSON to your URL when a payment changes status.
{
"id": "6f1c8d20-57ab-4e39-b1c4-9a02d7e6f381",
"event": "payment.paid",
"data": {
"payment_id": "2b7e4c1a-93d0-4f6e-8a51-0c7d2e9b4f13",
"reference": "order-1042",
"amount": "25.00",
"currency": "USD",
"status": "paid",
"net_amount": "24.03",
"paid_at": "2026-10-30T12:00:00+00:00"
}
}net_amount and paid_at are null unless the payment is paid. Match events to orders by reference, or by payment_id, the id you got when creating the payment. If you didn't send a reference, reference is the payment id.
| Event | Meaning |
|---|---|
payment.paid | The buyer paid in full. This is the only event that means you should fulfil the order. |
payment.underpaid | The buyer paid too little. This is final and nothing is credited to your balance. |
payment.expired | The payment window ended. It can still become paid or underpaid if money arrives late. |
payment.failed | The payment could not be completed. |
Delivery
Reply with any 2xx status within 5 seconds and do slow work afterwards. Anything else counts as failed, and redirects aren't followed. We retry up to 5 times, after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. An event can arrive more than once or out of order, so ignore an id you've already handled.
Verify the signature
Every request has an X-Luzapay-Signature header in the form t=<unix time>,v1=<signature>. The signature is the HMAC SHA-256 hex of <t>.<body>, using your signing secret. Check it against the exact bytes you received, before parsing the JSON, and refuse a timestamp more than 300 seconds from now.
const crypto = require("crypto");
const express = require("express");
const app = express();
function isValid(rawBody, header, secret) {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || "");
if (!match) return false;
const [, t, v1] = match;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest();
return crypto.timingSafeEqual(expected, Buffer.from(v1, "hex"));
}
// express.raw keeps the exact bytes. express.json would break the signature.
app.post("/webhooks/luzapay", express.raw({ type: "application/json" }), (req, res) => {
if (!isValid(req.body, req.get("X-Luzapay-Signature"), process.env.LUZAPAY_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
if (event.event === "payment.paid") {
// Fulfil the order for event.data.payment_id
}
res.sendStatus(200);
});<?php
$secret = getenv('LUZAPAY_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_LUZAPAY_SIGNATURE'] ?? '';
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)
|| abs(time() - (int) $m[1]) > 300
|| !hash_equals(hash_hmac('sha256', $m[1] . '.' . $body, $secret), $m[2])) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
if ($event['event'] === 'payment.paid') {
// Fulfil the order for $event['data']['payment_id']
}
http_response_code(200);Payment links
No code needed. Create a link on the Payment links page of your dashboard and post it anywhere, in the form https://app.luzapay.com/l/your-link. One link works for any number of buyers, and each buyer gets their own payment.
Set a product name, then either a fixed USD amount or leave the amount empty to let the buyer enter it. You can add a redirect URL, and disable a link at any time. Links are created in the dashboard, not through the API.
WooCommerce plugin
Adds a "Pay with card or crypto" option to the classic and block checkouts. Needs WordPress 6.5, PHP 7.4 and a store currency of USD.
Beta — test on a staging store first.
- Upload the plugin under Plugins, Add New, Upload Plugin, and activate it.
- Go to WooCommerce, Settings, Payments, Luzapay, and enter your API key.
- In your Luzapay dashboard, set the webhook URL to the one shown on the plugin's settings page.
- Paste the webhook secret into the plugin settings, enable the payment method and save.
Orders change payment state only when a signed webhook arrives, never because a buyer returned to your store. Underpaid payments, and paid amounts that differ from the order total, put the order on hold for you to review.