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 secretstore 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

  1. Extract X-Webhook-Timestamp and X-Webhook-Signature-256 from the headers
  2. Reject if the timestamp is older than 5 minutes (prevents replays)
  3. Compute HMAC-SHA256(secret, timestamp + "." + rawBody)
  4. 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 -- a payment.failed sample carries failureCode, a payment.returned sample carries returnReasonCode. The payment.* wildcard and statement.generated are not triggerable.
  • The envelope's synthetic flag is true, 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 2xx status — do heavy processing asynchronously
  • Handle duplicates — use the event id for idempotency
  • Don't rely on arrival order — reconcile with status, previousStatus, and createdAt
  • Monitor delivery status — log webhook deliveries on your side and inspect responses to debug setup issues