# Exact Online Webhooks

## When to Use This Skill

* How do I receive Exact Online webhooks?
* How do I verify the Exact Online `HashCode` signature?
* Why is my Exact Online webhook signature verification failing?
* How do I subscribe to a topic with the `WebhookSubscriptions` endpoint?
* How do I handle `Accounts`, `Items`, `StockPositions`, `FinancialTransactions`, `GoodsDeliveries`, or `Contacts` events?
* Why does my Exact Online webhook payload only contain a `Key` (GUID) and not the full record?

## How Exact Online Webhooks Work (Read This First)

Exact Online does not use the [Standard Webhooks](https://www.standardwebhooks.com/)
spec, and the signature is not an HTTP header. Instead, the POST body is:

```json
{
  "Content": {
    "Topic": "Accounts",
    "Action": "Update",
    "Key": "d4d4c8b6-1a2b-4c3d-9e8f-1234567890ab",
    "Division": 123456,
    "ClientId": "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
  },
  "HashCode": "5A3F9C2E7B1D8A46F0C3E9B2D7A15C8E4F6091A2B3C4D5E6F7089ABCDEF01234"
}

```

Two consequences drive everything below:

1. The signature is the `HashCode` body field. You verify by re-computing an
  HMAC-SHA256 over the raw JSON of the `Content` node and comparing to `HashCode`.
2. The payload is thin. `Content` carries only `Topic`, `Action`, `Key`
  (the entity GUID), `Division`, and `ClientId`. To act on the change you
  fetch the full record from the REST API using the `Key` and `Division`.

```
Exact Online ──POST {"Content":{…},"HashCode":"…"}──▶ your endpoint
                                                        │  verify HashCode
                                                        ▼
                              GET /api/v1/{Division}/{entity}?$filter=ID eq guid'{Key}'
                                                        │  (OAuth2 bearer)
                                                        ▼
                                          read full record → act → return 200

```

## Verification (core)

Compute HMAC-SHA256 over the exact raw JSON substring of the `Content` node
(the characters between `{"Content":` and `,"HashCode":` in the raw body — braces
included). Key it with your app's Webhook secret (from the Exact App Center),
hex-encode, uppercase, and compare to `HashCode`. Do not re-serialize the
parsed `Content` object — key order/whitespace would differ and break the hash.

> Verified against a real delivery (July 2026). An `Accounts/Update` webhook
> was reproduced exactly: HMAC-SHA256 over the raw substring between `{"Content":`
> and `,"HashCode":`, hex-encoded and uppercased, matched the delivered `HashCode`.
> Lowercase hex and base64 both failed, so the uppercasing is required.
> 
> Exact's KB pages are JS-rendered and never state the signed substring in prose —
> these boundaries originally came from community implementations (picqer's PHP
> client) and are now confirmed by evidence.
> 
> One caveat: Exact sends compact JSON, so for that delivery the raw substring
> and a re-serialized compact `Content` were byte-identical and both matched. The
> test therefore cannot distinguish them. Keep using the raw substring — it is the
> only form that stays correct if Exact ever emits whitespace or reorders keys.
> See [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/exact-online-webhooks/references/verification.md) for the failure modes.

```javascript
const crypto = require('crypto');

function verifyExactWebhook(rawBody, secret) {
  const raw = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody;
  const prefix = '{"Content":';
  const marker = ',"HashCode":';
  const start = raw.indexOf(prefix);
  const end = raw.lastIndexOf(marker);              // HashCode is last => lastIndexOf
  if (start === -1 || end === -1 || end < start) return false;

  const contentJson = raw.slice(start + prefix.length, end); // exact bytes Exact signed
  let hashCode;
  try { hashCode = JSON.parse(raw).HashCode; } catch { return false; }
  if (!hashCode) return false;

  const expected = crypto.createHmac('sha256', secret)
    .update(contentJson, 'utf8').digest('hex').toUpperCase();
  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected), Buffer.from(String(hashCode).toUpperCase()));
  } catch { return false; }
}

```

There is no official Exact Online SDK, so verification is manual in every
language. Always verify against the raw body — parse JSON only after the
`HashCode` checks out.

> For complete handlers with route wiring, topic dispatch, and tests, see:
> 
> * [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/exact-online-webhooks/examples/express/)
> * [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/exact-online-webhooks/examples/nextjs/)
> * [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/exact-online-webhooks/examples/fastapi/)

## Common Topics

Subscribe to one topic per subscription, per division. `Action` is one of
`Create`, `Update`, or `Delete`.

| Topic | Fires When | Common Use Cases |
| --- | --- | --- |
| `Accounts` | A customer/supplier account is created, updated, or deleted | Sync CRM, dedupe contacts |
| `Items` | A product/item changes | Sync catalog, pricing |
| `StockPositions` | An item's stock position changes | Inventory sync, reorder alerts |
| `FinancialTransactions` | A financial transaction is booked/changed | Reconciliation, reporting |
| `GoodsDeliveries` | A goods delivery is created/updated | Fulfilment, shipping (supports near-instant delivery via `IsInstant`) |
| `Contacts` | A contact person changes | CRM sync |

Exact documents ~30 topics. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/exact-online-webhooks/references/overview.md)
for the full list and payload details.

## Environment Variables

```bash
EXACT_WEBHOOK_SECRET=your_app_webhook_secret   # from the Exact App Center (OAuth app registration)

```

The Webhook secret is set on your OAuth app in the Exact App Center — it is
not the OAuth client secret. Fetching the full record additionally needs an
OAuth2 access token; see [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/exact-online-webhooks/references/setup.md).

## Local Development

```bash
# Start tunnel (no account needed) — forwards to your local handler
npx hookdeck-cli listen 3000 exact-online --path /webhooks/exact-online

```

Register the resulting public URL as the `CallbackURL` when you create a
subscription (`POST /api/v1/{division}/webhooks/WebhookSubscriptions`).

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/exact-online-webhooks/references/overview.md) - Topics, payload structure, the fetch-to-enrich pattern
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/exact-online-webhooks/references/setup.md) - App Center secret, OAuth, subscribing to topics
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/exact-online-webhooks/references/verification.md) - HashCode HMAC-SHA256 verification in depth and gotchas