Tutorials

Sync invoices to your CRM

Keep deal stages in step with invoices using signed webhooks.

20 minutes · Intermediate

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.

server.js
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:

invoice.partially_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=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:

sync.js
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.created to tell sales when Oatmilk suggests an invoice is paid but needs a person to approve it.