Gareth Wilson Gareth Wilson

Guide to Unit Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationUnit Dashboard (Developer > Webhooks) or POST /webhooks
Event selectionsubscriptionType: All, OnlyAuthorizationRequest or NotAuthorizationRequest
BodyJSON:API document. data is an array of events
Media typeapplication/json (Json) or application/vnd.api+json (JsonAPI)
Signature headerX-Unit-Signature
SchemeHMAC-SHA1, base64, no prefix
Signed contentThe entire raw request body
SecretThe webhook's token, required on create, used as a plain string
Replay windowNone. No timestamp is signed
AtMostOnceOne event per request. A timeout or any status other than 200 loses the event
AtLeastOnceUp to 64 events per request, retried with Fibonacci backoff, up to 1 hour between retries
SequencingThe next request waits for the response to the previous one, in both modes
UnavailableAfter 7 days unresponsive in production, 2 days in sandbox, until re-enabled
Sandbox limit100 enabled webhooks

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

EnvironmentIP addresses
Sandbox54.81.62.38, 35.169.213.205, 3.234.105.75
Live3.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.

AreaExamples
Onboardingapplication.created, .approved, .denied, .pendingReview, .awaitingDocuments, .canceled, document.approved, document.rejected, customer.created, customer.updated, customer.archived
Accountsaccount.created, .updated, .frozen, .unfrozen, .closed, .reopened, accountHold.created, accountHold.released
Cardscard.created, .activated, .statusChanged, .pinChanged, .reissuing, .replacing, cardFraudCase.*
Authorizationsauthorization.created, .updated, .canceled, .declined, .amountChanged, authorizationRequest.approved, authorizationRequest.declined
Transactionstransaction.created, transaction.updated
Paymentspayment.created, .clearing, .sent, .returned, .rejected, .pendingReview, .canceled, receivedPayment.*, recurringPayment.*, bulkPayments.finished, bulkPayments.failed
CheckscheckDeposit.* (8 events), checkPayment.* (14 events), stopPayment.*
LendingcreditApplication.*, creditDecision.*, repayment.*, recurringRepayment.*
Otherdispute.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:

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

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

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.


Gareth Wilson

Gareth Wilson

Product Marketing

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