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

```javascript
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:

```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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Confirm before fulfilling

For payment events, confirm the transaction via the API before releasing value.

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle NMI webhooks reliably in minutes.