# Handle webhooks end to end

> Register a Moov webhook endpoint, verify signatures, respond fast, dedupe events by ID, react to transfer statuses, and test it.

> AI agents: search and read all Moov docs with the docs MCP server at https://docs.moov.io/mcp (no API key). Setup: https://docs.moov.io/guides/developer-tools/mcp-ai/docs-mcp/index.md

Build a webhook handler that only trusts events from Moov, never processes the same event twice, and keeps your records in step with every transfer.

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](/guides/webhooks/check-webhook-signatures/index.md).

## Prerequisites

- Everything in [Before you start](/guides/get-started/recipes/index.md#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 `admin` or `developer` permissions, 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](/guides/webhooks/webhook-events/index.md).

### Register the endpoint in the Dashboard

In the Dashboard, go to **Developers &gt; 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](/guides/webhooks/set-up-webhooks/index.md).

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

```javascript
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](/guides/webhooks/check-webhook-signatures/index.md), 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.

```javascript
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](/guides/webhooks/set-up-webhooks/index.md#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.

```javascript
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](/guides/money-movement/idempotency/index.md).

### React to transfer status changes

When `transfer.updated` arrives, fetch the transfer with the get transfer [endpoint](/api/money-movement/transfers/get/index.md). The response has the overall `status`, any `failureReason`, and rail details such as `source.cardDetails` or `source.achDetails`.

```zsh
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](/guides/money-movement/events-and-statuses/index.md) and [rail-specific transfer statuses](/guides/webhooks/webhook-events/index.md#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](/api/money-movement/transfers/list/index.md) and compare their statuses to your records:

```zsh
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 &gt; 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](/guides/get-started/api-authentication/test-mode/index.md) trigger amounts to produce failures, such as a [declined card](/guides/get-started/api-authentication/test-mode/index.md#declined-card-transfers) or an [ACH return](/guides/get-started/api-authentication/test-mode/index.md#ach-returns) (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 &gt; 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](/guides/get-started/recipes/refunds-cancellations/index.md)                       |

## Next steps

- [Webhook events](/guides/webhooks/webhook-events/index.md): See every event type and its payload.
- [Refund, reverse, or cancel a payment](/guides/get-started/recipes/refunds-cancellations/index.md): Return funds and track the outcome with webhooks.
- [Accept a card payment](/guides/get-started/recipes/accept-card-payment/index.md): Generate the transfer events this handler processes.
- [Webhook handler example](https://github.com/moovfinancial/webhook-handler): Fork Moov's example webhook handler project on GitHub.

