Gareth Wilson Gareth Wilson

Guide to Nium Webhooks: Features and Best Practices

Published


Nium webhooks tell your platform what is happening to the money and cards you run through Nium: a payout was sent to the beneficiary's bank, a wallet was funded, a card transaction was approved or declined, an FX conversion completed, or Nium needs more information about a customer. If you build cross-border payments, payroll or card products on Nium, webhooks are how those status changes reach your systems without polling the API.

This guide covers how Nium delivers webhooks, how to identify event types across Nium's irregular naming, how to authenticate requests that Nium doesn't sign, the at-most-once delivery default and how to change it, and the best practices for a production receiver.

What are Nium webhooks?

Nium sends webhooks from the Nium One platform to a single endpoint you give them, in the form https://<customerHost:Port>/webhook. Every event goes to that URL as a POST with a JSON body. Setup isn't self-serve: you provide the URL to Nium, and the delivery options are configured as part of your client setup.

The event type is in the body. For almost every event, it's the template field, for example CARD_POS_APPROVED_WEBHOOK. Request for Information (RFI) webhooks are the exception: they have no template, and their type is rfiDetails.status.

Each body is a flat object with fields specific to the event, and Nium says it "generally carries just enough details about the event". You're expected to call Nium's APIs for anything more.

Nium doesn't sign webhooks. Its documented authentication is a static identifier header, for example x-partner-key, which you agree with Nium during client setup and which Nium then includes on every webhook. Your receiver compares that header with the value you stored.

Nium webhook features

FeatureDetails
ConfigurationOne client webhook URL, provided to Nium and set up as part of client configuration
MethodPOST with content-type: application/json
Event typetemplate in the body, or rfiDetails.status for RFI webhooks
Documented events79 template values and 3 RFI statuses
Delivery IDx-request-id header, unique per event and repeated on redelivery
AuthenticationA static identifier header such as x-partner-key, configured at client setup
SignatureNone
SuccessHTTP 200. Any other status, or a timeout, is a failure
Default deliveryAt most once. Failures are not retried
Optional retriesEnabled per event type with Nium: up to 20 retries, one every 5 minutes
Source IPsNot published for webhooks

Common events

Nium groups its webhooks into platform, payout, payin, issuing and card, and RFI events. A selection:

EventFires when
REMIT_TRANSACTION_INITIATED_WEBHOOKA transfer is created
REMIT_TRANSACTION_SENT_TO_BANK_WEBHOOKA payout is sent to a partner bank
REMIT_TRANSACTION_PAID_WEBHOOKA remittance transaction is paid
REMIT_TRANSACTION_REJECTED_WEBHOOKA payout request made through the Transfers API fails
REMIT_TRANSACTION_RETURNED_WEBHOOKA remittance transaction is returned
BENEFICIARY_VERIFICATION_STATUSNium has verified a beneficiary's bank account details
CARD_WALLET_FUNDING_WEBHOOKA wallet is funded
FX_CONVERSION_COMPLETED_WEBHOOKAn FX conversion between two currencies completes
CUSTOMER_STATUS_WEBHOOKA customer's status or sub-status changes
CARD_POS_APPROVED_WEBHOOKA point-of-sale card transaction is approved
CARD_INSUFFICIENT_FUNDS_DECLINED_WEBHOOKA card transaction is declined for insufficient funds
RFI_REQUESTEDNium raises a Request for Information and is waiting for a response

Don't assume a naming pattern. Most template values end in _WEBHOOK, but CUSTOMER_COMPLIANCE_STATUS, BENEFICIARY_VERIFICATION_STATUS and the DIRECT_DEBIT_* funding instrument and micro-deposit events don't, and CARD_3DS_Failed_Authentication_WEBHOOK is mixed case. Treat each value as an opaque string and match it exactly.

A payout status update looks like this (trimmed):

{
  "clientHashId": "0498f10f-1968-494f-9f7a-454ed23942a0",
  "customerHashId": "0de1e512-e0d7-4eca-ad41-dd39325facc2",
  "walletHashId": "e83cca77-8b63-4a25-b580-1d872380ef29",
  "transactionCurrency": "INR",
  "transactionAmount": "5418.1700",
  "systemReferenceNumber": "RT1343085439",
  "billingCurrency": "SGD",
  "billingAmount": "100.0000",
  "payoutMethod": "LOCAL",
  "template": "REMIT_TRANSACTION_SENT_TO_BANK_WEBHOOK",
  "externalId": "Custom1245"
}

Amounts are strings, so parse them with a decimal type rather than a float. RFI webhooks share one shape instead: clientHashId, customerHashId and an rfiDetails object with rfiId, status, query, rfiExpiry and related fields.

Nium's own examples aren't always reliable. Several, including the overview's CARD_POS_APPROVED_WEBHOOK example, are missing commas or contain placeholders, so they aren't valid JSON. A few event pages, such as VTS Token, Remit Transaction Sub-status Update and Card Details Updated, show no usable template value at all. Build parsers from real deliveries where you can.

See Nium webhook payloads in action. Inspect and replay sample Nium webhook payloads in Hookdeck Inspect, with no account or setup required.

Setting up Nium webhooks

Because Nium configures webhooks during client setup, setup is a conversation with your Nium representative rather than a dashboard form.

  1. Generate a long random value for the identifier header (openssl rand -hex 32 works) and store it in your app's environment.
  2. Give Nium your HTTPS endpoint URL and ask them to include your identifier header, for example x-partner-key, on every webhook.
  3. Decide which events need retries and ask Nium to enable them. Without this, every event is delivered at most once.
  4. Confirm the exact header name and value Nium will send, since your receiver must match both.

For local development, use the Hookdeck CLI: hookdeck listen 3000 nium --path /webhook gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. Use it with a sandbox client.

With Hookdeck. Create a Nium source in Hookdeck, enter the Header Name you agreed with Nium (for example x-partner-key) and the Header Value, including any prefix, and give Nium the source URL as your webhook endpoint.

Securing Nium webhooks

Compare the identifier header with your stored value. Look the header up case-insensitively, compare the whole value in constant time, and check lengths first, because crypto.timingSafeEqual throws on buffers of different lengths. Return exactly 200 on success: Nium treats any other status as a failure.

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

const app = express();

function safeEqual(a, b) {
  const x = Buffer.from(String(a), 'utf8');
  const y = Buffer.from(String(b), 'utf8');
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyNiumRequest(headers) {
  const secret = process.env.NIUM_WEBHOOK_SECRET;
  if (!secret) return false; // fail closed when unconfigured
  const name = (process.env.NIUM_WEBHOOK_HEADER || 'x-partner-key').toLowerCase();
  const received = headers[name];
  return typeof received === 'string' && safeEqual(received, secret);
}

function eventType(body) {
  if (typeof body.template === 'string') return body.template;
  if (body.rfiDetails && typeof body.rfiDetails.status === 'string') return body.rfiDetails.status;
  return 'UNKNOWN';
}

app.post('/webhook', express.raw({ type: '*/*' }), async (req, res) => {
  if (!verifyNiumRequest(req.headers)) {
    return res.sendStatus(401);
  }

  let body;
  try {
    body = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  const requestId = req.headers['x-request-id'];
  // Persist before acknowledging: by default Nium never retries
  await saveEvent(requestId, eventType(body), body);

  res.sendStatus(200); // exactly 200, not 201 or 204
});

The same check in Python:

import hmac
import os


def verify_nium_request(headers) -> bool:
    secret = os.environ.get("NIUM_WEBHOOK_SECRET")
    if not secret:
        return False
    name = os.environ.get("NIUM_WEBHOOK_HEADER", "x-partner-key")
    received = headers.get(name)  # case-insensitive in Starlette and Flask
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))

A static header proves the sender knows the value. It doesn't protect the body from modification, and nothing prevents a captured request from being replayed, because Nium sends no signature or timestamp. For a payments integration, that matters: treat each webhook as a notification and confirm amounts and statuses through Nium's API before you move money or release goods. Keep the endpoint on HTTPS and agree a rotation process with Nium in case the value leaks.

Make Nium webhooks production-ready. Hookdeck Event Gateway checks your identifier header on every request, answers Nium with a 200 as soon as the event is stored, and retries delivery to your service, so the at-most-once default stops costing you events.

Nium webhook limitations and pain points

At most once by default

The Problem: If your endpoint times out or returns anything other than 200, the event is gone. A deploy, a crash or a 204 response loses a payout or card status update.

Why It Happens: Nium's default is to "deliver every webhook event at most once", with no retry on a timeout or a non-200 status. Retries exist, but only for the event types Nium enables for you, and they stop after 20 attempts five minutes apart, about 100 minutes in all.

Workarounds:

  • Ask Nium to enable retries for every event you depend on.
  • Return exactly 200, quickly, after storing the event.
  • Reconcile transaction and payout status from the Nium API on a schedule.

How Hookdeck Can Help: Hookdeck answers Nium with a 200 as soon as the request is stored, then delivers to your service with automatic retries on a schedule you control. Issues alert you when deliveries fail, and you can replay any stored event.

A static header and no signature

The Problem: A request with the right header value is indistinguishable from a real one, and its body can't be integrity-checked.

Why It Happens: Nium's documented authentication is the static identifier header configured at client setup.

Workarounds:

  • Use a long random value and compare it in constant time.
  • Verify important state through the Nium API before acting.
  • Keep the value out of logs and agree a rotation process with Nium.

How Hookdeck Can Help: Hookdeck's Nium source compares the header you name against the exact value you enter. A request without a matching header still gets a 200 response, but Hookdeck marks it as failed verification and creates no event, so it never reaches your destination. Every request is stored with its headers and body.

Two type fields and irregular names

The Problem: A handler that only reads template misses every RFI webhook, and pattern matching on _WEBHOOK misses several real events.

Why It Happens: RFI webhooks carry their type in rfiDetails.status, and template values don't follow one convention.

Workarounds:

  • Read template first, then fall back to rfiDetails.status.
  • Match exact strings from Nium's event pages, and log anything unrecognised.

How Hookdeck Can Help: Filters can match on template or rfiDetails.status to route payout, card and compliance events to different destinations.

Duplicates from retries

The Problem: With retries enabled, an event your service processed but didn't acknowledge in time arrives again.

Why It Happens: Every redelivery repeats the original attempt, with the same x-request-id.

Workarounds:

  • Store x-request-id and skip any value you've already processed, as Nium recommends.

How Hookdeck Can Help: Hookdeck deduplication can drop repeated deliveries based on the x-request-id header before they reach your handler.

Best practices

Store, then return exactly 200

Nium counts any status other than 200 as a failure. Persist the event, return 200, then process it in the background. See why to process webhooks asynchronously.

Deduplicate on x-request-id

It's unique per event and identical on every redelivery, which makes it the natural idempotency key. See our guide to webhook idempotency.

Treat events as notifications

The body carries "just enough details". Fetch the transaction, wallet or card from the API before taking any irreversible action, especially for payouts and refunds.

Use decimal types for amounts

Amounts and balances arrive as strings, sometimes with four decimal places. Parse them with a decimal library and keep the currency alongside.

Handle unknown events gracefully

Nium documents more than 80 event types, and new ones can appear. Store and acknowledge events you don't recognise rather than rejecting them, so a new event type never turns into a lost one.

Conclusion

Nium sends unsigned JSON POST requests to one client URL, identified by template or, for RFI webhooks, rfiDetails.status, and authenticated by a static header agreed during client setup. Delivery is at most once unless Nium enables retries for an event, success means exactly 200, and x-request-id is your deduplication key.

Hookdeck Event Gateway checks the identifier header, stores every event on arrival and retries delivery to your service, which turns Nium's at-most-once default into delivery you can rely on.

Get started with Hookdeck for free and handle Nium 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.