# 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:

```json
{
  "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

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`.

```javascript
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:

```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](/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](/docs/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](/docs/destinations#set-a-max-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](/docs/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](/webhooks/guides/implement-webhook-idempotency).

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Codat webhooks reliably in minutes.