Gareth Wilson Gareth Wilson

Guide to Rutter Webhooks: Features and Best Practices

Published


Rutter webhooks tell your application what changed in the accounting and commerce platforms your customers have connected: a connection finished its first sync or needs re-authenticating, an invoice or bill was created or updated, an order changed, or a record was deleted. If you build on Rutter's unified API, webhooks are how you keep your copy of each customer's data current without re-reading every connection on a schedule.

This guide covers how Rutter webhooks are configured and signed, how to keep verification working through a client secret rotation, the events and payload shape, Rutter's delivery guarantees (and the lack of them), and the best practices for production.

What are Rutter webhooks?

Rutter connects to accounting and commerce platforms such as QuickBooks, Xero, Stripe, Sage Intacct and Amazon, and exposes their data through one API. Each customer's link to a platform is a Connection. Rutter's webhooks report on those Connections and on changes to the data behind them.

Rutter generates webhooks itself, so they exist even for platforms that have no webhooks of their own. Each time an Incremental Sync runs, Rutter works out what changed and fires the matching events. For QuickBooks Online, Xero, Stripe and Sage Intacct you can also turn on real-time webhooks, which use the platform's own notifications so that changes arrive without waiting for the next sync.

There is one webhook URL per Rutter organisation, and every event for every Connection goes to it. Each delivery is signed with your organisation's client secret, the same credential you use for the API.

Rutter webhook features

FeatureDetails
ConfigurationOne URL per organisation, on the dashboard's Platforms page
Real-time webhooksQuickBooks Online, Xero, Stripe and Sage Intacct, set up on the platform's Webhooks tab
Signature headersX-Rutter-Signature and X-Rutter-Signatures
SchemeHMAC-SHA256, base64 digest, prefixed with sha256=
Signed contentThe webhook payload
SecretYour organisation's client secret
Secret rotationDuring the grace period, X-Rutter-Signatures carries signatures for both secrets
Event identifiercode in the body, with the entity family in type
Response windowA 2xx within 5 seconds
RetriesExponential backoff with jitter, for up to 15 minutes from creation
Delivery guaranteeAt least once, and a small share of webhooks may be dropped
Event IDNone

Rutter does not publish source IP ranges or send a timestamp header, so the signature is the only check you can make on a delivery.

Common events

Every payload has a type, which names the entity family, and a code, which names the event. Route on code.

typeCodesFires when
CONNECTIONINITIAL_UPDATEThe first data download for a Connection has finished and you can start fetching data
CONNECTIONHISTORICAL_UPDATEA batch of historical data has synced (only with data batching enabled)
CONNECTIONINCREMENTAL_SYNC_COMPLETEDA manual incremental sync triggered from the dashboard has finished
CONNECTIONCONNECTION_UPDATEDAn end user re-authenticated an existing Connection
CONNECTIONCONNECTION_NEEDS_UPDATEThe merchant uninstalled or unauthorised the Connection, or authentication failed
CONNECTIONCONNECTION_DISABLED, CONNECTION_ERROR, CONNECTION_LINK_ERRORRutter disabled the Connection, hit a sync or connection error, or authentication failed during linking
AccountingACCOUNT_*, BILL_*, BILL_PAYMENT_*, CUSTOMER_*, INVOICE_*, INVOICE_CREDIT_MEMO_*, JOURNAL_ENTRY_*, VENDOR_*Records were created or updated
CommerceORDER_*, PRODUCT_*, PRODUCT_READY, PRODUCT_FINISH_FAILED, STORE_UPDATEDOrders, products and store settings changed, or an asynchronous product job finished
Deletes*_DELETED, for example BILL_DELETED and ORDER_DELETEDRutter detected a deletion on the underlying platform, for supported resources and platforms

Rutter's public list is not complete. It also mentions JOB_COMPLETED and EXPENSE_CREATED in passing, and says the full list is in the webhook section of the dashboard. Treat unknown codes as normal.

A connection event is small:

{
  "type": "CONNECTION",
  "code": "CONNECTION_NEEDS_UPDATE",
  "connection_id": "3adc1519-0d6e-4222-83de-25b457cf42be",
  "access_token": "313c3e00-2bbc-4b2e-a9dc-13abed546b65",
  "update_url": "https://link.rutterapi.com/connection/3adc1519-0d6e-4222-83de-25b457cf42be"
}

update_url is a link you can send to the merchant so they can re-authenticate. Other connection codes add their own fields, such as disable_error on CONNECTION_DISABLED and message on CONNECTION_ERROR.

Entity events carry the entity under a key named after it, such as bill, order or vendor. When the change came from one of your own API writes, they also carry job_id:

{
  "type": "VENDOR",
  "code": "VENDOR_CREATED",
  "access_token": "313c3e00-2bbc-4b2e-a9dc-13abed546b65",
  "connection_id": "222c3e00-2bbc-4b2e-a9dc-13abed546b65",
  "job_id": "3446b185-117f-46bc-939b-aa53de5c35c1",
  "vendor": {}
}

Rutter documents the vendor object in its API reference and leaves it out of the webhook example, as above.

Setting up Rutter webhooks

  1. Start your receiver and make sure it is reachable on a public HTTPS URL. For local development, the Hookdeck CLI gives you one: hookdeck listen 3000 rutter --path /webhooks/rutter.
  2. In the Rutter dashboard, go to the Platforms page and paste your URL into the webhook setting.
  3. Store your organisation's client secret server-side, for example as RUTTER_CLIENT_SECRET.
  4. To get real-time events for QuickBooks, Xero, Stripe or Sage Intacct, open that platform on the Platforms tab, select its Webhooks tab, and follow the instructions there.
  5. Run through the connection flow with a test account and check that INITIAL_UPDATE arrives.

Rutter documents no URL validation or handshake request.

Securing Rutter webhooks

Each delivery has two signature headers:

  • X-Rutter-Signature: sha256= followed by the base64 HMAC-SHA256 of the payload, keyed with your current client secret.
  • X-Rutter-Signatures: every currently active signature, separated by , . Normally it has one entry, equal to X-Rutter-Signature. During a client secret rotation it has two: the new secret's signature first, then the expiring one.

Rutter recommends verifying against X-Rutter-Signatures: compute the signature with each secret you hold, and accept the webhook if any of them matches any entry. That keeps your endpoint working throughout a rotation.

const crypto = require('crypto');
const express = require('express');

function safeEqual(a, b) {
  const left = Buffer.from(a, 'utf8');
  const right = Buffer.from(b, 'utf8');
  return left.length === right.length && crypto.timingSafeEqual(left, right);
}

function verifyRutterSignature(rawBody, signaturesHeader, secrets) {
  if (!signaturesHeader) return false;
  const received = signaturesHeader.split(',').map((s) => s.trim()).filter(Boolean);

  return secrets.some((secret) => {
    const expected =
      'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
    return received.some((sig) => safeEqual(sig, expected));
  });
}

const app = express();

app.post('/webhooks/rutter', express.raw({ type: '*/*' }), (req, res) => {
  // Hold the old and new client secrets during a rotation grace period.
  const secrets = [process.env.RUTTER_CLIENT_SECRET, process.env.RUTTER_CLIENT_SECRET_OLD].filter(Boolean);
  if (secrets.length === 0) return res.sendStatus(500); // fail closed

  const header = req.get('x-rutter-signatures') || req.get('x-rutter-signature');
  if (!verifyRutterSignature(req.body, header, secrets)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.code. Persist the event, then process it asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import base64
import hashlib
import hmac


def verify_rutter_signature(raw_body: bytes, signatures_header, secrets) -> bool:
    if not signatures_header:
        return False
    received = [s.strip() for s in signatures_header.split(",") if s.strip()]
    for secret in secrets:
        digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
        expected = "sha256=" + base64.b64encode(digest).decode("ascii")
        if any(hmac.compare_digest(sig.encode("utf-8"), expected.encode("utf-8")) for sig in received):
            return True
    return False


# In FastAPI: raw_body = await request.body()
# verify_rutter_signature(raw_body, request.headers.get("x-rutter-signatures"),
#                         [os.environ["RUTTER_CLIENT_SECRET"]])

Rutter describes the signed content as "the webhook payload" and doesn't mention re-serialisation, so verify against the raw bytes you received, before any JSON parsing.

Make Rutter webhooks production-ready. Hookdeck Event Gateway verifies X-Rutter-Signature, acknowledges every delivery inside Rutter's 5 second window, and durably queues connection and entity events so Rutter's 15 minute retry limit stops being yours.

Rutter webhook limitations and pain points

A 5 second window and a 15 minute retry budget

The Problem: A slow handler or a short outage turns into permanently failed webhooks.

Why It Happens: Rutter waits 5 seconds for a 2xx. Any non-2xx response or timeout is retried with exponential backoff and up to 10 seconds of jitter: roughly 15 seconds, 25 seconds, 45 seconds, 1.5 minutes, 3 minutes and 5.5 minutes after each previous attempt. Retries stop 15 minutes after the webhook was created, and the webhook is then marked as permanently failed.

Workarounds:

  • Verify, store and acknowledge within the 5 seconds, and do the work afterwards.
  • Keep deploys short, or drain traffic to a second instance during them.
  • Reconcile against the API on a schedule to catch what was lost.

How Hookdeck Can Help: Hookdeck accepts Rutter's delivery straight away and then retries your service on a schedule you choose, with replay for anything that still fails. Your own downtime is no longer limited to 15 minutes.

Webhooks are not fully reliable

The Problem: Your copy of a customer's data drifts, even though every webhook you received was processed correctly.

Why It Happens: Rutter says plainly that webhooks are not 100% reliable and that a small percentage are dropped under normal conditions. It recommends calling the API regularly as well as using webhooks.

Workarounds:

  • Treat webhooks as a fast path, not the only path. Run a periodic sync per Connection.
  • Use INITIAL_UPDATE as the trigger for your first full read, and never fetch before it arrives. Fetching earlier returns a PRODUCT_NOT_READY error.

How Hookdeck Can Help: Hookdeck can't recover a webhook Rutter never sent, but it makes sure none of the ones Rutter does send are lost on your side, and its Issues alert you when deliveries to your service start failing.

Duplicates and no event ID

The Problem: The same change is processed twice, and there is no ID to tell you so.

Why It Happens: Delivery is at least once, and Rutter also sends webhooks with identical payloads when the raw platform data changes but the normalised Rutter object does not. The body has no event ID, and Rutter documents no delivery ID header.

Workarounds:

  • Make writes idempotent, for example upserts keyed on connection_id and the entity id.
  • Hash the raw body and skip exact repeats within a time window.

How Hookdeck Can Help: Hookdeck's deduplication can drop exact duplicate payloads, or duplicates on the fields you choose, before they reach your service.

Rotation breaks single-header verifiers

The Problem: Verification fails for some or all deliveries while you rotate your client secret.

Why It Happens: During the grace period, X-Rutter-Signature carries only the new secret's signature. A verifier that reads only that header and still holds the old secret rejects everything. Because the signing key is your organisation's client secret, rotating your API credentials also changes the webhook key.

Workarounds:

  • Verify against X-Rutter-Signatures and hold both secrets until the grace period has ended and all in-flight webhooks are processed.
  • Load secrets from a store you can update without a deploy.

How Hookdeck Can Help: Hookdeck's Rutter source type verifies the primary X-Rutter-Signature header. When you start a rotation, update the secret on the Hookdeck source to the new client secret, and verification carries on. Your own service then only needs to trust Hookdeck.

Your own writes come back as events

The Problem: Your integration writes a bill through Rutter, receives BILL_CREATED for it, and syncs it back as if a user had made it.

Why It Happens: Rutter fires entity webhooks for changes made through its own API as well as changes on the platform. Writes are asynchronous jobs, and the entity webhook and the JOB_COMPLETED webhook for the same write can arrive in either order.

Workarounds:

  • Use job_id. When it is present, the change came from your API call, and it matches job.id on the JOB_COMPLETED webhook.
  • Don't read a missing job_id as proof of a human edit. Rutter lists cases without one, including related records touched as a side effect, follow-up *_UPDATED events after a re-read (routine on QuickBooks Online), and QuickBooks Desktop.

How Hookdeck Can Help: Hookdeck filters can route events that carry job_id to a different destination from those that don't, so write confirmations and external changes take separate paths.

Best practices

Verify against the raw body and the full signature list

Read the raw bytes (express.raw(), await request.text() in Next.js, await request.body() in FastAPI), split X-Rutter-Signatures on commas, and accept a match against any secret you currently hold.

Treat the payload as sensitive

Every webhook includes the Connection's access_token. Keep raw payloads out of general application logs and restrict who can read stored events.

Route on code and ignore what you don't know

Use code as the event name. Acknowledge codes you do not handle with a 200, since the documented list is incomplete and Rutter adds events over time.

Acknowledge fast and process asynchronously

Five seconds is tight once you add a database write and an API call. Verify, store, return 200, and process in a background job. See why to process webhooks asynchronously.

Make handlers idempotent

With at-least-once delivery, identical payloads and no event ID, idempotency has to come from your data model. See our guide to webhook idempotency.

Don't expect events for inactive records

Rutter sends a delete webhook only when an entity is actually deleted on the platform, and only for supported resources. Marking a record inactive does not send one, so reconcile status fields on your periodic sync.

Conclusion

Rutter signs each webhook with a base64 HMAC-SHA256 keyed with your client secret, in both X-Rutter-Signature and the rotation-friendly X-Rutter-Signatures list. Verify against the raw body, route on code, use job_id to recognise your own writes, and back webhooks with a periodic sync, because Rutter allows only 5 seconds per attempt and 15 minutes of retries, and says some webhooks will be dropped.

Hookdeck Event Gateway sits in front of your receiver, checks every Rutter signature, answers within Rutter's window and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Rutter webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.