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-Signature header?
  • How do I handle DATA_MODEL_CHANGES, SYNC_COMPLETED, or CONNECTION_CHANGED events?
  • 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_WEBHOOK_TOLERANCE_SECONDS 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.

EventFires when
DATA_MODEL_CHANGESSynced records are created, updated, or deleted (data.data_model, data.type of CREATE / UPDATE / DELETE, up to 50 records per delivery)
SYNC_STARTEDA sync begins
SYNC_COMPLETEDA sync finishes (data.status: SUCCESS, FAILED, RUNNING, CREATED)
CONNECTION_CHANGEDA company's connection status changes (HEALTHY, EXPIRED, DISCONNECTED)
DISCONNECT_REQUESTEDAn 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

HeaderDescription
X-RootFi-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>; sent only when the config has a signing key
X-RootFi-Webhook-Delivery-IdUnique id for the delivery, stable across retries: use it to deduplicate
X-RootFi-TimestampUnix 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 13, 2026

View on GitHub →