Tutorials
Sync invoices to your CRM
Keep deal stages in step with invoices using signed webhooks.
Sales teams live in a CRM; finance lives in Oatmilk. In this tutorial a small server listens for invoice events and updates the matching deal in your CRM: sent, partly paid, paid or overdue. It uses webhooks, so nothing polls.
1. Create an endpoint
Subscribe to every invoice event with the group wildcard. Store the secret from the response as OATMILK_WEBHOOK_SECRET.
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://crm-sync.example.com/webhooks/oatmilk",
"description": "CRM sync",
"events": [
"invoice.*"
]
}'2. Receive and verify events
Verify the signature against the raw body, skip events you've already handled, and answer quickly.
import express from "express";
import { verifyOatmilkSignature } from "./verify.js";
const app = express();
const handled = new Set();
app.post("/webhooks/oatmilk", express.raw({ type: "application/json" }), async (request, response) => {
const rawBody = request.body.toString("utf8");
if (!verifyOatmilkSignature(rawBody, request.get("Oatmilk-Signature") ?? "", process.env.OATMILK_WEBHOOK_SECRET)) return response.sendStatus(400);
const event = JSON.parse(rawBody);
response.sendStatus(200);
if (handled.has(event.id)) return;
handled.add(event.id);
await syncInvoice(event);
});
app.listen(3000);verify.js is the function from Verify signatures. In production, keep handled event IDs in your database rather than in memory.
3. Update the deal
Every invoice event carries the invoice's status and amounts, and subject.id is the invoice's ID:
POST /webhooks/oatmilk HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Oatmilk-Webhooks/1.0
Oatmilk-Signature: t=1790000000,v1=9e08e2ad5bbe907776be46d622ff5db78f3dea183b666121d3f8bfae09aa5991
Oatmilk-Event-Id: 89abb6ea-0d1e-4f2a-8b3c-4d5e6f7a8b9c
Oatmilk-Event-Type: invoice.partially_paid
Oatmilk-Delivery-Id: 3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f
Oatmilk-Delivery-Attempt: 1
{
"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
}
}Read the invoice itself before you update the deal. display_status is the status people see in Oatmilk, including overdue:
const STAGES = { sent: "Invoiced", partially_paid: "Partly paid", paid: "Won – paid", overdue: "Overdue", void: "Lost" };
export async function syncInvoice(event) {
if (!event.type.startsWith("invoice.") || !event.subject) return;
const { invoice } = await callOatmilk("invoices.get", { id: event.subject.id });
const stage = STAGES[invoice.display_status];
if (!stage) return;
await crm.deals.update({ externalId: invoice.number, stage, amountPaid: Number(invoice.amount_paid_minor) / 100 });
}callOatmilk is the helper from Retrying safely, and crm stands for your CRM's client. Reading the invoice with invoices.get instead of trusting the event's order means a late retry can never move a paid deal back to "Invoiced".
4. Test it
Send a sample invoice.paid to your endpoint and check the deal moves:
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"
}'Going further
- Subscribe to
party.*as well to create CRM companies when customers are added. - Use
proposal.createdto tell sales when Oatmilk suggests an invoice is paid but needs a person to approve it.