Agent skill
RootFi (Commenda) Webhooks Skill
Receive and verify RootFi (Commenda) webhooks. Use when setting up RootFi unified accounting integration webhook handlers, debugging X-RootFi-Signature verification, or handling events such as DATA_MODEL_CHANGES, SYNC_STARTED, SYNC_COMPLETED, CONNECTION_CHANGED, and DISCONNECT_REQUESTED.
Install this skill
npx skills add hookdeck/webhook-skills --skill rootfi-webhooks
When to Use This Skill
- How do I receive RootFi webhooks?
- How do I verify the
X-RootFi-Signatureheader? - How do I handle
DATA_MODEL_CHANGES,SYNC_COMPLETED, orCONNECTION_CHANGEDevents? - Why are my RootFi deliveries unsigned, or why does signature verification fail?
- How do I deduplicate RootFi deliveries and handle out-of-order events?
RootFi's unified accounting, commerce and payments integrations API is now documented as Commenda integrations (integrations.docs.commenda.io).
Essential Code (USE THIS)
When a webhook config has a signing_key, every delivery carries X-RootFi-Signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 (hex) of "<t>.<raw body>", keyed with the signing_key as UTF-8 bytes, used as-is (not hex- or base64-decoded). Use t exactly as it appears in the header, sign the raw body bytes (never re-serialized JSON), and accept the delivery if any v1 matches. Failures return 401.
Verification (Node.js)
const crypto = require('crypto');
// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyRootfi(rawBody, header, secret) {
if (!secret || !header) return false;
let t; const v1 = [];
for (const part of header.split(',')) { // trim: a space after the comma is allowed
const p = part.trim(), i = p.indexOf('=');
if (i > 0) { const k = p.slice(0, i).trim(), v = p.slice(i + 1).trim();
if (k === 't') t = v; else if (k === 'v1') v1.push(v); } // ignore v0 and other keys
}
if (!t || v1.length === 0) return false;
// Optional replay window, off by default: retries and replays keep the original timestamp
const expected = crypto.createHmac('sha256', secret) // signing_key as UTF-8
.update(`${t}.`).update(rawBody).digest(); // t stays a string; body stays bytes
// 64 hex chars first: Buffer.from(x, 'hex') silently truncates invalid hex
return v1.some((s) => /^[0-9a-f]{64}$/i.test(s) &&
crypto.timingSafeEqual(Buffer.from(s, 'hex'), expected));
}
Verification (Python)
import hashlib, hmac, re
def verify_rootfi(raw_body: bytes, header: str, secret: str) -> bool:
if not secret or not header:
return False
t, v1 = None, []
for part in header.split(","):
k, sep, v = part.strip().partition("=")
if sep and k.strip() == "t": t = v.strip()
elif sep and k.strip() == "v1": v1.append(v.strip())
if not t or not v1:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest().encode()
# exact hex format first, then compare_digest on bytes (never str)
return any(re.fullmatch(r"[0-9a-fA-F]{64}", s, re.ASCII) and
hmac.compare_digest(s.lower().encode(), expected) for s in v1)
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Timestamp window: off by default
RootFi's docs suggest rejecting timestamps more than about 300 seconds old. Hookdeck retries and replays resend the original headers and timestamp, and Hookdeck itself applies no tolerance, so a fixed window would reject legitimate retries. The examples therefore skip the freshness check unless you set ROOTFI_ to a positive number of seconds (t is Unix seconds; unset or 0 = off). Dedupe on X-RootFi-Webhook-Delivery-Id instead. references/verification.md shows where the window goes.
Setup with Hookdeck
Set a signing_key on the RootFi webhook config. Without one, deliveries are unsigned and fail verification.
RootFi does not generate the key: choose a long random string, set it as signing_key (dashboard or POST /v4/core/webhook-config), and paste the same string into the Hookdeck source. Use the Hookdeck source URL as the config's url. See references/setup.md.
Event Envelope and Types
Every body is { "event": "<TYPE>", "data": { ... } }, with no extra wrapper. Branch on event; no header names the type. Each webhook config subscribes to exactly one event type.
| Event | Fires when |
|---|---|
DATA_MODEL_CHANGES | Synced records are created, updated, or deleted (data.data_model, data.type of CREATE / UPDATE / DELETE, up to 50 records per delivery) |
SYNC_STARTED | A sync begins |
SYNC_COMPLETED | A sync finishes (data.status: SUCCESS, FAILED, RUNNING, CREATED) |
CONNECTION_CHANGED | A company's connection status changes (HEALTHY, EXPIRED, DISCONNECTED) |
DISCONNECT_REQUESTED | An end user requests a disconnect from the SDK (nothing is disconnected until a dashboard user resolves it) |
Delivery is at least once and unordered: a SYNC_COMPLETED can arrive before the last DATA_MODEL_CHANGES batch of that sync. See references/overview.md for payloads and retries.
Important Headers
| Header | Description |
|---|---|
X-RootFi-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>; sent only when the config has a signing key |
X-RootFi-Webhook-Delivery-Id | Unique id for the delivery, stable across retries: use it to deduplicate |
X-RootFi-Timestamp | Unix timestamp (seconds) when the delivery was attempted |
Environment Variables
ROOTFI_WEBHOOK_SECRET=your_signing_key # the config's signing_key, used as-is as a UTF-8 HMAC key
ROOTFI_WEBHOOK_TOLERANCE_SECONDS=0 # optional; a positive value opts in to the replay window
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 rootfi --path /webhooks/rootfi
Reference Materials
- references/overview.md - RootFi webhook concepts, events, payloads, retries
- references/setup.md - Creating a webhook config, the signing key, Hookdeck setup
- references/verification.md - Signature details, the timestamp window, and debugging