Gareth Wilson Gareth Wilson

Guide to Mercury Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPOST https://api.mercury.com/api/v1/webhooks
LimitsUp to 100 endpoints per organisation. Not available in the sandbox
FilterseventTypes (all if omitted) and filterPaths (only send when these fields change)
Endpoint statusactive, paused, or disabled by Mercury after repeated failures
PayloadEvents API structure, JSON Merge Patch (RFC 7396)
Event identityresourceType and operationType in the body
Signature headerMercury-Signature: t=<unix seconds>,v1=<hex>
SchemeHMAC-SHA256, lowercase hex
Signed content<t>.<raw body>
SecretReturned only when the endpoint is created. Used as-is
Replay windowMercury recommends 5 minutes. You enforce it, if at all
Response window5 seconds
RetriesUp to 10, exponential backoff over about a day. No retry on 4xx except 429
DeliveryAt least once
TestingVerifyWebhook sends a test event

Mercury publishes no source IP list for webhooks.

Common events

Mercury supports seven event types for subscriptions:

Event typeFires when
transaction.createdA new transaction is created in your account
transaction.updatedAn existing transaction is modified
checkingAccount.balance.updatedThe available, current or in-flight balance of a checking account changes
savingsAccount.balance.updatedThe same, for a savings account
treasuryAccount.balance.updatedThe same, for a treasury account
creditAccount.balance.updatedThe same, for a credit account
investmentAccount.balance.updatedThe 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:

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

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:

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

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.

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