# Guide to Unit Webhooks: Features and Best Practices

Unit webhooks tell your application what is happening across the banking products you build on Unit: an application is approved or denied, a customer or account is created, a card is activated, a transaction posts, an ACH payment clears or is returned, a check deposit is rejected, or a dispute changes status. Because a banking product depends on getting these events right, how Unit delivers them matters as much as what they contain.

This guide covers how Unit webhooks are created and signed, the two delivery modes and the trade-offs between them, the events and the JSON:API payload format, and the best practices for running a Unit receiver in production.

## What are Unit webhooks?

Every change in Unit is recorded as an event with a type such as `customer.created` or `payment.clearing`. A webhook sends those events to a URL as HTTP `POST` requests. You create webhooks in the Unit Dashboard or through the API, and each one has a few settings that change what arrives:

* **`deliveryMode`** is either `AtMostOnce`, one event per request and no retries, or `AtLeastOnce`, up to 64 events per request with retries.
* **`subscriptionType`** is `All`, `OnlyAuthorizationRequest` or `NotAuthorizationRequest`. You can't pick individual event types beyond that.
* **`contentType`** is `Json` or `JsonAPI`, which sets the media type of the request.
* **`includeResources`** adds the full related resource, such as the customer for `customer.created`, to each event.

Every request is signed. Unit computes an HMAC-SHA1 of the entire body with the webhook's secret `token` and sends it base64-encoded in `X-Unit-Signature`.

## Unit webhook features

| Feature | Details |
| --- | --- |
| Configuration | Unit Dashboard (**Developer > Webhooks**) or `POST /webhooks` |
| Event selection | `subscriptionType`: `All`, `OnlyAuthorizationRequest` or `NotAuthorizationRequest` |
| Body | JSON:API document. `data` is an array of events |
| Media type | `application/json` (`Json`) or `application/vnd.api+json` (`JsonAPI`) |
| Signature header | `X-Unit-Signature` |
| Scheme | HMAC-SHA1, base64, no prefix |
| Signed content | The entire raw request body |
| Secret | The webhook's `token`, required on create, used as a plain string |
| Replay window | None. No timestamp is signed |
| AtMostOnce | One event per request. A timeout or any status other than `200` loses the event |
| AtLeastOnce | Up to 64 events per request, retried with Fibonacci backoff, up to 1 hour between retries |
| Sequencing | The next request waits for the response to the previous one, in both modes |
| Unavailable | After 7 days unresponsive in production, 2 days in sandbox, until re-enabled |
| Sandbox limit | 100 enabled webhooks |

Unit publishes the IP addresses it sends from, and says they may change:

| Environment | IP addresses |
| --- | --- |
| Sandbox | `54.81.62.38`, `35.169.213.205`, `3.234.105.75` |
| Live | `3.209.193.26`, `54.156.65.95`, `54.165.224.37` |

Unit doesn't document how long it waits for a response before treating a delivery as timed out.

## Common events

Unit documents more than a hundred event types, named `<resource>.<event>`, and says new types are added continuously. Your listener must handle types it doesn't know without breaking.

| Area | Examples |
| --- | --- |
| Onboarding | `application.created`, `.approved`, `.denied`, `.pendingReview`, `.awaitingDocuments`, `.canceled`, `document.approved`, `document.rejected`, `customer.created`, `customer.updated`, `customer.archived` |
| Accounts | `account.created`, `.updated`, `.frozen`, `.unfrozen`, `.closed`, `.reopened`, `accountHold.created`, `accountHold.released` |
| Cards | `card.created`, `.activated`, `.statusChanged`, `.pinChanged`, `.reissuing`, `.replacing`, `cardFraudCase.*` |
| Authorizations | `authorization.created`, `.updated`, `.canceled`, `.declined`, `.amountChanged`, `authorizationRequest.approved`, `authorizationRequest.declined` |
| Transactions | `transaction.created`, `transaction.updated` |
| Payments | `payment.created`, `.clearing`, `.sent`, `.returned`, `.rejected`, `.pendingReview`, `.canceled`, `receivedPayment.*`, `recurringPayment.*`, `bulkPayments.finished`, `bulkPayments.failed` |
| Checks | `checkDeposit.*` (8 events), `checkPayment.*` (14 events), `stopPayment.*` |
| Lending | `creditApplication.*`, `creditDecision.*`, `repayment.*`, `recurringRepayment.*` |
| Other | `dispute.created`, `dispute.statusChanged`, `chargeback.created`, `statements.created`, `taxForm.created`, `reward.sent`, `whiteLabelAppUser.created` |

Here is Unit's `customer.created` example:

```json
{
  "data": [
    {
      "id": "28",
      "type": "customer.created",
      "attributes": {
        "createdAt": "2020-07-29T12:53:05.882Z",
        "tags": { "tag": "value" }
      },
      "relationships": {
        "customer": { "data": { "id": "52", "type": "individualCustomer" } },
        "application": { "data": { "id": "52", "type": "individualApplication" } }
      }
    }
  ]
}

```

Each event has an `id`, which is your deduplication key, a `type`, `attributes` with `createdAt`, `tags` and the event's own fields, and `relationships` that point at the affected resources by `id` and `type`. With `includeResources` on, the full related resources come in an `included` array.

Two details catch out handlers:

* `data` is an array, and with `AtLeastOnce` it can hold up to 64 events of different types. A few of Unit's examples (`assignment.*`, `cardFraudCase.*`, `repayment.*` and `wireDrawdown.created`) show `data` as a single object instead, so handle both shapes.
* Unit says `payment.canceled` can arrive as `payment.canceled` or `payment.Canceled`, and its own example uses the capitalised form. Compare types case-insensitively, or match both.

## Setting up Unit webhooks

1. In the Unit Dashboard, go to **Webhooks** under **Developer** in the top menu and click **Create**.
2. Enter a label, your endpoint URL and a secret **token**. Store the same token server-side, for example as `UNIT_WEBHOOK_TOKEN`.
3. Choose the delivery mode, content type, subscription type and whether to include resources.

Or create the webhook through the API. `token`, `contentType` and `deliveryMode` are required:

```bash
curl -X POST 'https://api.s.unit.sh/webhooks' \
  -H 'Content-Type: application/vnd.api+json' \
  -H "Authorization: Bearer $UNIT_API_TOKEN" \
  --data-raw '{
    "data": {
      "type": "webhook",
      "attributes": {
        "label": "Production events",
        "url": "https://example.com/webhooks/unit",
        "token": "a-long-random-token",
        "contentType": "Json",
        "deliveryMode": "AtLeastOnce",
        "includeResources": false,
        "subscriptionType": "NotAuthorizationRequest"
      }
    }
  }'

```

That URL is Unit's sandbox API. Webhooks can be updated with `PATCH /webhooks/:id`, including a new `token`, and switched on and off with `POST /webhooks/:id/enable` and `/disable`.

If you use Hookdeck, create a Unit source, set the same token on it, and use the Hookdeck source URL as the webhook URL.

For most integrations, choose `AtLeastOnce`. `AtMostOnce` gives up after a single failed attempt, which is rarely what you want for banking events. Unit recommends a dedicated `AtMostOnce` webhook with `OnlyAuthorizationRequest` if you implement programmatic authorization of card use, "due to the sensitive nature of this flow".

## Securing Unit webhooks

Compute an HMAC-SHA1 of the raw request body with your token, encode it as base64, and compare it with `X-Unit-Signature` in constant time. Base64 is case-sensitive, so don't change the case of either side.

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

function verifyUnitSignature(rawBody, signatureHeader, token) {
  if (!signatureHeader) return false;

  const expected = crypto.createHmac('sha1', token).update(rawBody).digest('base64');

  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/unit', express.raw({ type: '*/*' }), (req, res) => {
  const token = process.env.UNIT_WEBHOOK_TOKEN;
  if (!token) return res.sendStatus(500); // fail closed

  if (!verifyUnitSignature(req.body, req.get('x-unit-signature'), token)) {
    return res.sendStatus(401);
  }

  const body = JSON.parse(req.body.toString('utf8'));
  const events = Array.isArray(body.data) ? body.data : [body.data];
  // Store each event keyed by event.id, then process asynchronously.
  res.status(200).end(); // exactly 200: AtMostOnce treats anything else as a failure
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_unit_signature(raw_body: bytes, signature_header, token: str) -> bool:
    if not signature_header:
        return False
    digest = hmac.new(token.encode("utf-8"), raw_body, hashlib.sha1).digest()
    expected = base64.b64encode(digest)
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)

# In FastAPI: raw_body = await request.body()
# verify_unit_signature(raw_body, request.headers.get("x-unit-signature"),
#                       os.environ["UNIT_WEBHOOK_TOKEN"])

```

Unit's own Node example registers `express.raw()` only for `application/json` and `application/vnd.api+json`, compares the digest with `==` rather than in constant time, and answers a mismatch with `500`. Using `'*/*'` covers both content types, and a `401` is a clearer answer for a bad signature. Under `AtLeastOnce`, Unit retries failed requests either way.

To check your verifier, use the token `unit-test-token` and this exact body:

```
{"data":[{"id":"28","type":"customer.created","attributes":{"createdAt":"2020-07-29T12:53:05.882Z"}}]}

```

The signature should be:

```
1CBbEcKHsrc9u6js5kiZU3aGf+c=

```

> **Make Unit webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies every `X-Unit-Signature`, answers Unit with a `200` as soon as the batch is stored, and durably queues each account, card and payment event before your service sees it.

## Unit webhook limitations and pain points

### AtMostOnce loses events on any failure

**The Problem:** Events disappear after a deploy, a slow response or a handler bug, with nothing to retry.

**Why It Happens:** In `AtMostOnce` mode, Unit makes one attempt per event. A timeout or any status other than `200` counts as a failure, and the event is not retried. Even a `201` or `204` counts as a failure.

**Workarounds:**

* Use `AtLeastOnce` unless you have a specific reason not to.
* If you must use `AtMostOnce`, return exactly `200`, and keep the handler to verification and storage.

**How Hookdeck Can Help:** Hookdeck answers Unit with a `200` once the request is stored, so a slow or failing service no longer loses `AtMostOnce` events. Hookdeck then retries delivery to your service on its own schedule.

### One slow response holds up every event

**The Problem:** Events arrive late, sometimes long after they happened in Unit.

**Why It Happens:** In both modes, Unit sends the next request only after it has a response to the previous one. A handler that takes several seconds slows every event behind it, and under `AtLeastOnce` a failing endpoint backs off for up to an hour between retries while new events wait.

**Workarounds:**

* Acknowledge immediately and process each event asynchronously.
* Monitor the gap between an event's `createdAt` and when you processed it.

**How Hookdeck Can Help:** Hookdeck acknowledges each request quickly, so Unit's queue keeps moving, and delivers events to your service with its own concurrency and rate limits.

### Retries and batches mean duplicates

**The Problem:** The same event is processed twice, or one bad event fails a whole batch.

**Why It Happens:** `AtLeastOnce` retries the whole request, which can hold up to 64 events. If your handler processed 63 and failed on the last, the retry sends all 64 again.

**Workarounds:**

* Deduplicate on each event's `id`, as Unit recommends. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).
* Store the batch first and process each event on its own, so one bad event doesn't fail the request.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated requests before they reach your service, and its transformations can work on the batch before delivery.

### Webhooks go Unavailable

**The Problem:** Events stop arriving entirely.

**Why It Happens:** A production webhook that has been unresponsive for 7 days (2 in sandbox) changes to `Unavailable` and receives nothing until someone re-enables it.

**Workarounds:**

* Alert on the webhook's `status` from `GET /webhooks/{id}`.
* Re-enable it with `POST /webhooks/:id/enable` once your endpoint is healthy.
* For gaps, reconcile with `GET /events`, which filters events by `filter[since]`, `filter[until]` and `filter[type][]`.

**How Hookdeck Can Help:** A Hookdeck source stays available even when your service doesn't, so the webhook never sits unresponsive for days, and events wait in Hookdeck until your service is back.

### A static body signature with no timestamp

**The Problem:** A captured request still verifies if it is replayed later.

**Why It Happens:** The signature covers only the body. No timestamp or nonce is signed.

**Workarounds:**

* Deduplicate on event `id`, which stops a replayed event from being applied twice.
* Allowlist Unit's published IP addresses for the environment you're in.

**How Hookdeck Can Help:** Hookdeck's deduplication drops repeated events before they reach your service.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Unit sent. In Express, mount `express.raw()` on the webhook route and parse after verification. In a Next.js route handler, use `Buffer.from(await request.arrayBuffer())`. In FastAPI, read `await request.body()`.

### Always iterate over events

Treat `data` as a list, even under `AtMostOnce`, and wrap a single object in a list if that is what arrives. Route each event on its own `type`.

### Normalise event types

Compare `type` case-insensitively, so `payment.Canceled` and `payment.canceled` reach the same handler, and acknowledge types you don't know. Unit adds new types continuously.

### Use relationships, then fetch

Events carry IDs, not full objects, unless `includeResources` is on. For decisions that move money, fetch the current resource from the Unit API by the related `id` rather than trusting a snapshot that may be out of date.

### Acknowledge fast and process asynchronously

Return `200` straight after verification and storage. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Unit signs every webhook request with a base64 HMAC-SHA1 of the raw body in `X-Unit-Signature`, keyed with the webhook token. Prefer `AtLeastOnce`, iterate over `data` as a batch, deduplicate on event `id`, compare types case-insensitively, return exactly `200`, and answer quickly, because every slow response delays the events queued behind it.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Unit signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Unit webhooks reliably in minutes.