Guide to Codat Webhooks: Features and Best Practices
Codat connects lenders and B2B software to their customers' financial data in accounting, banking and commerce platforms. Much of that work is asynchronous: a company links its accounting system and Codat pulls the data in the background. Codat webhooks tell your application when each step finishes, so you can fetch data or update a loan application without polling.
This guide covers Codat's current webhook service, delivered through Svix: how consumers are configured, how to verify signatures, the event catalogue, the retry and recovery tools, and the best practices for running a Codat receiver in production. Codat's legacy rule-based webhooks, used before 6 March 2024, are not covered.
What are Codat webhooks?
Codat calls your receiving endpoints webhook consumers. Each consumer is a URL subscribed to one or more event types. When an event occurs, Codat sends an HTTP POST with a JSON body to every consumer subscribed to it.
Every request is signed with the consumer's own signing secret. The signature covers the message ID, a timestamp and the raw body, and arrives in three headers: webhook-id, webhook-timestamp and webhook-signature.
Codat webhook features
| Feature | Details |
|---|---|
| Configuration | Codat Portal (Monitor > Webhooks > Events) or the Create webhook API |
| Subscription model | At least one event type per consumer endpoint |
| Signature headers | webhook-id, webhook-timestamp, webhook-signature |
| Scheme | Svix: HMAC-SHA256 over {id}.{timestamp}.{body}, base64, v1, prefix |
| Secret | One per endpoint, whsec_ prefix, shown in the endpoint's detail view |
| Response window | 2xx within 15 seconds |
| Retries | Immediately, then 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h (about 28 hours) |
| Auto-disable | After 5 days of failed attempts |
| Recovery | Resend one message, recover failed messages or replay missed ones from the Portal |
| Delivery guarantee | At least once; webhook-id is stable across attempts |
| Extra security | Custom headers (such as Authorization) and mutual TLS |
| Filtering | Up to 10 company tags per consumer |
| Rate limit | Messages per second, per consumer |
| Source IPs | 4.159.114.108, 20.117.190.191 |
Common events
Every message shares one envelope:
{
"id": "743ec94a-8aa4-44bb-8bd4-e1855ee0e74b",
"eventType": "read.completed",
"generatedDate": "2022-10-23T00:00:00.000Z",
"payload": {
"referenceCompany": {
"id": "0498e921-9b53-4396-a412-4f2f5983b0a2",
"name": "Toft stores"
},
"modifiedFromDate": "2022-10-23T00:00:00.000Z",
"dataTypes": [
{
"connectionId": "2e9d2c44-f675-40ba-8049-353bfcb5e171",
"dataType": "invoices",
"recordsModified": false,
"status": "Complete"
}
]
}
}
eventType names the event, and payload holds the event's data, usually starting with referenceCompany. For connection events, payload.connection includes the connection's id, platformName, status and sourceType.
| Event | Fires when |
|---|---|
company.created / company.deleted | A company is created or deleted in Codat |
connection.created | The business creates a connection |
connection.connected | A connection is successfully linked |
connection.disconnected / connection.reconnected | A connection drops or comes back |
connection.failed | A connection fails to link |
connection.deleted | A connection is deleted |
read.completed.initial | The first read of a solution's data types has completed |
read.completed | A read of a solution's data types has completed |
{dataType}.write.successful / .unsuccessful | A write completes or fails, for example bills.write.successful |
client.rateLimit.reached / client.rateLimit.reset | Your API quota runs out or resets |
bankFeeds.sourceAccount.connected / .disconnected | A bank feed source account changes status |
expenses.sync.successful / .unsuccessful | An expense sync completes or fails |
reports.categorizedBankStatement.generate.successful / .unsuccessful | A categorised bank statement is generated or fails |
reports.creditModel.generate.successful / .unsuccessful | A Credit Model report is generated or fails |
reports.spendAnalysis.generate.successful / .unsuccessful | A spend analysis report is generated or fails |
AccountCategoriesUpdated | Codat AI has categorised a company's accounts |
company.product.added / .removed | A product is added to or removed from a company |
Two naming quirks are worth handling. Codat's event catalogue spells the successful categorised bank statement event with a singular report. prefix in one place, while its API specification and examples use reports.; match both. AccountCategoriesUpdated is the catalogue name, but Codat's API specification gives this webhook an older rule-style body (ClientId, CompanyId, RuleType, AlertId, Message, Data) with RuleType set to Account Categories Updated and no eventType. Fall back to RuleType when eventType is missing.
Setting up Codat webhooks
- In the Codat Portal, open Monitor > Webhooks > Events and click Add endpoint. Some Codat pages describe this as Settings > Webhooks > Events > Configure consumer; both lead to the same consumer list.
- Enter the endpoint URL and an optional description, and choose at least one event type.
- Open the new endpoint and copy its Signing secret, which starts with
whsec_. Store it server-side, for example asCODAT_WEBHOOK_SECRET. Each endpoint has its own. - On the Testing tab, choose an example event and click Send example. You can open the message to see its payload, attempts and status.
You can also create, list and delete consumers through Codat's API. Custom headers can only be set in the Portal, on the endpoint's Advanced tab, which also holds the mutual TLS settings.
Codat says that "in general, you need one consumer per event type". If you expect more than 50 consumers, Codat asks you to contact them first.
To limit a consumer to some companies, enter up to 10 company tags in key:value form, such as region:us. A message is delivered when any of the company's tags matches.
Securing Codat webhooks
Strip whsec_ from the secret and base64-decode the rest to get the key bytes. Build the signed content from the webhook-id header, a period, the webhook-timestamp header, a period and the raw request body. Compute an HMAC-SHA256, base64-encode it, and compare it in constant time with every v1, entry in webhook-signature.
const crypto = require('crypto');
const express = require('express');
function verifyCodatSignature(rawBody, headers, secret) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!id || !timestamp || !signatureHeader) return false;
// The key is the base64-decoded part of the secret after "whsec_".
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]))
.digest();
// The header can hold several space-delimited "v1,<base64>" entries.
return signatureHeader.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version !== 'v1' || !value) return false;
const received = Buffer.from(value, 'base64');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/codat', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.CODAT_WEBHOOK_SECRET;
if (!secret || !secret.startsWith('whsec_')) return res.sendStatus(500); // fail closed
if (!verifyCodatSignature(req.body, req.headers, secret)) return res.sendStatus(400);
// Optional: reject a webhook-timestamp far from the current time.
// const age = Math.abs(Date.now() / 1000 - Number(req.get('webhook-timestamp')));
// if (age > 300) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on req.get('webhook-id'), then route on event.eventType.
res.sendStatus(200);
});
The same check in Python:
import base64
import hashlib
import hmac
def verify_codat_signature(raw_body: bytes, headers, secret: str) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not (msg_id and timestamp and signature_header):
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())
for entry in signature_header.split(" "):
version, _, value = entry.partition(",")
if version == "v1" and hmac.compare_digest(value.encode("utf-8"), expected):
return True
return False
# In FastAPI: raw_body = await request.body()
# verify_codat_signature(raw_body, request.headers, os.environ["CODAT_WEBHOOK_SECRET"])
Codat recommends the Svix library (npm install svix, pip install svix), which takes the whsec_ secret, the raw body and the headers. The library also rejects messages whose timestamp is too far from the current time, which matters if requests can reach your code late, for example from a queue.
Codat signs the raw string body. A framework that parses JSON and re-serialises it will break verification, so read the bytes before parsing.
Make Codat webhooks production-ready. Hookdeck Event Gateway verifies Codat's Svix signature with your
whsec_secret, answers inside Codat's 15 second window, and queues every company, connection and report event for your service.
Codat webhook limitations and pain points
One consumer per event type
The Problem: A growing integration ends up with dozens of consumer endpoints, each with its own secret.
Why It Happens: Codat's guidance is one consumer per event type, and each endpoint has a separate signing secret and settings.
Workarounds:
- Group event types that one service handles into a single consumer.
- Keep a mapping of endpoint to secret in your configuration, and use a path per consumer so you know which secret applies.
How Hookdeck Can Help: Point consumers at Hookdeck sources and use filters on eventType to route events to the right service, so the number of consumers no longer has to match the number of internal handlers.
A 15 second timeout
The Problem: Messages fail even though your service received them.
Why It Happens: Codat treats any response after 15 seconds as a failure, and slow work such as pulling data after read.completed easily exceeds it.
Workarounds:
- Put the event on a queue and respond
200straight away, as Codat recommends.
How Hookdeck Can Help: Hookdeck responds to Codat immediately and delivers to your service at the pace it can handle, with a delivery rate you set.
Endpoints disabled after five days of failures
The Problem: A consumer silently stops receiving events.
Why It Happens: If all attempts to an endpoint fail for 5 days, Codat disables it. No attempts are made while it is disabled.
Workarounds:
- Watch the Portal's Activity graph and Logs for failures.
- After a fix, re-enable the endpoint (Monitor > Webhooks > Events > Endpoints, then Enable endpoint) and replay missed messages for the affected period.
How Hookdeck Can Help: Hookdeck stays available to Codat while your service is down, so the Codat endpoint keeps succeeding. Hookdeck holds the events and retries towards your service until it recovers.
Duplicate deliveries
The Problem: The same read or connection event is processed twice.
Why It Happens: Codat delivers at least once, and Portal recovery sends new attempts regardless of the retry schedule.
Workarounds:
- Deduplicate on
webhook-id, which Codat keeps the same for every attempt at a message. See our guide to webhook idempotency.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeats with the same webhook-id header.
Best practices
Verify against the raw body
Mount express.raw() on the route and verify before parsing. In Next.js, use await request.text(). In FastAPI, read await request.body().
Route on eventType and tolerate variants
Match eventType exactly for most events, allow both report. and reports. prefixes for the categorised bank statement event, and fall back to RuleType for the account categories webhook. Log unknown types instead of returning an error.
Use company tags to split traffic
If different products or regions are handled by different services, tag companies and give each consumer the tags it should receive, instead of filtering in code.
Allowlist Codat's IPs if you are behind a firewall
Codat sends from 4.159.114.108 and 20.117.190.191. Allow both, or messages will fail at your network edge.
Conclusion
Codat's webhook service signs each message with Svix's scheme: an HMAC-SHA256 over the message ID, timestamp and raw body, keyed with a per-endpoint whsec_ secret. Verify against the raw bytes, respond within 15 seconds, deduplicate on webhook-id, and watch for failures before Codat disables an endpoint after 5 days.
Hookdeck Event Gateway sits in front of your consumers, verifies every Codat request, routes events by type and holds them until your services have processed them.
Get started with Hookdeck for free and handle Codat webhooks reliably in minutes.