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:
deliveryModeis eitherAtMostOnce, one event per request and no retries, orAtLeastOnce, up to 64 events per request with retries.subscriptionTypeisAll,OnlyAuthorizationRequestorNotAuthorizationRequest. You can't pick individual event types beyond that.contentTypeisJsonorJsonAPI, which sets the media type of the request.includeResourcesadds the full related resource, such as the customer forcustomer.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:
{
"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:
datais an array, and withAtLeastOnceit can hold up to 64 events of different types. A few of Unit's examples (assignment.*,cardFraudCase.*,repayment.*andwireDrawdown.created) showdataas a single object instead, so handle both shapes.- Unit says
payment.canceledcan arrive aspayment.canceledorpayment.Canceled, and its own example uses the capitalised form. Compare types case-insensitively, or match both.
Setting up Unit webhooks
- In the Unit Dashboard, go to Webhooks under Developer in the top menu and click Create.
- Enter a label, your endpoint URL and a secret token. Store the same token server-side, for example as
UNIT_WEBHOOK_TOKEN. - 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:
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.
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:
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 verifies every
X-Unit-Signature, answers Unit with a200as 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
AtLeastOnceunless you have a specific reason not to. - If you must use
AtMostOnce, return exactly200, 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
createdAtand 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. - 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
statusfromGET /webhooks/{id}. - Re-enable it with
POST /webhooks/:id/enableonce your endpoint is healthy. - For gaps, reconcile with
GET /events, which filters events byfilter[since],filter[until]andfilter[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.
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 sits in front of your receiver, checks every Unit signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Unit webhooks reliably in minutes.