Webhooks
Webhooks
Get a signed HTTPS request the moment something happens in Oatmilk.
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
- You add an endpoint, an
https://address on your server, and choose which events it receives. - When an event happens, Oatmilk sends a
POSTwith the event as JSON, signed with your endpoint's secret. - Your server checks the signature, answers
2xxquickly, and does the work afterwards. - 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.
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.
{
"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.
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. |
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 | 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 before you trust a delivery.
- Browse the event catalog with a sample of every event.
- Test your endpoint without waiting for real events.