Webhooks
Luqra sends event notifications to your HTTPS endpoints when things happen in your account — like a payment changing status or a statement being generated. You register webhook endpoints, and Luqra POSTs signed JSON payloads to them.
Registering an endpoint
curl -X POST https://{api-host}/v1/webhooks \
-H "Authorization: Bearer $YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhooks/luqra",
"subscribedEvents": ["statement.generated"]
}'
The response includes a secret — store it securely. It's only returned once and is used to verify webhook signatures.
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"url": "https://your-server.com/webhooks/luqra",
"subscribedEvents": ["statement.generated"],
"label": null,
"enabled": true,
"secret": "<your-64-character-hex-secret>",
"createdAt": "2026-04-15T12:00:00.000Z"
},
"meta": { "timestamp": "2026-05-01T10:00:00.000Z" }
}
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | yes | HTTPS endpoint URL (max 2000 characters) |
subscribedEvents |
string[] | yes | At least one event type from the list below, or the payment.* wildcard |
label |
string | no | Free-text label for your own bookkeeping (1-255 characters). Never affects delivery. |
URL requirements
- Must be HTTPS (HTTP is rejected)
- Must be publicly routable (private IPs, localhost, and link-local addresses are blocked)
- Must not contain embedded credentials in the URL
Event types
Event names are dotted lowercase in the form resource.event, grouped under a resource prefix. All payment events live under the payment. prefix, and the suffix mirrors the payment's status token in lowercase -- the payment.completed event carries "status": "COMPLETED" in its payload.
Payment events
A payment event fires on every status transition of a payment -- one event type per status you can observe via GET /v1/payments/:id. See Payment Lifecycle for how payments move between statuses.
| Event | Payment status |
Fires when |
|---|---|---|
payment.awaiting_confirmation |
AWAITING_CONFIRMATION |
A payment was created via the Luqra Now app and is waiting for the recipient to confirm. API-created payments do not enter this state. |
payment.queued |
QUEUED |
The payment is queued for processing on the payment rail |
payment.processing |
PROCESSING |
The payment was submitted to the payment rail |
payment.completed |
COMPLETED |
The payment was handed off to the payment network |
payment.failed |
FAILED |
A failure prevented processing; no funds were moved. Check failureCode in the payload. |
payment.failed_in_flight |
FAILED_IN_FLIGHT |
The payment was submitted but confirmation was not received in time; under review |
payment.canceled |
CANCELED |
The payment was canceled before processing began |
payment.returned |
RETURNED |
A completed payment was returned by the receiving bank. Check returnReasonCode in the payload. |
payment.blocked |
BLOCKED |
The payment was stopped by our automated review process |
Only originator payments emit events -- internal money movements (fee collection, refunds spawned by a return) do not. A return still reaches you as payment.returned on the original payment.
Statement events
| Event | Description |
|---|---|
statement.generated |
A monthly statement PDF has been generated for an originator |
The payment.* family
Subscribe to payment.* to receive every payment event, including event types Luqra adds in the future. It is the only wildcard -- a global * is rejected. You can combine it with concrete event names in the same subscription.
Subscription limits
At most 5 endpoints per event type can be subscribed in your organization, enforced when you create or update an endpoint (HTTP 400 when exceeded). A payment.* subscription counts toward the limit of every payment event type. Disabling an endpoint does not free its slots -- only deleting it does.
Payload format
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"version": "1",
"eventType": "statement.generated",
"createdAt": "2026-04-15T12:00:00.000Z",
"synthetic": false,
"data": {
"statementId": "44b1e845-31ff-4fdb-ad9c-50b0e111f0e4",
"originatorId": "19ea54fc-b0be-4adc-88f1-faa7e28d6d8b",
"periodYear": 2026,
"periodMonth": 3
}
}
version identifies the envelope schema. The current value is "1". Luqra may add new fields to the envelope or to the data object at any time — your handler must ignore unknown fields without erroring.
synthetic distinguishes test deliveries from real events: true on events fired from the test endpoint (including sample payment events), false on every real event. A production handler should check this flag before acting on a payload — synthetic events reference sample entities that do not exist in your account.
createdAt on payment events is when the event was emitted, frozen across retries of the same delivery. On statement events it is stamped when the delivery attempt is signed, so a retried delivery carries a fresh value. Deduplicate on the envelope id, not on createdAt.
Payment event payloads
For payment events, data is the payment exactly as GET /v1/payments/:id would have returned it at the moment of the status transition, plus one extra field: previousStatus. The snapshot is frozen when the event is emitted — retries deliver the identical payload, and later transitions never rewrite earlier events.
On a payment.awaiting_confirmation event the counterparty has not yet submitted their bank details on the payment link: contact.bankAccount is null, and the whole contact object is null when the counterparty is not an existing contact. Both are populated on every event from payment.queued onward. A payment.canceled event is the exception: cancellation is only possible before submission, so it carries the same nulls as payment.awaiting_confirmation.
{
"id": "7d0f7d0a-92b5-4f0e-9c66-8f4f5f2a1b3c",
"version": "1",
"eventType": "payment.completed",
"createdAt": "2026-05-01T10:00:00.000Z",
"synthetic": false,
"data": {
"paymentId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"status": "COMPLETED",
"previousStatus": "PROCESSING",
"createdAt": "2026-05-01T09:45:00.000Z",
"completedAt": "2026-05-01T10:00:00.000Z",
"failedAt": null,
"returnedAt": null,
"canceledAt": null,
"paymentAmount": 100000,
"discountFee": 250,
"flatFee": 100,
"returnFee": 0,
"currencyCode": "USD",
"paymentRail": "ACH",
"direction": "OUTBOUND",
"paymentNote": "Invoice 1042",
"externalPaymentId": "pay-1042",
"failureCode": null,
"failureMessage": null,
"failureReason": null,
"returnReasonCode": null,
"originator": {
"id": "9b2f8c44-1d3e-4a5b-8c6d-7e8f9a0b1c2d",
"firstName": null,
"lastName": null,
"companyName": "Acme Corp LLC",
"entityType": "BUSINESS",
"bankAccount": {
"bankName": "First Platypus Bank",
"swiftCode": null,
"subType": "CHECKING",
"achRoutingNumber": "*****0021",
"achAccountNumber": "******7890"
}
},
"contact": {
"id": "3c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f",
"firstName": "Jane",
"lastName": "Doe",
"companyName": null,
"entityType": "INDIVIDUAL",
"bankAccount": {
"bankName": "Sample Credit Union",
"swiftCode": null,
"subType": "SAVINGS",
"achRoutingNumber": "*****0025",
"achAccountNumber": "******4321"
}
}
}
}
Fields
| Field | Type | Description |
|---|---|---|
paymentId |
string (UUID) | Payment identifier -- the same id you use with GET /v1/payments/:id |
status |
string | The status the payment transitioned to. Matches the event type's suffix in SCREAMING_SNAKE case. |
previousStatus |
string or null | The status the payment transitioned from. Always present; null when the payment was created directly in this status -- payments created through the API start in QUEUED, and payments created in the Luqra Now app may start in AWAITING_CONFIRMATION. |
createdAt |
string (ISO 8601) | When the payment was created (not the event) |
completedAt |
string or null | When the payment completed |
failedAt |
string or null | When the payment failed |
returnedAt |
string or null | When the payment was returned |
canceledAt |
string or null | When the payment was canceled |
paymentAmount |
integer | Payment amount in minor units (cents) |
discountFee |
integer | Discount fee in minor units |
flatFee |
integer | Flat fee in minor units |
returnFee |
integer | Return fee in minor units |
currencyCode |
string | Currency code, e.g. USD |
paymentRail |
string | ACH, FEDNOW, RTP, FEDWIRE, or SWIFT |
direction |
string | INBOUND or OUTBOUND |
paymentNote |
string or null | Free-text note attached to the payment |
externalPaymentId |
string or null | Your identifier for the payment |
failureCode |
string or null | INVALID_ORIGINATOR_DATA, INVALID_CONTACT_DATA, or INTERNAL_ERROR. Populated on failures. |
failureMessage |
string or null | Actionable failure text (names the rejected field for data failures) |
failureReason |
string or null | Human-readable failure explanation; populated when status is FAILED or FAILED_IN_FLIGHT |
returnReasonCode |
string or null | ACH return code (e.g. R01) when status is RETURNED |
originator |
object | The payment's originator party (see below) |
contact |
object or null | The payment's contact party (see below); null when the counterparty is not an existing contact and has not yet submitted their details |
Each party object (originator, contact) contains:
| Field | Type | Description |
|---|---|---|
id |
string (UUID) | Party identifier -- use it to route events to the right records on your side |
firstName |
string or null | First name (individuals) |
lastName |
string or null | Last name (individuals) |
companyName |
string or null | Company name (businesses) |
entityType |
string | INDIVIDUAL or BUSINESS |
bankAccount |
object or null | Bank account with PII-masked numbers (see below). Always set on originator; null on contact until the counterparty's bank details are submitted. |
bankAccount fields:
| Field | Type | Description |
|---|---|---|
bankName |
string or null | Bank name, unmasked |
swiftCode |
string or null | SWIFT/BIC code, masked |
subType |
string | CHECKING or SAVINGS |
achRoutingNumber |
string | ACH routing number, masked |
achAccountNumber |
string | Account number, masked |
Bank data masking
Webhook payloads mask bank data exactly like the API does: leading asterisks with the last four characters visible (e.g. *****0021; account numbers of four or fewer digits show only the last two). Full numbers never appear in a webhook payload.
Verifying signatures
Every webhook delivery includes these headers:
| Header | Description |
|---|---|
X-Webhook-Signature-256 |
HMAC-SHA256 signature: sha256=<hex> |
X-Webhook-Timestamp |
ISO 8601 timestamp of when the payload was signed |
X-Webhook-Event |
The event type (e.g., statement.generated) |
X-Webhook-Delivery |
Delivery ID, unique per event and endpoint; stays the same across retries |
The signature is computed over {timestamp}.{body} — the timestamp and raw request body joined with a period. This prevents replay attacks.
Verification steps
- Extract
X-Webhook-TimestampandX-Webhook-Signature-256from the headers - Reject if the timestamp is older than 5 minutes (prevents replays)
- Compute
HMAC-SHA256(secret, timestamp + "." + rawBody) - Compare your computed signature with the header value using constant-time comparison
Node.js example
import {createHmac, timingSafeEqual} from 'node:crypto'
const verifyWebhook = (
rawBody: string,
signature: string, // X-Webhook-Signature-256 header
timestamp: string, // X-Webhook-Timestamp header
secret: string, // Your webhook secret
): boolean => {
// 1. Check timestamp freshness (5 minute window)
const age = Date.now() - new Date(timestamp).getTime()
if (Math.abs(age) > 5 * 60 * 1000) return false
// 2. Compute expected signature
const signedPayload = `${timestamp}.${rawBody}`
const expected = `sha256=${createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex')}`
// 3. Constant-time comparison
if (expected.length !== signature.length) return false
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
}
Python example
import hmac
import hashlib
from datetime import datetime, timedelta, timezone
def verify_webhook(raw_body: str, signature: str, timestamp: str, secret: str) -> bool:
# 1. Check timestamp freshness
ts = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
if abs((datetime.now(timezone.utc) - ts).total_seconds()) > 300:
return False
# 2. Compute expected signature
signed_payload = f"{timestamp}.{raw_body}"
expected = "sha256=" + hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
# 3. Constant-time comparison
return hmac.compare_digest(expected, signature)
Managing endpoints
List
curl "https://{api-host}/v1/webhooks" \
-H "Authorization: Bearer $YOUR_API_KEY"
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"url": "https://your-server.com/webhooks/luqra",
"subscribedEvents": ["statement.generated", "payment.*"],
"label": "Accounting system",
"enabled": true,
"createdAt": "2026-04-15T12:00:00.000Z",
"updatedAt": "2026-04-15T12:00:00.000Z"
}
],
"meta": { "timestamp": "2026-05-01T10:00:00.000Z" }
}
Get
curl "https://{api-host}/v1/webhooks/WEBHOOK_ID" \
-H "Authorization: Bearer $YOUR_API_KEY"
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"url": "https://your-server.com/webhooks/luqra",
"subscribedEvents": ["statement.generated", "payment.*"],
"label": "Accounting system",
"enabled": true,
"createdAt": "2026-04-15T12:00:00.000Z",
"updatedAt": "2026-04-15T12:00:00.000Z"
},
"meta": { "timestamp": "2026-05-01T10:00:00.000Z" }
}
Update
Update the URL, subscribed events, label, or enabled status. All fields are optional -- only the fields you include are changed. subscribedEvents, when provided, replaces the full subscription (an empty array unsubscribes the endpoint from everything). Set label to null to clear it.
curl -X PATCH "https://{api-host}/v1/webhooks/WEBHOOK_ID" \
-H "Authorization: Bearer $YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled": false}'
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"url": "https://your-server.com/webhooks/luqra",
"subscribedEvents": ["statement.generated"],
"label": null,
"enabled": false,
"createdAt": "2026-04-15T12:00:00.000Z",
"updatedAt": "2026-05-01T10:00:00.000Z"
},
"meta": { "timestamp": "2026-05-01T10:00:00.000Z" }
}
Delete
curl -X DELETE "https://{api-host}/v1/webhooks/WEBHOOK_ID" \
-H "Authorization: Bearer $YOUR_API_KEY"
Returns 204 No Content on success.
Endpoint summary
| Method | Path | Description |
|---|---|---|
POST |
/v1/webhooks |
Create a new endpoint |
GET |
/v1/webhooks |
List all endpoints |
GET |
/v1/webhooks/:id |
Get endpoint details |
PATCH |
/v1/webhooks/:id |
Update URL, events, label, or enabled status |
DELETE |
/v1/webhooks/:id |
Remove an endpoint |
POST |
/v1/webhooks/:id/test |
Send a test event |
Testing your endpoint
Send a test event to verify your endpoint is reachable and correctly configured:
curl -X POST https://{api-host}/v1/webhooks/WEBHOOK_ID/test \
-H "Authorization: Bearer $YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
{
"data": {
"success": true,
"statusCode": 200,
"error": null,
"durationMs": 142
},
"meta": { "timestamp": "2026-05-01T10:00:00.000Z" }
}
With an empty body {}, the test sends a TEST event with a sample payload. Your endpoint should return a 2xx response. If the delivery fails, success is false, statusCode is the HTTP status your endpoint returned (or null if the connection failed), error contains a description of the failure, and durationMs reports the delivery latency in milliseconds.
Firing a sample payment event
Pass an eventType to receive a realistic sample of a specific payment event instead -- same envelope, signing, and payload shape as the real event, carrying representative synthetic data:
curl -X POST https://{api-host}/v1/webhooks/WEBHOOK_ID/test \
-H "Authorization: Bearer $YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"eventType": "payment.completed"}'
- Any payment event type is accepted (e.g.
payment.completed,payment.failed,payment.returned). Status-specific fields are included -- apayment.failedsample carriesfailureCode, apayment.returnedsample carriesreturnReasonCode. Thepayment.*wildcard andstatement.generatedare not triggerable. - The envelope's
syntheticflag istrue, and the payload references stable sample entities (paymentId, originator, and contact ids are fixed synthetic UUIDs). - The event fires regardless of the endpoint's subscriptions, is not persisted, and never appears in delivery records -- it exists purely to exercise your handler's signature verification, parsing, and routing.
Delivery contract
At-least-once. Every delivery is attempted until your endpoint acknowledges it, so the same event can arrive more than once (for example, when your endpoint responds slowly and the attempt is retried). Deduplicate on the envelope id -- it is stable across every retry of the same event to your endpoint.
No ordering guarantee. Events are delivered independently, and retries can reorder them -- a payment.completed may arrive before the payment.processing that preceded it. Do not treat arrival order as transition order; reconcile using the payload's status and previousStatus plus the envelope createdAt.
Retry window. A delivery fails on a non-2xx response or a timeout (your endpoint has 30 seconds to respond). Failed deliveries are retried with exponential backoff -- starting at 30 seconds and doubling up to a 1 hour cap between attempts -- for up to 72 hours for payment events and up to 14 days for statement events. After the window closes, the delivery is not attempted again.
Permanent client errors stop retries. If your endpoint returns a 4xx status other than 408, 425, or 429, the delivery is treated as permanently failed (a misconfigured or dead endpoint will not self-heal) and is not retried.
The X-Webhook-Delivery header remains the same across retries of the same delivery.
Best practices
- Always verify signatures before processing the payload
- Respond quickly with a
2xxstatus — do heavy processing asynchronously - Handle duplicates — use the event
idfor idempotency - Don't rely on arrival order — reconcile with
status,previousStatus, andcreatedAt - Monitor delivery status — log webhook deliveries on your side and inspect responses to debug setup issues