# Webhooks

Money moves on its own clock, so the parts of this API you probably care most about, like the
vendor getting paid and the payer settling, happen long after your request returned. Subscribe
once instead of polling for them.

```bash
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" }'
```

Leaving `eventTypes` out subscribes you to everything, including events we add later, which is
what most integrations want. The response carries the signing secret, and that is the only time
you will see it.

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

## Events

| Event | When |
| --- | --- |
| `partner.organization.added` | a business you synced exists at Lunch |
| `partner.organization.optedIn` | it has cleared onboarding |
| `partner.organization.remittanceUpdated` | where its money goes has changed |
| `partner.invoice.created` | an invoice you synced exists |
| `partner.invoice.paid` | the payer settled it |
| `partner.invoice.factoredUpdated` | how much of it is financed has changed |
| `partner.loan.created` | an advance was taken |
| `partner.loan.issued` | the vendor has been paid |
| `partner.loan.paid` | the 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.

```js
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);
};
```

<Callout type="caution" title="Check the timestamp, not just the signature">
Skip the age check and all the signature tells you is that the body was ours at some point, not
that this particular delivery is fresh. Reject anything older than five minutes.
</Callout>

## What a receiver has to cope with

Delivery is at-least-once, so a retry can turn up after a success you failed to record.
`Lunch-Delivery` stays the same across attempts, so key your deduplication on that.

Deliveries can also arrive out of order, and the ordering does not sort itself out afterwards. A
delivery that failed and got retried can land after a later one already succeeded, which means a
financing update can reach you behind the state that superseded it.
`partner.invoice.factoredUpdated` carries a `sequence` for this reason: ignore any whose sequence
is not greater than the one you already hold for that invoice.

Do not reach for `occurredAt` to order them instead. It records when the transaction started, so
two events can easily carry stamps in the opposite order to their causes. Use it in your logs.

## Answering

Any `2xx` accepts the delivery. Anything else gets retried over roughly 28 hours. If the endpoint
is gone for good, answer `410` and we will stop sending and revoke the subscription.

[`GET /v1/webhooks`](/reference/webhooks#list-your-subscriptions) lists your live subscriptions, without their
secrets. [`DELETE /v1/webhooks/{reference}`](/reference/webhooks#stop-sending-to-a-destination) stops one.
