# Webhooks

Advance and payment events occur after your request returns. Subscribe to receive them instead of
polling.

## Subscribing

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

Omit `eventTypes` to receive all events, including future ones.

<Callout type="caution" title="Store the signing secret immediately">
The signing secret is returned only in this response. If the response is lost, see
[Idempotent Requests](/idempotent-requests) before retrying.
</Callout>

## Technical Details

| Requirement | Detail |
| --- | --- |
| Endpoint | Public `https` URL. |
| Refused addresses | Private, loopback and link-local. |
| Redirects | Not followed. |
| Success | Any `2xx` response. |
| Retries | Other responses are retried for about 28 hours. |
| Retired endpoints | Return `410` to stop deliveries and revoke the subscription. |

## Authentication

Each delivery includes `Lunch-Signature: t=<unix seconds>,v1=<hex>`: an HMAC-SHA256 of
`"<timestamp>.<raw body>"`, keyed with your signing 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">
Reject deliveries older than five minutes. A valid signature does not prove a delivery is recent.
</Callout>

## Webhook Idempotency

Delivery is at-least-once. Deduplicate on `Lunch-Delivery`, which is constant across retries.

## Ordering

Delivery order is not guaranteed, and out-of-order deliveries are not corrected. A retried delivery
can arrive after a later one.

- `partner.invoice.factoredUpdated` includes a `sequence`. Ignore events whose `sequence` is not
  greater than the last one processed for that invoice.
- Do not order by `occurredAt`. It is the transaction start time. Use it for logging.

## Events

Event names use `loan` for advances.

| Event | Description |
| --- | --- |
| `partner.organization.added` | An organization you synced exists at Lunch. |
| `partner.organization.optedIn` | The organization has completed onboarding. |
| `partner.organization.remittanceUpdated` | The destination for the organization's funds has changed. |
| `partner.invoice.created` | An invoice you synced exists at Lunch. |
| `partner.invoice.paid` | The payor has paid the invoice. |
| `partner.invoice.factoredUpdated` | The advance's financing state has changed: `REQUESTED`, `FUNDING`, `FUNDED` or `REPAID`. |
| `partner.loan.created` | An advance has been created. |
| `partner.loan.issued` | The vendor has been paid the advance. |
| `partner.loan.paid` | The advance has settled. |

## Managing Subscriptions

| Action | Call |
| --- | --- |
| List active subscriptions (secrets excluded) | [`GET /v1/webhooks`](/reference/webhooks#list-webhook-subscriptions) |
| Delete a subscription | [`DELETE /v1/webhooks/{reference}`](/reference/webhooks#delete-a-webhook-subscription) |

## What's Next

- [API reference](/reference)
