# Webhooks

> Get a signed HTTPS request the moment something happens in Oatmilk.

Source: https://app.getoatmilk.com/docs/webhooks

Webhooks tell your server when something happens: an invoice is paid, a document is signed, a receipt finishes processing, a deadline comes up. Instead of asking Oatmilk every few minutes, you give it an address and it sends you each event as it happens.

## How it works

1. You add an endpoint, an `https://` address on your server, and choose which events it receives.
2. When an event happens, Oatmilk sends a `POST` with the event as JSON, signed with your endpoint's secret.
3. Your server checks the signature, answers `2xx` quickly, and does the work afterwards.
4. If your server doesn't answer `2xx`, Oatmilk tries again later, waiting longer each time.

## Add an endpoint

An administrator adds endpoints in **Developers › Webhooks**, or with the API. Subscribe to exact event names such as `invoice.paid`, to a group such as `invoice.*`, or to `*` for everything.

```bash
curl https://app.getoatmilk.com/api/v1/accounting/webhooks.endpoints.create \
  -H "Authorization: Bearer $OATMILK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "url": "https://example.com/webhooks/oatmilk",
  "description": "CRM sync",
  "events": [
    "invoice.*"
  ]
}'
```

The response includes the endpoint's signing secret. It's shown once, so store it with your other secrets right away.

```json
{
  "data": {
    "endpoint": {
      "id": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a",
      "url": "https://example.com/webhooks/oatmilk",
      "description": "CRM sync",
      "events": [
        "invoice.*"
      ],
      "secret_hint": "a1B2",
      "secret_version": 1,
      "active": true,
      "failure_count": 0,
      "disabled_reason": null,
      "disabled_at": null,
      "last_delivery_at": null,
      "last_success_at": null,
      "created_by": "user_synthetic",
      "archived_at": null,
      "revision": 1,
      "created_at": "2026-09-30T14:00:00Z",
      "updated_at": "2026-09-30T14:00:00Z",
      "status": "active"
    },
    "secret": "whsec_synthetic_shown_once_store_it_now"
  }
}
```

Endpoints must use `https://` on port 443 or 8443 with a public address. Private and local network addresses are refused, except `http://localhost` while you develop.

## What you receive

Every delivery is a JSON event with the same envelope. `type` says what happened, `subject` names the record it happened to, and `data` holds the details for that type.

```http
POST /webhooks/oatmilk HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Oatmilk-Webhooks/1.0
Oatmilk-Signature: t=1790000000,v1=054f6210bdad1839a948f5b1baa4c10b12f49d1268668775cd06aae44572ee04
Oatmilk-Event-Id: 5b5965f9-0d1e-4f2a-8b3c-4d5e6f7a8b9c
Oatmilk-Event-Type: invoice.paid
Oatmilk-Delivery-Id: 3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f
Oatmilk-Delivery-Attempt: 1

{
  "id": "5b5965f9-0d1e-4f2a-8b3c-4d5e6f7a8b9c",
  "type": "invoice.paid",
  "created": 1790000000,
  "organizationId": "org_synthetic",
  "subject": {
    "type": "invoice",
    "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10"
  },
  "data": {
    "number": "INV-2026-012",
    "status": "paid",
    "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d",
    "currency": "CAD",
    "totalMinor": "113000",
    "amountPaidMinor": "113000",
    "balanceMinor": "0",
    "issueDate": "2026-09-01",
    "dueDate": "2026-10-01",
    "scheduledSendDate": null
  }
}
```

| Field | Meaning |
| --- | --- |
| `id` | The event's unique ID. The same event always has the same ID, so use it to skip duplicates. |
| `type` | What happened, such as `invoice.paid`. See the [event catalog](https://app.getoatmilk.com/docs/webhooks/events.md). |
| `created` | When it happened, in Unix seconds. |
| `organizationId` | The company it happened in. |
| `subject` | The record it's about: `type` and `id`, or `null`. |
| `data` | Details for this event type. Mail events never include the sender, subject or content. |

Each delivery also carries these headers:

| Header | Value |
| --- | --- |
| `Content-Type` | Always `application/json`. |
| `User-Agent` | `Oatmilk-Webhooks/1.0`. |
| `Oatmilk-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>`, checked as described in Verify signatures. |
| `Oatmilk-Event-Id` | The event's ID, the same on every retry. Use it to skip duplicates. |
| `Oatmilk-Event-Type` | The event type, such as `invoice.paid`. |
| `Oatmilk-Delivery-Id` | This delivery's ID, as shown in the delivery log. |
| `Oatmilk-Delivery-Attempt` | 1 for the first try, then 2, 3 and so on for retries. |

## Answer quickly

Oatmilk waits 10 seconds for an answer. Check the signature, store the event, answer `200`, and do slow work, such as calling other services, afterwards in a queue. A slow answer counts as a failure and the event is sent again.

## Retries

When your endpoint doesn't answer `2xx`, times out or can't be reached, Oatmilk tries again, up to 8 times in all:

| Attempt | Sent | Time since the first try |
| --- | --- | --- |
| 1 | Right after the event | — |
| 2 | 30 seconds after the last try | 30 seconds |
| 3 | 1.5 minutes after the last try | 2 minutes |
| 4 | 4.5 minutes after the last try | 6.5 minutes |
| 5 | 13.5 minutes after the last try | 20 minutes |
| 6 | 40.5 minutes after the last try | 1 h 1 min |
| 7 | 2 h 2 min after the last try | 3 h 2 min |
| 8 | 6 hours after the last try | 9 h 2 min |

Redirects aren't followed, and `410 Gone` stops deliveries straight away. After 20 failed attempts in a row with no success in the last day, Oatmilk turns the endpoint off and emails your administrators. Fix it, turn it back on in Developers › Webhooks, and retry failed deliveries from the delivery log.

## Handle duplicates and order

Deliveries are at least once: a retry, or a lost response, can bring the same event twice. Store each event `id` you've handled and skip repeats. Events can also arrive out of order, so when order matters, read the record from the API and act on its current state rather than on the order events arrived.

## Next

- [Verify signatures](https://app.getoatmilk.com/docs/webhooks/signatures.md) before you trust a delivery.
- Browse the [event catalog](https://app.getoatmilk.com/docs/webhooks/events.md) with a sample of every event.
- [Test your endpoint](https://app.getoatmilk.com/docs/webhooks/testing.md) without waiting for real events.
