# Guide to Mercury Webhooks: Features and Best Practices

Mercury webhooks tell your systems when money moves: a transaction is created, a pending payment posts, or the balance of a checking, savings, treasury, credit or investment account changes. If your business banks with Mercury, webhooks are how those changes reach your accounting, cash management and reconciliation tools as they happen, instead of through repeated calls to the API.

This guide covers how Mercury webhook endpoints are created and signed, how to read the change events they deliver, the retry and disabling rules, and the practices that keep a Mercury receiver reliable.

## What are Mercury webhooks?

Mercury's Webhooks API pushes the same change events as its Events API. When a resource in your account changes, Mercury sends an HTTP `POST` describing the change to each webhook endpoint that is subscribed to it.

You create endpoints through the API, not the dashboard, and each one can be narrowed to particular event types and particular fields. Mercury returns the endpoint's signing secret once, in the response to the create call, and never again.

Every request is signed in the `Mercury-Signature` header, which looks like `t=<timestamp>,v1=<signature>`. The signature is a hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the secret.

One detail catches out most new integrations. The event types you subscribe to, such as `transaction.created`, never appear in the body. Each delivery describes a change with `resourceType` and `operationType` instead, so your handler routes on those.

## Mercury webhook features

| Feature | Details |
| --- | --- |
| Configuration | `POST https://api.mercury.com/api/v1/webhooks` |
| Limits | Up to 100 endpoints per organisation. Not available in the sandbox |
| Filters | `eventTypes` (all if omitted) and `filterPaths` (only send when these fields change) |
| Endpoint status | `active`, `paused`, or `disabled` by Mercury after repeated failures |
| Payload | Events API structure, JSON Merge Patch (RFC 7396) |
| Event identity | `resourceType` and `operationType` in the body |
| Signature header | `Mercury-Signature: t=<unix seconds>,v1=<hex>` |
| Scheme | HMAC-SHA256, lowercase hex |
| Signed content | `<t>.<raw body>` |
| Secret | Returned only when the endpoint is created. Used as-is |
| Replay window | Mercury recommends 5 minutes. You enforce it, if at all |
| Response window | 5 seconds |
| Retries | Up to 10, exponential backoff over about a day. No retry on `4xx` except `429` |
| Delivery | At least once |
| Testing | `VerifyWebhook` sends a test event |

Mercury publishes no source IP list for webhooks.

## Common events

Mercury supports seven event types for subscriptions:

| Event type | Fires when |
| --- | --- |
| `transaction.created` | A new transaction is created in your account |
| `transaction.updated` | An existing transaction is modified |
| `checkingAccount.balance.updated` | The available, current or in-flight balance of a checking account changes |
| `savingsAccount.balance.updated` | The same, for a savings account |
| `treasuryAccount.balance.updated` | The same, for a treasury account |
| `creditAccount.balance.updated` | The same, for a credit account |
| `investmentAccount.balance.updated` | The same, for an investment account |

These names are filters. The delivery itself is an Events API event, like this one for a transaction that has just posted:

```json
{
  "id": "bfa85eaa-afab-11f0-8fea-17d650f2306e",
  "resourceType": "transaction",
  "resourceId": "1d3042b6-af63-11f0-89d2-3503f2fcfef7",
  "operationType": "update",
  "resourceVersion": 2,
  "occurredAt": "2025-01-01T00:00:00.000000Z",
  "changedPaths": ["status", "postedAt"],
  "mergePatch": {
    "postedAt": "2025-01-01T00:00:00.000000+00:00",
    "status": "sent"
  },
  "previousValues": {
    "postedAt": null,
    "status": "pending"
  }
}

```

`resourceType` names the kind of resource and `operationType` is `create`, `update` or `delete`. `changedPaths` lists the fields that changed, `mergePatch` holds their new values as a JSON Merge Patch, and `previousValues` holds the old ones. `resourceVersion` is the resource's own revision counter. `id` identifies the event and is your deduplication key.

So a `transaction.updated` subscription delivers `resourceType: "transaction"` and `operationType: "update"`. Mercury's documentation doesn't show a balance event, so the `resourceType` those carry isn't documented. Log the first few you receive before you write routing rules for them.

A merge patch only contains what changed. To work with the whole transaction, apply the patch to your stored copy, or fetch the transaction from the API.

## Setting up Mercury webhooks

Webhooks are only available in production, so you need a production API token.

1. Create the endpoint with `POST /webhooks`, as below, and save the `secret` from the response straight away. Mercury won't return it again.
2. Store the secret server-side, for example as `MERCURY_WEBHOOK_SECRET`.
3. Call `VerifyWebhook` on the new endpoint to send a test event. It sends `transaction.created` unless you ask for another type.
4. Add `filterPaths`, such as `["status"]`, if you only care about some fields. Mercury then sends an event only when one of them changes.

```bash
curl -X POST "https://api.mercury.com/api/v1/webhooks" \
  -H "Authorization: Bearer $MERCURY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/webhooks/mercury","eventTypes":["transaction.created","transaction.updated"]}'

```

If you lose the secret, you can't read it back. Create a new endpoint and delete the old one.

If you use Hookdeck, create a Mercury source, set the secret as its **Webhook Signing Secret**, and use the source URL as the endpoint `url`. Hookdeck reads `t` and `v1` from `Mercury-Signature`, verifies the HMAC, and applies no timestamp window, so retries and replays of old events still verify.

## Securing Mercury webhooks

Split `Mercury-Signature` into `t` and `v1`. Build the signed string from `t`, a dot and the raw body, compute an HMAC-SHA256 with the secret as the key, encode it as hex, and compare it with `v1` in constant time. The secret is used as a plain string, not decoded.

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

function parseMercurySignature(header) {
  const parts = {};
  for (const item of (header || '').split(',')) {
    const index = item.indexOf('=');
    if (index > 0) parts[item.slice(0, index).trim()] = item.slice(index + 1).trim();
  }
  return parts;
}

function verifyMercurySignature(rawBody, signatureHeader, secret) {
  const { t, v1 } = parseMercurySignature(signatureHeader);
  if (!t || !v1 || !secret) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.`)
    .update(rawBody)
    .digest('hex');

  const received = Buffer.from(v1.toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/mercury', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.MERCURY_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const header = req.get('mercury-signature');
  if (!verifyMercurySignature(req.body, header, secret)) {
    return res.sendStatus(401);
  }

  // Optional replay check, off by default because retries keep the original t:
  // const { t } = parseMercurySignature(header);
  // if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, route on event.resourceType + event.operationType.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_mercury_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    parts = {}
    for item in signature_header.split(","):
        key, sep, value = item.partition("=")
        if sep:
            parts[key.strip()] = value.strip()
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature:
        return False
    signed = timestamp.encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_mercury_signature(raw_body, request.headers.get("mercury-signature"),
#                          os.environ["MERCURY_WEBHOOK_SECRET"])

```

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

```
{"id":"bfa85eaa-afab-11f0-8fea-17d650f2306e","resourceType":"transaction","operationType":"update"}

```

The header should be:

```
t=1735689600,v1=383849c451c7c8c6bac6e0590f19e306439d49fb962b25621bfe3b557097ee9d

```

Mercury recommends rejecting webhooks with timestamps older than 5 minutes. That check is yours to make, and it is commented out above for a reason: a retry from Mercury, or a replay from Hookdeck, carries the original `t`. Deduplicating on `id` protects against replays without rejecting legitimate late deliveries.

> **Make Mercury webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Mercury-Signature` header, answers Mercury inside its 5-second window, and durably queues every transaction and balance event before your service sees it.

## Mercury webhook limitations and pain points

### The secret is shown once

**The Problem:** Months later, a new service needs the signing secret, and nobody has it.

**Why It Happens:** Mercury returns the secret only in the create response. `GET` and update calls never include it.

**Workarounds:**

* Write the secret to your secret manager in the same script that creates the endpoint.
* If it's lost, create a new endpoint, switch your receiver over, and delete the old one.

**How Hookdeck Can Help:** With a Hookdeck source in front, the secret only has to live in one place. Your own services receive verified events from Hookdeck and never need Mercury's secret.

### Subscription names don't match the payload

**The Problem:** A handler that switches on `transaction.created` never matches anything.

**Why It Happens:** `eventTypes` filters what is sent, but the body carries the Events API fields `resourceType` and `operationType`. The verbs differ too: `create` in the body, `created` in the subscription. Balance events' `resourceType` values aren't documented.

**Workarounds:**

* Route on `resourceType` and `operationType`.
* Log the first deliveries for each subscribed balance type and write rules from what you see.
* Treat unknown combinations as "log and acknowledge".

**How Hookdeck Can Help:** Hookdeck filters can route on `resourceType` and `operationType`, so transactions and balance changes can go to different destinations.

### Some failures are never retried

**The Problem:** An event disappears after one failed attempt.

**Why It Happens:** Mercury retries no response and most error statuses up to 10 times over about a day, but it doesn't retry a `4xx` other than `429`. A `400` from a strict validator or a `401` from a misconfigured verifier loses that event.

**Workarounds:**

* Return `4xx` only for requests that really are invalid, such as a bad signature.
* Return `5xx` if your own dependencies are down, so Mercury tries again.
* Reconcile transactions through the API after any incident.

**How Hookdeck Can Help:** Hookdeck answers Mercury as soon as it accepts the request, then retries delivery to your service on whatever status codes you choose, so a bug in your handler doesn't throw events away.

### Endpoints are disabled automatically

**The Problem:** Events stop arriving after an outage.

**Why It Happens:** Mercury marks an endpoint `disabled` after consecutive delivery failures. It stays disabled until you set its `status` back to `active`.

**Workarounds:**

* Poll your endpoints and alert when one reports `disabled`.
* Reactivate it by updating `status` to `active`, then reconcile the gap through the API.

**How Hookdeck Can Help:** Hookdeck keeps answering Mercury while your service is down, so your outages don't count as Mercury delivery failures, and the events wait for you in Hookdeck.

## Best practices

### Verify against the raw body

Mercury warns that whitespace and key order differences break the signature. In Express, use `express.raw({ type: '*/*' })` and parse after verification. In Next.js, read `await request.text()`. In FastAPI, read `await request.body()`.

### Deduplicate on `id`

Delivery is at least once. Store each event `id` before processing and skip any you've seen. Mercury notes that this also protects against replayed requests. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Order by `resourceVersion`

Retries can arrive after later changes. Keep the last `resourceVersion` you applied for each resource and ignore events with a lower one.

### Filter at the source

Use `eventTypes` and `filterPaths` so Mercury only sends what you handle. A reconciliation job that cares about posting can subscribe to `transaction.updated` with `filterPaths: ["status"]`.

### Acknowledge fast and process asynchronously

Mercury waits 5 seconds. Verify, store the event, return `200`, then apply the patch or call the API. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Mercury signs every webhook with `Mercury-Signature: t=<timestamp>,v1=<hex>`, an HMAC-SHA256 of the timestamp and raw body keyed with a secret you only see once. Save that secret when you create the endpoint, verify against the raw bytes, route on `resourceType` and `operationType` rather than the subscription names, deduplicate on `id`, and respond within 5 seconds with a status that tells Mercury whether to retry.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the Mercury signature, and holds each event until your service has processed it, so an outage on your side doesn't disable the endpoint.

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