# Oatmilk webhooks > How Oatmilk sends signed events to your server, how to verify them, and a sample of every event. # 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=,v1=`, 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. # Verify signatures > Check that a delivery came from Oatmilk and wasn't changed on the way. Source: https://app.getoatmilk.com/docs/webhooks/signatures Anyone can send a request to your endpoint, so check every delivery before you trust it. Oatmilk signs each one with your endpoint's secret and puts the signature in the `Oatmilk-Signature` header: ```http Oatmilk-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` - `t` is when the delivery was signed, in Unix seconds. - `v1` is an HMAC-SHA256 of the text `{t}.{raw body}`, keyed with your endpoint's secret, as hex. ## Check it 1. Read the **raw body** exactly as it arrived, before any JSON parsing. Parsing and re-serialising changes the bytes, and the signature won't match. 2. Split the header on commas, then each part on the first `=`, to get `t` and `v1`. 3. Refuse the delivery if `t` is more than 300 seconds from now. This stops someone replaying an old delivery. 4. Compute the HMAC of `{t}.{raw body}` with your secret and compare it to `v1` with a constant-time comparison. ```js title="verify.js" import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyOatmilkSignature(rawBody, header, secret, toleranceSeconds = 300) { const parts = Object.fromEntries(header.split(",").map(part => part.trim().split("="))); const timestamp = Number(parts.t); if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest(); const received = Buffer.from(parts.v1 ?? "", "hex"); return received.length === expected.length && timingSafeEqual(received, expected); } // Use the raw request body exactly as received, before JSON parsing: // verifyOatmilkSignature(body, request.headers.get("Oatmilk-Signature"), process.env.OATMILK_WEBHOOK_SECRET) ``` ```python title="verify.py" import hashlib import hmac import time def verify_oatmilk_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool: parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part) try: timestamp = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - timestamp) > tolerance_seconds: return False signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ``` ```ts title="app/webhooks/oatmilk/route.ts" import { verifyOatmilkSignature } from "@/lib/oatmilk-signature"; export async function POST(request: Request) { const rawBody = await request.text(); const signature = request.headers.get("Oatmilk-Signature") ?? ""; if (!verifyOatmilkSignature(rawBody, signature, process.env.OATMILK_WEBHOOK_SECRET!)) { return new Response("Invalid signature", { status: 400 }); } const event = JSON.parse(rawBody); if (await alreadyHandled(event.id)) return new Response(null, { status: 200 }); await enqueue(event); return new Response(null, { status: 200 }); } ``` ## Try it here Paste a delivery's raw body, its `Oatmilk-Signature` header and your endpoint's secret to see whether it verifies, and why not if it doesn't. You can also sign a body with your own secret to send a test delivery to your server. Everything runs in your browser: nothing you type here is sent anywhere. In the HTML version of this page, a playground verifies a delivery or signs a test body with your own secret, entirely in the browser. ## When verification fails | Symptom | Likely cause | | --- | --- | | Every delivery fails | The wrong secret, or a framework parsed the body before you read it. | | Deliveries fail after you rotated the secret | Your server still uses the old secret. Rotation takes effect for the next delivery. | | Only some deliveries fail | A proxy or middleware is changing the body, such as re-encoding characters. | | Failures mention the time | Your server's clock is off by more than 300 seconds. Sync it with NTP. | ## Rotate a secret Rotate an endpoint's secret in Developers › Webhooks or with `webhooks.endpoints.rotateSecret`. The new secret is returned once and signs every delivery from then on, so update your server first, then rotate. # Event catalog > Every event Oatmilk sends, with a sample of each delivery. Source: https://app.getoatmilk.com/docs/webhooks/events These are the 53 events Oatmilk sends today, grouped by area. Pick one to see the exact delivery your endpoint would receive. The samples are built by the same code that sends real deliveries, with synthetic data. Subscribe to an exact name such as `invoice.paid`, to a group with `invoice.*`, or to everything with `*`. New event types may be added: handle types you don't recognise by answering `200` and ignoring them. ### Invoices #### `invoice.created` An invoice was created as a draft. ```json { "id": "bf4e5ff3-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.created", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": null, "status": "draft", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": null, "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.updated` Invoice details changed. ```json { "id": "242a3bc2-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.updated", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": null, "status": "draft", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": null, "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.review_requested` An automatically prepared invoice is waiting for an administrator's review. ```json { "id": "05df9dfe-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.review_requested", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "awaiting_approval", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.approved` An administrator approved a prepared invoice for sending. ```json { "id": "517c257c-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.approved", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "approved", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.issued` An invoice received its number and became final. ```json { "id": "88423668-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.issued", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "approved", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.sent` An invoice was emailed to the customer or marked as sent. ```json { "id": "5b5b1cd5-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.sent", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "sent", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null, "method": "email", "automatic": false } } ``` #### `invoice.payment_recorded` A payment was recorded against an invoice. ```json { "id": "8947d320-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.payment_recorded", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "partially_paid", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "50000", "balanceMinor": "63000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null, "paymentId": "3e4f5a6b-7c8d-4e9f-8a0b-1c2d3e4f5a6b", "paymentAmountMinor": "50000" } } ``` #### `invoice.payment_removed` A recorded payment was removed from an invoice. ```json { "id": "4aaf716a-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.payment_removed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "sent", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null, "paymentId": "3e4f5a6b-7c8d-4e9f-8a0b-1c2d3e4f5a6b", "paymentAmountMinor": "50000" } } ``` #### `invoice.partially_paid` An invoice is partly paid and a balance remains. ```json { "id": "89abb6ea-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.partially_paid", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "partially_paid", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "50000", "balanceMinor": "63000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.paid` An invoice is paid in full. ```json { "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 } } ``` #### `invoice.overdue` An invoice passed its due date with a balance remaining. ```json { "id": "6466ebf5-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.overdue", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "overdue", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.voided` An invoice was voided. ```json { "id": "a6472076-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.voided", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "number": "INV-2026-012", "status": "void", "partyId": "0b7a5d5e-3c34-4b8e-9e6a-5f8d0a1c2b3d", "currency": "CAD", "totalMinor": "113000", "amountPaidMinor": "0", "balanceMinor": "113000", "issueDate": "2026-09-01", "dueDate": "2026-10-01", "scheduledSendDate": null } } ``` #### `invoice.status_changed` An invoice moved to a different status. Sent with the more specific event for the new status. ```json { "id": "64e730e8-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "invoice.status_changed", "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 } } ``` ### Accounts #### `party.created` A customer or vendor account was added. ```json { "id": "d68f1ad6-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "party.created", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "party", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "name": "Synthetic Ventures Inc.", "kind": "customer", "jurisdiction": "CA-ON", "currency": "CAD", "archived": false } } ``` #### `party.updated` Account details changed. ```json { "id": "3b6af6a5-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "party.updated", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "party", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "name": "Synthetic Ventures Inc.", "kind": "customer", "jurisdiction": "CA-ON", "currency": "CAD", "archived": false } } ``` #### `party.archived` An account was archived. ```json { "id": "cbbd66c4-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "party.archived", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "party", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "name": "Synthetic Ventures Inc.", "kind": "customer", "jurisdiction": "CA-ON", "currency": "CAD", "archived": true } } ``` ### Signatures #### `signing.envelope.sent` A document was sent for signature. The subject is the envelope; subjectType and subjectId name the record it belongs to. ```json { "id": "075b4f43-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.sent", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "title": "Synthetic services agreement", "subjectType": "contractor", "subjectId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "signers": 2 } } ``` #### `signing.envelope.viewed` A signer opened the document. ```json { "id": "52af32ad-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.viewed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "recipientId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a" } } ``` #### `signing.envelope.signed` A signer finished signing. ```json { "id": "4bb0ab43-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.signed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "recipientId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "completed": false } } ``` #### `signing.envelope.completed` Everyone signed and the completed PDF is ready. ```json { "id": "60666626-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.completed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "title": "Synthetic services agreement", "subjectType": "contractor", "subjectId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "finalSha256": "9f2b5c1d7e3a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c" } } ``` #### `signing.envelope.declined` A signer declined to sign. ```json { "id": "e54db1c1-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.declined", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "recipientId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a" } } ``` #### `signing.envelope.changes_requested` A signer suggested changes before signing. Signing waits until the sender sends a revised version or keeps the document as it is. ```json { "id": "4fac8213-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.changes_requested", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "recipientId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "requestId": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d", "changes": 2, "comments": 1 } } ``` #### `signing.envelope.revised` The sender answered suggested changes with a revised version. The subject is the old envelope, which is cancelled as replaced; revisedEnvelopeId is the new one sent for signature. ```json { "id": "6c84221b-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.revised", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "title": "Synthetic services agreement", "subjectType": "contractor", "subjectId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "revisedEnvelopeId": "7b8c9d0e-1f2a-4b3c-8d4e-5f6a7b8c9d0e", "revision": 2, "accepted": 2, "rejected": 1 } } ``` #### `signing.envelope.voided` The sender voided the document. ```json { "id": "531da664-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.voided", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "title": "Synthetic services agreement", "subjectType": "contractor", "subjectId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" } } ``` #### `signing.envelope.expired` The signing window ended before everyone signed. ```json { "id": "afb478fa-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "signing.envelope.expired", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "envelope", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "title": "Synthetic services agreement", "subjectType": "contractor", "subjectId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" } } ``` ### Compliance #### `compliance.item.created` A checklist item was added. ```json { "id": "e49d1e1e-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.created", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "upcoming", "category": "sales_tax" } } ``` #### `compliance.item.assigned` A checklist item's owner changed. ```json { "id": "0de97794-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.assigned", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "upcoming", "assigneeUserId": "user_synthetic" } } ``` #### `compliance.item.due_soon` A checklist item was included in a reminder before its due date. ```json { "id": "4b4cdc02-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.due_soon", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "daysUntil": 14, "digestKey": "gst_hst_return:2026-10-31" } } ``` #### `compliance.item.overdue` A checklist item passed its due date. ```json { "id": "89b5aa20-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.overdue", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "daysUntil": -2, "digestKey": "gst_hst_return:2026-10-31" } } ``` #### `compliance.item.completed` A checklist item was marked done. ```json { "id": "45d673e3-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.completed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "done" } } ``` #### `compliance.item.skipped` A checklist item was marked not applicable. ```json { "id": "a43d5876-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.skipped", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "skipped" } } ``` #### `compliance.item.snoozed` A checklist item was snoozed. ```json { "id": "aba90388-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.snoozed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "upcoming", "snoozedUntil": "2026-10-15" } } ``` #### `compliance.item.reopened` A completed checklist item was reopened. ```json { "id": "7fc23838-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.item.reopened", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "ruleKey": "gst_hst_return", "title": "GST/HST return", "dueDate": "2026-10-31", "status": "upcoming" } } ``` #### `compliance.reminder.sent` A reminder email was queued for administrators. ```json { "id": "bc71c767-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.reminder.sent", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_digest", "id": "gst_hst_return:2026-10-31" }, "data": { "groupKey": "gst_hst_return:2026-10-31", "itemIds": [ "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f" ], "recipientCount": 2, "trigger": "schedule", "emailId": "4b5c6d7e-8f9a-4b0c-8d1e-2f3a4b5c6d7e" } } ``` #### `compliance.kickoff.started` Tax preparation started automatically. ```json { "id": "6031a16b-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.kickoff.started", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "kind": "corporate", "from": "2025-10-01", "to": "2026-09-30", "runId": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "trigger": "schedule" } } ``` #### `compliance.kickoff.completed` The tax preparation workspace is ready. ```json { "id": "e48f84f1-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "compliance.kickoff.completed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "compliance_item", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "kind": "corporate", "from": "2025-10-01", "to": "2026-09-30", "runId": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "followUps": 3, "emailQueued": true } } ``` ### Contractors #### `contractor.profile.submitted` A contractor submitted their profile. ```json { "id": "bf2c169d-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.profile.submitted", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" } } ``` #### `contractor.payment_profile.updated` A contractor's payment method changed. Account details aren't included. ```json { "id": "dbabbb50-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payment_profile.updated", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "method": "wise_email", "currency": "CAD", "source": "contractor" } } ``` #### `contractor.hours.submitted` A contractor submitted hours for approval. ```json { "id": "af7232fd-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.hours.submitted", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "periodStart": "2026-09-01", "periodEnd": "2026-09-15", "submittedMinutes": 2250 } } ``` #### `contractor.hours.approved` Submitted hours were approved. ```json { "id": "abbba00d-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.hours.approved", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "approvedEntries": 5, "status": "approved" } } ``` #### `contractor.payout.prepared` A payout was prepared from approved hours and is waiting for approval. ```json { "id": "1704c2d0-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.prepared", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "totalMinor": "450000", "currency": "CAD", "status": "pending_approval" } } ``` #### `contractor.payout.approved` An administrator approved a payout. ```json { "id": "bf097e5e-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.approved", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "totalMinor": "450000", "currency": "CAD", "status": "approved" } } ``` #### `contractor.payout.sent` A payout was sent through the payment provider. ```json { "id": "2dab54b7-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.sent", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "totalMinor": "450000", "currency": "CAD", "status": "sent", "environment": "production" } } ``` #### `contractor.payout.paid` A payout was marked as paid. ```json { "id": "2da99ddb-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.paid", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "totalMinor": "450000", "currency": "CAD", "status": "paid_manually" } } ``` #### `contractor.payout.failed` A payout could not be completed. ```json { "id": "273519c2-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.failed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "errorCode": "WISE_TRANSFER_RETURNED", "totalMinor": "450000", "currency": "CAD" } } ``` #### `contractor.payout.cancelled` A payout was cancelled. ```json { "id": "f3aa8578-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.payout.cancelled", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor_payout", "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "payoutId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "periodId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "totalMinor": "450000", "currency": "CAD", "status": "cancelled" } } ``` #### `contractor.agreement.imported` A signed contractor agreement was uploaded. ```json { "id": "d83680d7-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.agreement.imported", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "envelopeId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "executedOn": "2026-09-01" } } ``` #### `contractor.agreement.sent` A contractor agreement was prepared or sent for signature. ```json { "id": "c719384d-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "contractor.agreement.sent", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "contractor", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }, "data": { "contractorId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "envelopeId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "sent": true } } ``` ### Receipts and mail #### `receipt.processed` A receipt finished processing and is ready for review. ```json { "id": "55e6b5c2-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "receipt.processed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "submission", "id": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a" }, "data": { "submissionId": "4d5e6f7a-8b9c-4d0e-8f1a-2b3c4d5e6f7a", "entries": [ { "merchant": "Synthetic Office Supply", "date": "2026-09-18", "currency": "CAD", "amountMinor": "4520", "categoryId": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b", "splits": [] } ], "flags": [] } } ``` #### `mail.received` An email to one of your Oatmilk addresses passed screening and reached the company inbox. Sender, subject, and content aren't included. ```json { "id": "2fde0a18-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "mail.received", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "mail", "id": "6f7a8b9c-0d1e-4f2a-8b3c-4d5e6f7a8b9c" }, "data": { "messageId": "6f7a8b9c-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "purpose": "financial", "mailbox": "invoices" } } ``` ### Suggestions #### `proposal.created` A suggested change is waiting for review. ```json { "id": "9008d816-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "proposal.created", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "proposalId": "8b9c0d1e-2f3a-4b4c-8d5e-6f7a8b9c0d1e", "subjectType": "invoice", "subjectId": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10", "field": "status", "proposedValue": { "status": "paid" }, "source": "rule", "confidence": "high" } } ``` #### `proposal.decided` A person approved or declined a suggested change. ```json { "id": "be8ff2a0-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "proposal.decided", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "invoice", "id": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10" }, "data": { "proposalId": "8b9c0d1e-2f3a-4b4c-8d5e-6f7a8b9c0d1e", "subjectType": "invoice", "subjectId": "7f0f6c1e-1c1f-4b5e-9c8d-2f5e8e3c1a10", "field": "status", "decision": "approve", "status": "applied" } } ``` ### Connectors #### `notion.sync.completed` A Notion sync finished. ```json { "id": "2c2c186d-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "type": "notion.sync.completed", "created": 1790000000, "organizationId": "org_synthetic", "subject": { "type": "connector", "id": "notion" }, "data": { "runId": "9c0d1e2f-3a4b-4c5d-8e6f-7a8b9c0d1e2f", "direction": "both", "read": 4, "compared": 3, "pushed": 3, "created": 1, "unchanged": 0, "proposals": 1, "suggestedLinks": 0, "errors": 0, "queued": 0 } } ``` ## The test event `webhook.test` is sent when you press **Send a test event** in Developers › Webhooks or call `webhooks.test` without an event type. It reaches every endpoint whatever its subscriptions, and has `"test": true` at the top level so you can tell it apart. A test of a catalog event has the same shape as the real one, with `"test": true` in its envelope and its `data`. # Test your endpoint > Send test events, replay deliveries and develop on your own computer. Source: https://app.getoatmilk.com/docs/webhooks/testing You don't have to wait for an invoice to be paid to know your endpoint works. ## Send a test event In **Developers › Webhooks**, open an endpoint and choose **Send a test event**. Or ask the API, optionally with a catalog event to get a realistic sample: ```bash curl https://app.getoatmilk.com/api/v1/accounting/webhooks.test \ -H "Authorization: Bearer $OATMILK_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "endpointId": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", "eventType": "invoice.paid" }' ``` Test deliveries are signed like real ones and appear in the delivery log, but they never count towards turning an endpoint off. ## Watch the delivery log Every delivery, with its payload, your server's status code, how long it took and a short excerpt of its answer, is in the delivery log in Developers › Webhooks and in `webhooks.deliveries.list`. Delivery rates and response times over time are in **Developers › Usage** and `webhooks.deliveries.stats`. Retry a failed delivery from there, or with `webhooks.deliveries.retry`, once your server is fixed. ```bash curl -G https://app.getoatmilk.com/api/v1/accounting/webhooks.deliveries.list \ -H "Authorization: Bearer $OATMILK_API_KEY" \ --data-urlencode 'status=failed' \ --data-urlencode 'limit=20' ``` ## Develop on your own computer While you develop, an endpoint can point at `http://localhost`. To receive real deliveries from staging, expose your local server with a tunnel such as `ngrok` or `cloudflared` and add its `https://` address as an endpoint. To test your signature check without Oatmilk at all, sign a sample with your own secret in the [signature playground](https://app.getoatmilk.com/docs/webhooks/signatures.md#try-it-here) and send it with the `curl` command it gives you. The timestamp is current, so your tolerance check passes. ## A checklist before going live - The endpoint answers `2xx` within a few seconds, before slow work. - It verifies `Oatmilk-Signature` against the raw body and refuses anything older than 300 seconds. - It stores each event `id` and skips duplicates. - It reads the record from the API when the order of events matters. - It answers `200` to event types it doesn't recognise. - The signing secret is stored like a password, and rotating it is a documented step.