# Sync invoices to your CRM

> Keep deal stages in step with invoices using signed webhooks.

Source: https://app.getoatmilk.com/docs/tutorials/sync-invoices-to-a-crm

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`.

```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://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.

```js title="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](https://app.getoatmilk.com/docs/webhooks/signatures.md). 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:

```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=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`:

```js title="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](https://app.getoatmilk.com/docs/errors.md#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:

```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"
}'
```

## 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.
