Guide to NMI Webhooks: Features and Best Practices
NMI (Network Merchants) webhooks notify your systems about payment activity: a sale succeeds, a refund is processed, an auth is captured or voided. If you're building payments on NMI, webhooks are how you react to transaction events without polling.
This guide covers how NMI webhooks work, the events you'll handle, how to verify the Webhook-Signature (with one gotcha that trips most people), and the best practices for production.
What are NMI webhooks?
NMI webhooks are HTTP POSTs delivered to a URL you configure in the merchant control panel, each signed with a custom scheme. The Webhook-Signature header has the form t=<nonce>,s=<signature>.
The gotcha: t is a nonce, not a Unix timestamp. The signature is an HMAC over the nonce and the body, and because there's no timestamp, NMI's scheme has no replay window.
NMI webhook features
| Feature | Details |
|---|---|
| Configuration | Merchant control panel: Settings > Webhooks |
| Signature header | Webhook-Signature, formatted t=<nonce>,s=<signature> |
| Signature scheme | HMAC-SHA256 over <nonce>.<raw_body>, lowercase hex, keyed with the signing key |
t value | A nonce (not a timestamp) |
| Replay window | None (no timestamp in the signature) |
| Events | transaction.<action>.<result> (transaction lifecycle only) |
| Retries | Retries on failure; respond 2xx quickly to stop |
| SDK | None |
Common events
NMI event names are dotted lowercase: transaction.<action>.<result>, where action is sale, auth, capture, void, refund, credit, or validate, and result is success, failure, or unknown:
| Event | Fires when |
|---|---|
transaction.sale.success | A sale succeeds |
transaction.auth.success | An authorization succeeds |
transaction.capture.success | A capture succeeds |
transaction.void.success | A transaction is voided |
transaction.refund.success | A refund succeeds |
The documented catalog is transaction-lifecycle only, there are no distinct chargeback events. Branch on the exact transaction.<action>.<result> name.
Setting up NMI webhooks
Configure your webhook endpoint and copy the signing key from the merchant control panel under Settings > Webhooks. That signing key is the HMAC key.
Securing NMI webhooks
The Webhook-Signature header is t=<nonce>,s=<sig>. To verify, compute an HMAC-SHA256 over <nonce>.<raw_body> (the nonce, a literal dot, then the raw request body) using your signing key, lowercase-hex-encode it, and constant-time-compare against the s= value. Use the raw, unparsed body, re-serializing the JSON breaks the HMAC.
const crypto = require("crypto");
const SIGNING_KEY = process.env.NMI_SIGNING_KEY;
function verify(rawBody, signatureHeader) {
const params = Object.fromEntries(
(signatureHeader || "").split(",").map((kv) => kv.split("="))
);
const nonce = params.t; // this is a nonce, not a timestamp
const message = `${nonce}.${rawBody.toString()}`;
const expected = crypto.createHmac("sha256", SIGNING_KEY).update(message).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(params.s || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.headers["webhook-signature"])) {
return res.sendStatus(401);
}
res.sendStatus(200); // acknowledge fast
processQueue.add(JSON.parse(req.body)); // process asynchronously
});
The same check in Python:
import hashlib
import hmac
import os
SIGNING_KEY = os.environ["NMI_SIGNING_KEY"].encode()
def verify(raw_body: bytes, signature_header: str) -> bool:
params = dict(kv.split("=", 1) for kv in (signature_header or "").split(",") if "=" in kv)
message = (params.get("t", "") + ".").encode() + raw_body
expected = hmac.new(SIGNING_KEY, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, params.get("s", ""))
NMI webhook limitations and pain points
t is a nonce, so there's no replay window
The Problem: The t= value looks like a Stripe-style timestamp, but it's a nonce. Signing timestamp.body (assuming a timestamp) fails, and because there's no timestamp, you can't use a freshness window to reject replays.
Why It Happens: NMI binds a nonce (not a timestamp) into the signed message.
Workarounds:
- Sign
<nonce>.<raw_body>exactly, and add your own replay protection: dedupe on a transaction id so a replayed payload has no additional effect.
How Hookdeck Can Help: Hookdeck verifies the Webhook-Signature at the edge and deduplicates deliveries, filtering replays your app would otherwise have to guard against. See our guide to webhook idempotency.
Raw-body sensitivity
The Problem: The HMAC is over the nonce plus the raw body. Middleware that parses and re-serializes the JSON changes the bytes and breaks verification.
Why It Happens: The signed content is the exact received body.
Workarounds:
- Capture the raw body (
php://input,express.raw,request.get_data()) and verify before parsing.
How Hookdeck Can Help: Hookdeck verifies against the bytes as received, so your app never wrestles with raw-body middleware.
No chargeback events
The Problem: The documented catalog is transaction-lifecycle only (transaction.<action>.<result>). There are no distinct chargeback events, so a handler waiting for one never fires.
Why It Happens: NMI's webhook catalog doesn't include chargeback events.
Workarounds:
- Handle the transaction events that exist, and reconcile chargebacks through other NMI reporting.
How Hookdeck Can Help: Hookdeck's observability shows exactly which events arrive, so you're not waiting on an event that doesn't exist.
Retries and duplicates
The Problem: NMI retries failed deliveries, so the same event can arrive more than once, and there's no replay window to lean on.
Why It Happens: At-least-once delivery favors eventual delivery.
Workarounds:
- Respond 2xx fast to stop retries, and dedupe on a transaction id.
How Hookdeck Can Help: Hookdeck deduplicates deliveries at the edge, so retries don't double-process.
Best practices
Verify over <nonce>.<body> with lowercase hex
Compute HMAC-SHA256 over <nonce>.<raw_body> with your signing key, lowercase-hex-encode, and compare against s= in constant time. Remember t is a nonce.
Add your own replay protection
Since there's no timestamp, dedupe on a transaction id so replays are no-ops.
Acknowledge fast, process asynchronously
Return 2xx quickly and defer work to a queue. See why to process webhooks asynchronously.
Confirm before fulfilling
For payment events, confirm the transaction via the API before releasing value.
Make NMI webhooks production-ready
Hookdeck verifies Webhook-Signature, deduplicates, and durably queues every transaction event
Conclusion
NMI webhooks are verified with a Webhook-Signature HMAC-SHA256 over <nonce>.<raw_body> (lowercase hex), where the t= value is a nonce, not a timestamp, so there's no replay window and you add your own via deduplication. Verify against the raw body, use the exact transaction.<action>.<result> names, and note there are no chargeback events.
Hookdeck verifies the signature, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique transaction events.
Get started with Hookdeck for free and handle NMI webhooks reliably in minutes.