Handle webhooks end to end
Webhooks tell your application when something changes in Moov, such as a transfer completing or a card payment being refunded, so you don't have to poll. In this recipe, you'll register an endpoint, verify each request's signature, acknowledge it quickly, hand it to a background worker, skip duplicates, and update your records from the transfer's current state. The handler examples use Node.js with Express, matching the JavaScript example in check webhook signatures.
Prerequisites
- Everything in Before you start: test mode API keys, authentication headers, your partner account ID, and a server you can deploy to
- Token scope for reading transfers:
/accounts/{accountID}/transfers.read, using your partner account ID - A public HTTPS URL for your endpoint. For local development, use a tunnel such as ngrok.
- A Dashboard user with
adminordeveloperpermissions, so you can manage webhooks under Developers
Steps
Choose the events you need
Subscribe only to the events your application acts on. Fewer events means less load on your server. For a payments integration, a typical starting set is:
| Event | Fires when |
|---|---|
transfer.created |
A transfer is created |
transfer.updated |
A transfer's status, or a rail-specific source or destination status, changes |
refund.created, refund.updated |
A card refund is created or its status changes |
cancellation.created, cancellation.updated |
A cancellation is created or its status changes |
dispute.created, dispute.updated |
A cardholder disputes a charge, or the dispute changes |
bankAccount.updated |
A linked bank account's status changes, for example after an ACH return |
Every event has the same envelope: an eventID, a type, a data object, and a createdOn timestamp. data is small; it carries IDs and a status, not the full resource. Deeper guide: webhook events.
Register the endpoint in the Dashboard
In the Dashboard, go to Developers > Webhooks and create a webhook. Enter your HTTPS URL, an optional description, and the event types from step 1. After you save it, copy the webhook's signing secret and store it as an environment variable, such as WEBHOOK_SECRET.
Test mode and production mode each have their own webhooks and signing secrets. When you go live, register the endpoint again in production mode and use the production secret. You can create more than one webhook if you want different event types to go to different services. Deeper guide: set up webhooks.
Verify the signature
Every delivery is an HTTP POST with four headers: X-Timestamp, X-Nonce, X-Webhook-ID, and X-Signature. To confirm Moov sent it, compute an HMAC-SHA512 of {X-Timestamp}|{X-Nonce}|{X-Webhook-ID} with your signing secret, hex-encode it, and compare it to X-Signature. If they don't match, reject the request.
const crypto = require("crypto");
const express = require("express");
const app = express();
const webhookSecret = process.env.WEBHOOK_SECRET;
function isSigned(req) {
// Node.js lowercases incoming header names
const timestamp = req.get("x-timestamp") || "";
const nonce = req.get("x-nonce") || "";
const webhookId = req.get("x-webhook-id") || "";
const signature = req.get("x-signature") || "";
const expected = crypto
.createHmac("sha512", webhookSecret)
.update(`${timestamp}|${nonce}|${webhookId}`)
.digest("hex");
return (
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
}
The signed string is built from headers only, so you can verify before you parse the body. Using timingSafeEqual avoids leaking information through comparison timing. Deeper guide: check webhook signatures, which also has a Go example.
Respond fast and process asynchronously
Moov waits 5 seconds for a 2xx response. If it doesn't get one, the delivery counts as failed and Moov retries it. Keep the handler small: verify, enqueue, and return 200. Do the real work, such as database writes, API calls, and emails, in a background worker.
app.post("/webhooks/moov", express.json(), async (req, res) => {
if (!isSigned(req)) {
return res.status(400).send("Signature is invalid");
}
// Hand off to your job queue (for example, SQS, BullMQ, or a jobs table)
await queue.add("moov-event", req.body);
res.sendStatus(200);
});
app.listen(3000);
If your server is overloaded, return 429 with a Retry-After header (in seconds). Moov waits that long before retrying. Deeper guide: retries and rate limiting.
Process each event once
Moov retries failed deliveries, so your worker can receive the same event more than once. Use the eventID in the body as an idempotency key: record it when you start processing, and skip any event you've already handled.
async function processEvent(event) {
// Insert fails if eventID already exists (unique constraint)
const isNew = await db.processedEvents.insertIfAbsent({
eventID: event.eventID,
type: event.type,
receivedOn: new Date(),
});
if (!isNew) return;
switch (event.type) {
case "transfer.updated":
return syncTransfer(event.data.accountID, event.data.transferID);
case "refund.updated":
return syncTransfer(event.data.accountID, event.data.transferID);
case "event.test":
return console.log("Test webhook received");
}
}
Don't rely on events arriving in order. Treat an event as a signal that something changed, then read the resource's current state, as the next step shows. When your handler calls Moov to create something, such as a transfer or refund, send an X-Idempotency-Key derived from the eventID so a retried event can't create a duplicate. Deeper guide: idempotency.
React to transfer status changes
When transfer.updated arrives, fetch the transfer with the get transfer endpoint. The response has the overall status, any failureReason, and rail details such as source.cardDetails or source.achDetails.
curl -X GET "https://api.moov.io/accounts/{partnerAccountID}/transfers/{transferID}" \
-H "Authorization: Bearer {token}" \
-H "X-Moov-Version: v2026.07.00"
Map the overall status to your own order or payout state:
status |
What it means | Typical action |
|---|---|---|
created, queued, pending |
The transfer is in progress. Rail-specific updates, such as a card payment becoming confirmed, also fire transfer.updated while it's pending. |
Show "processing"; for cards, fulfill once source.cardDetails.status is confirmed |
completed |
Funds reached the destination | Mark paid; release funds or goods |
failed |
The transfer didn't succeed; no money moved | Read failureReason; ask for a new payment method or retry with a new idempotency key |
canceled |
The transfer was canceled before it settled | Mark canceled; nothing to refund |
reversed |
The transfer completed, then funds were returned, for example after an ACH return | Reverse the order or payout; check the return code and the bank account status |
Deeper guides: events and statuses and rail-specific transfer statuses.
Plan for retries and outages
If your endpoint doesn't return a 2xx within 5 seconds, Moov retries the delivery multiple times, backing off, for up to 24 hours. If your server still returns 404 after the last retry, Moov disables the webhook. If you return 429 without a Retry-After header, Moov uses its default backoff.
After an outage longer than the retry window, reconcile instead of waiting for events. Page through recent transfers with the list transfers endpoint and compare their statuses to your records:
curl -X GET "https://api.moov.io/accounts/{partnerAccountID}/transfers?startDateTime=2026-11-01T00:00:00Z&count=200" \
-H "Authorization: Bearer {token}" \
-H "X-Moov-Version: v2026.07.00"
Use skip to page past the first 200 results, and filter with status if you only need, for example, reversed transfers.
Test in test mode
In the Dashboard, open your webhook under Developers > Webhooks and select Send test webhook. Your endpoint receives an event.test payload with "data": { "ping": true }. Use it to check that signature verification passes and the handler returns 200.
Then generate real events. Create a transfer in test mode and watch transfer.created and transfer.updated arrive. Use the test mode trigger amounts to produce failures, such as a declined card or an ACH return (for example, 5501 for R01), and confirm your handler moves your records to failed or reversed. To test duplicates, send the same payload to your worker twice and confirm the second one is skipped.
What to watch for
| Signal | What it means | What to do |
|---|---|---|
Your endpoint returns 400 for real events |
Signature check fails | Confirm you're using the signing secret for this webhook and mode, and the exact timestamp|nonce|webhookID string |
| Moov retries events you already handled | Your handler took longer than 5 seconds or returned a non-2xx |
Acknowledge first, then process in a background worker |
The same eventID arrives twice |
A retry after a slow or failed response | Skip it; your eventID record makes processing idempotent |
| Events stop arriving | The webhook was disabled after repeated 404 responses, or the URL changed |
Check the webhook in Developers > Webhooks, fix the endpoint, then reconcile with the list transfers endpoint |
transfer.updated with status reversed |
Funds were returned after the transfer completed | Undo fulfillment or payout; read the return details on the transfer |
refund.updated or cancellation.updated with status failed |
The refund or cancellation didn't go through | See the refunds and cancellations recipe |