Guide to Rutter Webhooks: Features and Best Practices
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
| Feature | Details |
|---|---|
| Configuration | One URL per organisation, on the dashboard's Platforms page |
| Real-time webhooks | QuickBooks Online, Xero, Stripe and Sage Intacct, set up on the platform's Webhooks tab |
| Signature headers | X-Rutter-Signature and X-Rutter-Signatures |
| Scheme | HMAC-SHA256, base64 digest, prefixed with sha256= |
| Signed content | The webhook payload |
| Secret | Your organisation's client secret |
| Secret rotation | During the grace period, X-Rutter-Signatures carries signatures for both secrets |
| Event identifier | code in the body, with the entity family in type |
| Response window | A 2xx within 5 seconds |
| Retries | Exponential backoff with jitter, for up to 15 minutes from creation |
| Delivery guarantee | At least once, and a small share of webhooks may be dropped |
| Event ID | None |
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.
type | Codes | Fires when |
|---|---|---|
CONNECTION | INITIAL_UPDATE | The first data download for a Connection has finished and you can start fetching data |
CONNECTION | HISTORICAL_UPDATE | A batch of historical data has synced (only with data batching enabled) |
CONNECTION | INCREMENTAL_ | A manual incremental sync triggered from the dashboard has finished |
CONNECTION | CONNECTION_UPDATED | An end user re-authenticated an existing Connection |
CONNECTION | CONNECTION_NEEDS_UPDATE | The merchant uninstalled or unauthorised the Connection, or authentication failed |
CONNECTION | CONNECTION_DISABLED, CONNECTION_ERROR, CONNECTION_LINK_ERROR | Rutter disabled the Connection, hit a sync or connection error, or authentication failed during linking |
| Accounting | ACCOUNT_*, BILL_*, BILL_PAYMENT_*, CUSTOMER_*, INVOICE_*, INVOICE_CREDIT_MEMO_*, JOURNAL_ENTRY_*, VENDOR_* | Records were created or updated |
| Commerce | ORDER_*, PRODUCT_*, PRODUCT_READY, PRODUCT_FINISH_FAILED, STORE_UPDATED | Orders, products and store settings changed, or an asynchronous product job finished |
| Deletes | *_DELETED, for example BILL_DELETED and ORDER_DELETED | Rutter 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
- 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. - In the Rutter dashboard, go to the Platforms page and paste your URL into the webhook setting.
- Store your organisation's client secret server-side, for example as
RUTTER_CLIENT_SECRET. - 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.
- Run through the connection flow with a test account and check that
INITIAL_UPDATEarrives.
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 toX-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_UPDATEas the trigger for your first full read, and never fetch before it arrives. Fetching earlier returns aPRODUCT_NOT_READYerror.
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_idand the entityid. - 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-Signaturesand 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 matchesjob.idon theJOB_COMPLETEDwebhook. - Don't read a missing
job_idas proof of a human edit. Rutter lists cases without one, including related records touched as a side effect, follow-up*_UPDATEDevents 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.