Gareth Wilson Gareth Wilson

Guide to NMI Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationMerchant control panel: Settings > Webhooks
Signature headerWebhook-Signature, formatted t=<nonce>,s=<signature>
Signature schemeHMAC-SHA256 over <nonce>.<raw_body>, lowercase hex, keyed with the signing key
t valueA nonce (not a timestamp)
Replay windowNone (no timestamp in the signature)
Eventstransaction.<action>.<result> (transaction lifecycle only)
RetriesRetries on failure; respond 2xx quickly to stop
SDKNone

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:

EventFires when
transaction.sale.successA sale succeeds
transaction.auth.successAn authorization succeeds
transaction.capture.successA capture succeeds
transaction.void.successA transaction is voided
transaction.refund.successA 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.