Lunch
Guides

Webhooks

Money moves on its own clock, so the parts of this API you care most about — the vendor being paid, the payer settling — happen long after your request returned. Subscribe once and stop polling.

TerminalCode
curl -X POST https://api.luxor.lunchpayments.com/v1/webhooks \ -H "Authorization: Bearer lux_sk_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/lunch/webhooks" }'

Leave eventTypes out to get everything, including events we add later — which is what most integrations want. The response carries the signing secret, once. We never return it again.

The endpoint must be https on the public internet. We refuse anything resolving to a private, loopback or link-local address, and we do not follow redirects.

Events

EventWhen
partner.organization.addeda business you synced exists at Lunch
partner.organization.optedInit has cleared onboarding
partner.organization.remittanceUpdatedwhere its money goes has changed
partner.invoice.createdan invoice you synced exists
partner.invoice.paidthe payer settled it
partner.invoice.factoredUpdatedhow much of it is financed has changed
partner.loan.createdan advance was taken
partner.loan.issuedthe vendor has been paid
partner.loan.paidthe advance is settled

Verifying a delivery

Every delivery carries Lunch-Signature: t=<unix seconds>,v1=<hex>. The signature is HMAC-SHA256 over the exact string "<timestamp>.<raw body>", keyed with your subscription secret.

JavascriptCode
import { createHmac, timingSafeEqual } from 'node:crypto'; // `raw` is the unparsed request body. Parsing and re-serialising it will not verify. // Anything malformed answers false rather than throwing: this runs on an endpoint the whole // internet can reach, and a header somebody made up should not become a 500. export const verify = (raw, header, secret) => { if (typeof header !== 'string') return false; const parts = Object.fromEntries( header.split(',').map((part) => { const at = part.indexOf('='); return at === -1 ? ['', ''] : [part.slice(0, at).trim(), part.slice(at + 1).trim()]; }), ); if (!parts.t || !parts.v1 || !/^[0-9a-f]+$/i.test(parts.v1)) return false; const age = Math.abs(Date.now() / 1000 - Number(parts.t)); if (!Number.isFinite(age) || age > 300) return false; const expected = createHmac('sha256', secret).update(`${parts.t}.${raw}`).digest(); const given = Buffer.from(parts.v1, 'hex'); return expected.length === given.length && timingSafeEqual(expected, given); };

Check the timestamp, not just the signature

Without the age check the signature says only that the body was ours once — not that this delivery is fresh. Reject anything older than five minutes.

Three properties a receiver has to respect

At-least-once. A retry can arrive after a success you failed to record. Lunch-Delivery is stable across attempts — key your deduplication on it.

Unordered, and it does not resolve itself. A delivery that failed and was retried can land after a later one succeeded, so a financing update can arrive behind the state that replaced it. partner.invoice.factoredUpdated carries a sequence for exactly this: ignore one whose sequence is not greater than the one you already hold for that invoice.

occurredAt is not an ordering key. It is the transaction's start time, so two events can carry stamps in the opposite order to their causes. It is for your logs.

Answering

2xx accepts. Anything else is retried over about 28 hours. Answer 410 when the endpoint is gone for good and we stop and revoke the subscription.

GET /v1/webhooks lists your live subscriptions, secrets omitted. DELETE /v1/webhooks/{reference} stops one.

Last modified on