# Guide to Dwolla Webhooks: Features and Best Practices

Dwolla webhooks tell your systems when something happens to the money and customers you manage through Dwolla's payments API: a customer is created or verified, a bank account is added, a transfer is created, completes or fails. If you move funds by ACH, RTP or FedNow through Dwolla, webhooks are how your application learns the outcome of each step without polling the API.

This guide covers how Dwolla webhook subscriptions are created and signed, the topics you will receive and the shape of their payloads, the retry and pause rules, and the best practices for running a Dwolla receiver in production.

## What are Dwolla webhooks?

Dwolla creates an event "each time a resource is created or updated". If your application has an active webhook subscription, each event becomes a webhook: a `POST` to the URL you subscribed, with a small JSON body describing the event and a signature header.

A subscription is all or nothing. Dwolla says that "all Events relevant to your integration will trigger a webhook notification each time they occur", so you cannot pick topics. Your handler receives every topic and decides which ones matter.

The payload is deliberately thin. It names the topic, the event ID and the ID of the resource that changed, plus links. Dwolla describes webhook payloads as "designed to be lightweight with only minimum details regarding the triggered event", so for anything beyond that you follow `_links.resource.href` and read the current state from the API.

## Dwolla webhook features

| Feature | Details |
| --- | --- |
| Configuration | `POST /webhook-subscriptions` with `url` and `secret` |
| Subscriptions | Up to five active in Production and ten in Sandbox |
| Subscription model | Every event for the application. No topic filter |
| Signature header | `X-Request-Signature-SHA-256` |
| Scheme | HMAC-SHA256, lowercase hex digest |
| Signed content | The raw request body, nothing else |
| Secret | Chosen by you when you create the subscription |
| Topic header | `X-Dwolla-Topic`, the same value as the body's `topic` |
| Replay window | None. No timestamp is signed |
| Response window | A 2xx within 10 seconds. 3xx and redirects count as failures |
| Retries | 8 retries over 72 hours |
| Concurrency | Bursts of up to 10 concurrent requests |
| Auto-pause | After 400 consecutive failures and 24 hours since the last success |
| Ordering | Not guaranteed |

Dwolla does not support IP allowlisting. It says it "uses a highly dynamic and wide range of IP addresses, meaning that your application cannot use IP whitelisting to authenticate webhook requests". The signature is the only check.

## Common events

Dwolla documents 75 topics. Topics for your Master Account are bare (`transfer_created`), and topics about your Customers are prefixed with `customer_` (`customer_transfer_created`).

| Area | Example topics | Fires when |
| --- | --- | --- |
| Customers | `customer_created`, `customer_verified`, `customer_suspended`, `customer_activated`, `customer_deactivated` | A Customer is created or its status changes |
| Verification | `customer_verification_document_needed`, `customer_verification_document_approved`, `customer_reverification_needed`, `customer_kba_verification_passed` | Identity verification needs documents, retries or KBA |
| Beneficial owners | `customer_beneficial_owner_created`, `customer_beneficial_owner_verified` | A business Customer's beneficial owner is added or verified |
| Funding sources | `customer_funding_source_added`, `customer_funding_source_verified`, `customer_funding_source_removed`, `customer_funding_source_negative` | A bank account is added, verified or removed, or a balance goes negative |
| Micro-deposits | `customer_microdeposits_added`, `customer_microdeposits_completed`, `customer_microdeposits_failed`, `customer_microdeposits_maxattempts` | Micro-deposit verification progresses or fails |
| Transfers | `customer_transfer_created`, `customer_transfer_completed`, `customer_transfer_failed`, `customer_transfer_cancelled` | A Customer transfer is created or reaches a final state |
| Bank transfers | `customer_bank_transfer_created`, `customer_bank_transfer_completed`, `customer_bank_transfer_failed` | The bank-side leg of a transfer progresses |
| Mass payments | `customer_mass_payment_created`, `customer_mass_payment_completed` | A mass payment is created or finishes |
| Master Account | `transfer_created`, `transfer_completed`, `funding_source_added`, `statement_created`, `account_suspended` | The same changes on your own account |

One API call can produce several events. Dwolla's example is a transfer from your Account to a Customer, which creates both `transfer_created` and `customer_transfer_created`. A transfer between two Customers produces a `customer_transfer_created` for each side, with different event IDs and the same resource ID.

Every body has the same shape. Here is Dwolla's `customer_created` example:

```json
{
  "_links": {
    "account": {
      "href": "https://api-sandbox.dwolla.com/accounts/0ee84069-47c5-455c-b425-633523291dc3",
      "resource-type": "account",
      "type": "application/vnd.dwolla.v1.hal+json"
    },
    "customer": {
      "href": "https://api-sandbox.dwolla.com/customers/a6f09251-c2de-4833-94a8-5c70916cebbc",
      "resource-type": "customer",
      "type": "application/vnd.dwolla.v1.hal+json"
    },
    "resource": {
      "href": "https://api-sandbox.dwolla.com/customers/a6f09251-c2de-4833-94a8-5c70916cebbc",
      "type": "application/vnd.dwolla.v1.hal+json"
    },
    "self": {
      "href": "https://api-sandbox.dwolla.com/events/29a82d20-a703-41cb-9b3c-bd409c499925",
      "resource-type": "event",
      "type": "application/vnd.dwolla.v1.hal+json"
    }
  },
  "created": "2019-05-30T18:21:11.490Z",
  "id": "29a82d20-a703-41cb-9b3c-bd409c499925",
  "resourceId": "a6f09251-c2de-4833-94a8-5c70916cebbc",
  "topic": "customer_created"
}

```

`id` is the event ID, `resourceId` the thing that changed and `topic` the event type. Transfer events also carry a `correlationId` if you set one when creating the transfer. An older example on Dwolla's working-with-webhooks page names the timestamp `timestamp` instead of `created`, so read both if you depend on it.

## Setting up Dwolla webhooks

1. Generate a random secret, for example with `openssl rand -hex 32`, and store it server-side as `DWOLLA_WEBHOOK_SECRET`. Dwolla never generates or shows a secret, so this is the only copy.
2. Get an API access token for your application.
3. Create the subscription:

```bash
curl -X POST "https://api-sandbox.dwolla.com/webhook-subscriptions" \
  -H "Accept: application/vnd.dwolla.v1.hal+json" \
  -H "Content-Type: application/vnd.dwolla.v1.hal+json" \
  -H "Authorization: Bearer $DWOLLA_ACCESS_TOKEN" \
  -d '{"url":"https://example.com/webhooks/dwolla","secret":"'"$DWOLLA_WEBHOOK_SECRET"'"}'

```

Dwolla answers `201 Created` with an empty body and a `Location` header pointing at the new subscription. Use `https://api.dwolla.com` in Production. Your endpoint should use HTTPS with a valid certificate.

For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 dwolla --path /webhooks/dwolla`) gives you a public HTTPS URL that forwards to your local server, with no account required. Subscribe that URL in Sandbox, then create a Customer to trigger `customer_created`.

## Securing Dwolla webhooks

Read `X-Request-Signature-SHA-256`, compute an HMAC-SHA256 of the raw request body with your subscription secret as the key, encode it as hex and compare in constant time. Dwolla stresses that the body "is already JSON-encoded prior to being sent. As such, the JSON body should never be re-encoded!" Parsing and re-serialising the JSON before verifying will break the signature, even for genuine requests.

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

function verifyDwollaSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/dwolla', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyDwollaSignature(req.body, req.get('x-request-signature-sha-256'), process.env.DWOLLA_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Store event.id, queue the work, then answer. Duplicates must still get a 2xx.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

def verify_dwolla_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_dwolla_signature(raw_body, request.headers.get("x-request-signature-sha-256"),
#                         os.environ["DWOLLA_WEBHOOK_SECRET"])

```

To check your verifier, use the secret `dwolla-test-secret` and this exact body:

```
{"id":"29a82d20-a703-41cb-9b3c-bd409c499925","resourceId":"a6f09251-c2de-4833-94a8-5c70916cebbc","topic":"customer_created","created":"2019-05-30T18:21:11.490Z"}

```

The signature should be:

```
d1e846eca5671f9b4db9553305d66da656abf1d9cd64606e2b2971a6885421c7

```

Dwolla also points out that because the endpoint needs the request body to authenticate, services that only see headers, such as AWS Lambda authorizers, cannot verify a Dwolla webhook.

> **Make Dwolla webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `X-Request-Signature-SHA-256` with your subscription secret, absorbs Dwolla's bursts of concurrent requests, and durably queues every event before your service sees it.

## Dwolla webhook limitations and pain points

### A paused subscription stops everything

**The Problem:** After an outage, events stop arriving altogether, and the ones you missed are not sent again when you fix your endpoint.

**Why It Happens:** Dwolla pauses a subscription after "400 consecutive failures and 24 hours since the last success", and emails the account Admin. While it is paused nothing is delivered, and Dwolla's FAQ is explicit that it "will not attempt to re-deliver webhooks" that failed while the subscription was paused or unavailable.

**Workarounds:**

* Unpause the subscription from the Dashboard or with the update-subscription endpoint as soon as your endpoint is healthy.
* List the webhooks for the subscription, find the ones with an empty `attempts` array, and retry each one with the retry-a-webhook endpoint.
* Reconcile against Dwolla's list-events endpoint for the outage window, and alert on the pause email.

**How Hookdeck Can Help:** Hookdeck acknowledges every delivery as soon as it arrives and stores it, so your downtime never counts as a Dwolla failure. Events wait in Hookdeck and are delivered with [retries](/docs/retries) when your service is back, and any of them can be replayed.

### Responding with an error to a duplicate

**The Problem:** Duplicate deliveries rejected with `409 Conflict` push the subscription towards being paused.

**Why It Happens:** Dwolla tries to send each webhook once but says the same one may arrive more than once. Any non-2xx counts as a failure and is retried, so treating duplicates as errors turns harmless repeats into a failure streak. Dwolla's own guidance is that a duplicate must still get a 200-level status.

**Workarounds:**

* Deduplicate on the event `id`, not on `resourceId` or `topic`. The sender and receiver of a transfer each get a `customer_transfer_created` with the same resource ID and different event IDs.
* Return `200` for any event you have already processed.

**How Hookdeck Can Help:** Hookdeck [deduplication](/docs/deduplication) can drop repeated deliveries with the same `id` before they reach your service.

### Bursts of concurrent requests

**The Problem:** A batch of transfers or a mass payment produces a spike of webhooks, and your handler slows down, times out and starts collecting retries.

**Why It Happens:** Dwolla may deliver "bursts of 10 concurrent webhook requests" when many events are created in a short time, and expects each answered within 10 seconds. Handlers that call the Dwolla API before responding fall behind fast.

**Workarounds:**

* Make the handler do nothing but verify, store and queue, as Dwolla recommends.
* Make sure the endpoint can handle at least 10 concurrent requests.
* If you need a lower rate, Dwolla says developer support can adjust it.

**How Hookdeck Can Help:** Hookdeck takes the burst at its own edge and delivers to your service at a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) you set.

### Events arrive out of order

**The Problem:** `customer_transfer_completed` is processed before `customer_transfer_created`, and your state machine rejects it.

**Why It Happens:** Dwolla says webhooks "are not guaranteed to be delivered in order", and retries on an 8-step schedule that reaches 72 hours mix old events in with new ones.

**Workarounds:**

* Treat each webhook as a prompt to fetch the resource from `_links.resource.href` and act on its current status, not on the topic alone.
* Compare `created` timestamps before moving a record backwards.

**How Hookdeck Can Help:** Hookdeck logs every delivery with its headers and body, so you can see the order events actually arrived in and replay them in the right order after a fix.

## Best practices

### Verify against the raw body

Mount `express.raw()` on the route, read `await request.body()` in FastAPI, or use `Buffer.from(await request.arrayBuffer())` in a Next.js route handler. Parse only after verifying, and never re-encode.

### Route on `topic`

The same value is in the body and in `X-Dwolla-Topic`. Prefer the body, because it is inside the signature. Keep a default branch that logs and acknowledges topics you do not handle, since Dwolla can add topics.

### Fetch the resource before acting

The payload tells you which resource changed. For transfers and Customers, read the resource from the API and act on its current status, which also protects you from out-of-order delivery.

### Deduplicate on the event ID

Store each event `id` before processing and skip ones you have seen, still returning `200`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Dwolla's recommended handler is ingest, authenticate, queue and process. Return a 2xx within 10 seconds and do the rest in a worker. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Use a second subscription for redundancy, carefully

Dwolla allows several subscriptions "for redundancy". Each one receives every event, so two subscriptions pointing at the same service means every event arrives twice. Deduplicate on `id` if you do this.

## Conclusion

Dwolla signs every webhook with a hex HMAC-SHA256 of the raw body in `X-Request-Signature-SHA-256`, using the secret you chose when you subscribed. Verify against the unmodified bytes, route on `topic`, deduplicate on the event `id`, fetch the resource before acting, and answer within 10 seconds so the subscription is never paused.

[Hookdeck Event Gateway](https://hookdeck.com) verifies each Dwolla signature, absorbs bursts and holds every event until your service has processed it, so an outage on your side does not pause the subscription or lose payment updates.

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