# Guide to RootFi (Commenda) Webhooks: Features and Best Practices

RootFi, now documented as Commenda integrations, is a unified API over accounting, commerce and payments platforms: connect a customer's Xero, QuickBooks or similar once, and RootFi syncs invoices, contacts and other records into one data model. Fintech and lending products use it to read their customers' books. Since syncs run in the background, RootFi's webhooks are how your product learns that data changed, a sync finished or a connection broke.

This guide covers RootFi's five event types, how record changes are batched, the optional `X-RootFi-Signature`, and the ordering and retry behaviour you need to design for.

## What are RootFi (Commenda) webhooks?

A RootFi webhook config subscribes to exactly one event type and sends it to one URL. You create configs on the dashboard's **Webhooks** page or with `POST /v4/core/webhook-config`, so a full integration usually has several. For `DATA_MODEL_CHANGES`, a config can narrow delivery to particular topics, such as `invoice.create`.

Each delivery is a JSON `POST` whose body is simply `{event, data}`. Headers carry a delivery ID that stays the same across retries, the attempt's timestamp and, if the config has a signing key, `X-RootFi-Signature`.

The signature uses the Stripe-style `t=...,v1=...` layout: a Unix timestamp and a hex HMAC-SHA256 of the timestamp and raw body. RootFi doesn't generate the key; you set `signing_key` on the config, and without it nothing is signed.

## RootFi (Commenda) webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard **Webhooks** page, or `POST /v4/core/webhook-config` |
| Scope | One event type per config; topics for `DATA_MODEL_CHANGES` |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-rootfi-signature` (`t=<unix>,v1=<hex>`) |
| Signed content | `<t>.<raw body>` |
| Key | `signing_key` you set on the config. No header without one |
| Body | JSON `{event, data}` |
| Event routing | Body field `event` |
| Delivery ID | `X-RootFi-Webhook-Delivery-Id`, stable across retries |
| Timeout | 30 seconds |
| Retries | 5 attempts in total, backoff capped at 15 minutes |
| Ordering | Not guaranteed |

## Events and payloads

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `DATA_MODEL_CHANGES` | Synced records are created, updated, or deleted | Mirror RootFi data (invoices, contacts) into your own store |
| `SYNC_STARTED` | A sync begins | Show sync progress, track `sync_id` |
| `SYNC_COMPLETED` | A sync finishes | Check which data models succeeded or failed |
| `CONNECTION_CHANGED` | A company's connection is established, expires, or is disconnected | Prompt customers to reconnect |
| `DISCONNECT_REQUESTED` | An end user requests a disconnect from the SDK | Route the request to a human; it does not disconnect anything |

### DATA_MODEL_CHANGES

`data.data_model` names the model (for example `INVOICE`) and `data.type` says whether records were created, updated or deleted. Records are batched, at most 50 per delivery and deduplicated by `platform_id` within a batch, so `records` is one page rather than every change in the sync.

The same record can arrive more than once, for example as a `CREATE` and then an `UPDATE` with no meaningful change. `UPDATE` is sent only when something changed, and deletions are detected only during a full sync. RootFi's example elides the record fields, so check a real delivery for each data model.

### SYNC_COMPLETED

Marks the end of a sync and reports how each data model fared, so you can see which succeeded or failed. Because delivery is unordered, it can arrive before the last `DATA_MODEL_CHANGES` batch for the same `sync_id`.

### DISCONNECT_REQUESTED

`data` holds `disconnect_request_id`, `connection_id`, `company_id` and `integration_type`. The event only records a request from an end user in the SDK: the connection stays active and keeps syncing until an authenticated dashboard user resolves it.

### Published payload example

A `CONNECTION_CHANGED` delivery, from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/rootfi/latest/CONNECTION_CHANGED.json).

```json
{
  "event": "CONNECTION_CHANGED",
  "data": {
    "connection_id": "1234_XERO",
    "company_id": 1234,
    "integration_type": "XERO",
    "status": "HEALTHY"
  }
}

```

## Setting up RootFi (Commenda) webhooks

### Prerequisites

* A RootFi (Commenda integrations) account with access to the **Webhooks** page, or API access to `POST /v4/core/webhook-config`
* A public HTTPS endpoint

### Get Your Signing Secret

Set a signing_key on the RootFi webhook config. Without one, deliveries are unsigned and fail verification.

Choose a long random string, for example with `openssl rand -hex 32`, and store the same value as `WEBHOOK_SECRET`. RootFi uses it as UTF-8 bytes, so don't decode it.

### Register Your Endpoint

**Dashboard**

1. Go to **Webhooks** and click **Create Webhook**.
2. Name it and choose the event type.
3. For **Data Model Changes**, select topics.
4. Enter your endpoint URL, any custom headers and the signing key.

**API**

```json
POST /v4/core/webhook-config
{
  "name": "Invoice changes",
  "webhook_type": "DATA_MODEL_CHANGES",
  "url": "https://api.example.com/hooks/rootfi",
  "topics": ["invoice.create", "invoice.update"],
  "headers": [{ "name": "X-Tenant", "value": "acme" }],
  "signing_key": "<random secret>"
}

```

Create one config per event type: `DATA_MODEL_CHANGES`, `SYNC_STARTED`, `SYNC_COMPLETED`, `CONNECTION_CHANGED` and `DISCONNECT_REQUESTED`. Valid topics come from `GET /v4/core/webhook-config/platform-topics`. The dashboard requires at least one topic; through the API, an empty list matches everything. The Webhooks page also lets you enable, disable or edit configs and shows each delivery's attempts and response codes.

### Test a delivery

Start a local receiver and forward requests with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 rootfi --path /webhooks/rootfi

```

Create a `SYNC_STARTED` and a `SYNC_COMPLETED` config pointing at the CLI's URL, then trigger a sync on a test connection.

## Securing RootFi (Commenda) webhooks

Split `X-RootFi-Signature` into `t` and `v1`, build the message from the `t` text, a dot and the raw body, compute HMAC-SHA256 with the `signing_key`, and compare it with `v1` in constant time. RootFi warns that re-serialised JSON won't match. See the [webhooks concept page](https://integrations.docs.commenda.io/documentation/concepts/webhooks).

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

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-rootfi-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const parts = signature.split(',').map((part) => part.trim());
  const times = parts.filter((part) => part.startsWith('t=')).map((part) => part.slice(2));
  if (times.length !== 1 || !/^[0-9]+$/.test(times[0])) return false;
  const message = Buffer.concat([Buffer.from(`${times[0]}.`, 'utf8'), rawBody]);
  const candidates = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler deduplicates on the delivery ID, upserts records by `platform_id`, and treats `SYNC_COMPLETED` as a signal to reconcile rather than as proof that every batch has arrived.

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

function createRootFiApp({ claimDelivery, records, syncs, connections }) {
  const app = express();
  app.post('/webhooks/rootfi', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    const deliveryId = req.headers['x-rootfi-webhook-delivery-id'];
    let parsed;
    try {
      parsed = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
    const { event, data } = parsed;
    if (typeof event !== 'string' || !data || typeof data !== 'object') return res.sendStatus(400);
    try {
      if (!(await claimDelivery(deliveryId))) return res.sendStatus(200);
      switch (event) {
        case 'DATA_MODEL_CHANGES':
          for (const record of Array.isArray(data.records) ? data.records : []) {
            await records.upsertOrDelete(data.data_model, data.type, record);
          }
          break;
        case 'SYNC_STARTED':
        case 'SYNC_COMPLETED':
          await syncs.record(event, data);
          break;
        case 'CONNECTION_CHANGED':
        case 'DISCONNECT_REQUESTED':
          await connections.handle(event, data);
          break;
        default:
          console.log('Unhandled RootFi event', event);
      }
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

A batch of 50 upserts can be slow; if it risks the 30-second timeout, store the body and process it from a queue.

RootFi's docs describe a 300-second replay window for `t`. The verifier above doesn't apply it, so that retries and replays through Hookdeck still verify. If you receive directly from RootFi, you can add the age check, and the delivery ID remains your guard against duplicates either way.

On a RootFi source, Hookdeck parses `t` and `v1` and checks the HMAC of `{t}.{body}` with your signing key. RootFi always receives `200`; a failed check is recorded as failed verification and the request isn't forwarded. Hookdeck doesn't enforce the 300-second window. If a config has no signing key by design, leave verification off on the source.

> **Make RootFi (Commenda) webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) collects all your one-event RootFi configs on one source, verifies them, and keeps record batches replayable after RootFi's five attempts.

## RootFi (Commenda) webhook limitations and pain points

### Out-of-order sync events

**The Problem:** Your app marks a sync complete while record batches are still arriving.

**Why It Happens:** Delivery is at-least-once with no ordering, and RootFi notes that `SYNC_COMPLETED` can arrive before the last `DATA_MODEL_CHANGES` batch.

**Workarounds:**

* Track batches per `sync_id` and reconcile through the API after `SYNC_COMPLETED`.
* Make every record write an upsert keyed on `platform_id`.

**How Hookdeck Can Help:** Hookdeck's event log shows the arrival order of every event for a sync, which helps when reconciling.

### A short retry window

**The Problem:** Record changes are lost after a brief outage.

**Why It Happens:** RootFi makes 5 attempts in total, with backoff that reaches about 15 minutes, then marks the delivery failed.

**Workarounds:**

* Check `GET /v4/core/webhook-logs` after incidents.
* Run a full sync to catch up when deliveries have failed.

**How Hookdeck Can Help:** Hookdeck accepts the first attempt and holds the event, retrying your service on your schedule.

### Signing is optional

**The Problem:** Unsigned configs accept forged data changes.

**Why It Happens:** `X-RootFi-Signature` is sent only when the config has a `signing_key`.

**Workarounds:**

* Set a signing key on every config, including ones created through the API.

**How Hookdeck Can Help:** With verification on, unsigned requests are recorded as failed and not forwarded.

### One event per config

**The Problem:** A full integration needs five configs, each with its own settings to keep in sync.

**Why It Happens:** Each webhook config subscribes to exactly one event type.

**Workarounds:**

* Manage configs through the API from code, with the same URL and key on each.

**How Hookdeck Can Help:** Point every config at one Hookdeck source, and use connection filters on `event` to route each type.

## Best practices

### Deduplicate on the delivery ID

`X-RootFi-Webhook-Delivery-Id` stays the same across retries, and the body has no event ID. Store it with the change, following our [idempotency guide](/webhooks/guides/implement-webhook-idempotency).

### Don't act on `DISCONNECT_REQUESTED` automatically

The event records a request, not a disconnection. Route it to a person who can review and resolve it in the dashboard.

### Use full syncs for deletions

`DELETE` changes are only detected during full syncs, so schedule them if deletions matter to you.

### Acknowledge within 30 seconds

Store the body and return `2xx`, then process batches in the background, as the [asynchronous processing guide](/webhooks/guides/why-implement-asynchronous-processing-webhooks) explains.

## Conclusion

RootFi's webhooks report the background work of a unified accounting API: record changes in batches, sync boundaries and connection status. A good receiver upserts by `platform_id`, deduplicates on the delivery ID, doesn't trust `SYNC_COMPLETED` to arrive last, and sets a signing key on every config.

[Hookdeck Event Gateway](https://hookdeck.com) brings RootFi's single-event configs together on one verified source and keeps their deliveries replayable.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle RootFi (Commenda) webhooks reliably in minutes.