Gareth Wilson Gareth Wilson

Guide to Dwolla Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPOST /webhook-subscriptions with url and secret
SubscriptionsUp to five active in Production and ten in Sandbox
Subscription modelEvery event for the application. No topic filter
Signature headerX-Request-Signature-SHA-256
SchemeHMAC-SHA256, lowercase hex digest
Signed contentThe raw request body, nothing else
SecretChosen by you when you create the subscription
Topic headerX-Dwolla-Topic, the same value as the body's topic
Replay windowNone. No timestamp is signed
Response windowA 2xx within 10 seconds. 3xx and redirects count as failures
Retries8 retries over 72 hours
ConcurrencyBursts of up to 10 concurrent requests
Auto-pauseAfter 400 consecutive failures and 24 hours since the last success
OrderingNot 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).

AreaExample topicsFires when
Customerscustomer_created, customer_verified, customer_suspended, customer_activated, customer_deactivatedA Customer is created or its status changes
Verificationcustomer_verification_document_needed, customer_verification_document_approved, customer_reverification_needed, customer_kba_verification_passedIdentity verification needs documents, retries or KBA
Beneficial ownerscustomer_beneficial_owner_created, customer_beneficial_owner_verifiedA business Customer's beneficial owner is added or verified
Funding sourcescustomer_funding_source_added, customer_funding_source_verified, customer_funding_source_removed, customer_funding_source_negativeA bank account is added, verified or removed, or a balance goes negative
Micro-depositscustomer_microdeposits_added, customer_microdeposits_completed, customer_microdeposits_failed, customer_microdeposits_maxattemptsMicro-deposit verification progresses or fails
Transferscustomer_transfer_created, customer_transfer_completed, customer_transfer_failed, customer_transfer_cancelledA Customer transfer is created or reaches a final state
Bank transferscustomer_bank_transfer_created, customer_bank_transfer_completed, customer_bank_transfer_failedThe bank-side leg of a transfer progresses
Mass paymentscustomer_mass_payment_created, customer_mass_payment_completedA mass payment is created or finishes
Master Accounttransfer_created, transfer_completed, funding_source_added, statement_created, account_suspendedThe 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:

{
  "_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:
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 (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.

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:

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

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.

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 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 for free and handle Dwolla 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.