Gareth Wilson Gareth Wilson

Guide to Codat Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationCodat Portal (Monitor > Webhooks > Events) or the Create webhook API
Subscription modelAt least one event type per consumer endpoint
Signature headerswebhook-id, webhook-timestamp, webhook-signature
SchemeSvix: HMAC-SHA256 over {id}.{timestamp}.{body}, base64, v1, prefix
SecretOne per endpoint, whsec_ prefix, shown in the endpoint's detail view
Response window2xx within 15 seconds
RetriesImmediately, then 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h (about 28 hours)
Auto-disableAfter 5 days of failed attempts
RecoveryResend one message, recover failed messages or replay missed ones from the Portal
Delivery guaranteeAt least once; webhook-id is stable across attempts
Extra securityCustom headers (such as Authorization) and mutual TLS
FilteringUp to 10 company tags per consumer
Rate limitMessages per second, per consumer
Source IPs4.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.

EventFires when
company.created / company.deletedA company is created or deleted in Codat
connection.createdThe business creates a connection
connection.connectedA connection is successfully linked
connection.disconnected / connection.reconnectedA connection drops or comes back
connection.failedA connection fails to link
connection.deletedA connection is deleted
read.completed.initialThe first read of a solution's data types has completed
read.completedA read of a solution's data types has completed
{dataType}.write.successful / .unsuccessfulA write completes or fails, for example bills.write.successful
client.rateLimit.reached / client.rateLimit.resetYour API quota runs out or resets
bankFeeds.sourceAccount.connected / .disconnectedA bank feed source account changes status
expenses.sync.successful / .unsuccessfulAn expense sync completes or fails
reports.categorizedBankStatement.generate.successful / .unsuccessfulA categorised bank statement is generated or fails
reports.creditModel.generate.successful / .unsuccessfulA Credit Model report is generated or fails
reports.spendAnalysis.generate.successful / .unsuccessfulA spend analysis report is generated or fails
AccountCategoriesUpdatedCodat AI has categorised a company's accounts
company.product.added / .removedA 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

  1. 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.
  2. Enter the endpoint URL and an optional description, and choose at least one event type.
  3. Open the new endpoint and copy its Signing secret, which starts with whsec_. Store it server-side, for example as CODAT_WEBHOOK_SECRET. Each endpoint has its own.
  4. 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 200 straight 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:

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.


Gareth Wilson

Gareth Wilson

Product Marketing

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