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

  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.

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.

webhooks.endpoints.create response
{
  "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.

invoice.paid delivery
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
  }
}
FieldMeaning
idThe event's unique ID. The same event always has the same ID, so use it to skip duplicates.
typeWhat happened, such as invoice.paid. See the event catalog.
createdWhen it happened, in Unix seconds.
organizationIdThe company it happened in.
subjectThe record it's about: type and id, or null.
dataDetails for this event type. Mail events never include the sender, subject or content.

Each delivery also carries these headers:

HeaderValue
Content-TypeAlways application/json.
User-AgentOatmilk-Webhooks/1.0.
Oatmilk-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>, checked as described in Verify signatures.
Oatmilk-Event-IdThe event's ID, the same on every retry. Use it to skip duplicates.
Oatmilk-Event-TypeThe event type, such as invoice.paid.
Oatmilk-Delivery-IdThis delivery's ID, as shown in the delivery log.
Oatmilk-Delivery-Attempt1 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:

AttemptSentSince the first try
1Right after the event—
230 seconds after the last try30 seconds
31.5 minutes after the last try2 minutes
44.5 minutes after the last try6.5 minutes
513.5 minutes after the last try20 minutes
640.5 minutes after the last try1 h 1 min
72 h 2 min after the last try3 h 2 min
86 hours after the last try9 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