Luzapay

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.

Request
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.

FieldDetails
amountRequiredString or number, from 0.01 to 100,000,000, with at most 2 decimals.
currencyOptionalMust be "USD", which is the default.
product_nameRequiredShown to the buyer. Up to 200 characters.
referenceOptionalYour own order reference. String of up to 200 characters, with no control characters.
buyer_emailOptionalUp to 200 characters. We send the buyer a receipt here once they have paid.
redirect_urlOptionalA 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.

Request
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"
  }'
Response · 201
{
  "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.

Response · 200
{
  "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.

Response · 400
{
  "error": "invalid",
  "message": "Enter a product name of up to 200 characters."
}
StatusErrorMeaning
400invalidA field or the Idempotency-Key is not valid.
401unauthorizedThe API key is missing, wrong or revoked.
403suspendedThe account is suspended.
403wrong_modeThis live or test key can't be used right now.
404not_foundNo payment with that id on your account.
422idempotency_key_reusedThe Idempotency-Key was already used for a different request.
429rate_limitedMore than 60 requests in a minute on this key. Try again in a minute.
503unavailableTemporary 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.

Payload
{
  "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.

EventMeaning
payment.paidThe buyer paid in full. This is the only event that means you should fulfil the order.
payment.underpaidThe buyer paid too little. This is final and nothing is credited to your balance.
payment.expiredThe payment window ended. It can still become paid or underpaid if money arrives late.
payment.failedThe 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.

Node
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
<?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);

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.

Download v1.0.1 (beta)

Beta — test on a staging store first.

  1. Upload the plugin under Plugins, Add New, Upload Plugin, and activate it.
  2. Go to WooCommerce, Settings, Payments, Luzapay, and enter your API key.
  3. In your Luzapay dashboard, set the webhook URL to the one shown on the plugin's settings page.
  4. 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.