Skip to content
NexG ConciergeDevelopers

Webhooks: calling back to NexG

How a partner sends signed events to NexG, how signatures are checked, and how retries and duplicates are handled.

On this page

How it works

When something happens on your side that NexG needs to know, such as a guest's payment clearing at your bank, your system posts an event to NexG. Each event is signed with a secret only you and NexG hold, so NexG can prove it came from you and was not changed on the way.

  1. You and NexG share a webhook secret of at least 32 characters, generated on your side.
  2. You send each event as JSON, with a signature over its exact bytes.
  3. NexG checks the signature and its age at the door, then again inside the database, which is what decides. Only then does anything change.
  4. NexG records the event id once, and answers with a status that tells you whether to retry.

Where to send events

EndpointWho sendsStatus
POST nexgapp.com/api/payments/bank/webhookBanks: collection succeeded, failed, expired or cancelled.Sandbox today
GET nexgapp.com/api/payments/bank/webhookAnyone: checks the URL is there.Live

The bank webhook works with the NexG Sandbox Bank on NexG's test deployments. On the production site it answers 503 no_bank_configured until a real bank is connected. NexG's M-Pesa provider calls NexG too, but on its own contract, which is not open to partners.

An event

Each event has an id of your choosing that is unique and never changes between retries, a type, and the current state of the object in data.

Request
POST /api/payments/bank/webhook HTTP/1.1
Host: nexgapp.com
Content-Type: application/json
X-Bank-Signature: t=1794655243,v1=5f0c3a…(64 hex characters)
X-Bank-Event-Id: evt_Q1rM7kHf0b2vYp3n

{
  "id": "evt_Q1rM7kHf0b2vYp3n",
  "type": "collection.succeeded",
  "created_at": "2026-11-10T11:20:43.018Z",
  "livemode": false,
  "data": {
    "id": "col_IwUUEAPrDf55OFD0",
    "status": "succeeded",
    "merchant_reference": "NXP-261110-7FD3EEA0C8",
    "amount": 161625,
    "currency": "KES",
    "bank_reference": "SBXB2FACAB615",
    "failure_reason": null
  }
}

Every field NexG reads is listed on the endpoint's reference page. Amounts are cents, and must match what NexG asked for to the cent.

Signing

The signature goes in one header:

Header
X-Bank-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
  • t is the current time in whole seconds. Keep your clock synced (NTP): NexG refuses a signature more than five minutes from its own clock, so an old event cannot be replayed.
  • v1 is the HMAC-SHA256, with the shared secret as the key, of t, a dot, and the request body exactly as sent, in lowercase hex (64 characters).
  • Sign the bytes you send. Serialise the JSON once, sign that string, and send that string; re-serialising can reorder keys or spacing and break the signature.
  • Sign every delivery attempt afresh, with a new t. The body stays the same.
# Sign "<t>.<body>" with the shared secret, then send the same bytes.
body='{"id":"evt_Q1rM7kHf0b2vYp3n","type":"collection.succeeded","created_at":"2026-11-10T11:20:43.018Z","livemode":false,"data":{"id":"col_IwUUEAPrDf55OFD0","status":"succeeded","merchant_reference":"NXP-261110-7FD3EEA0C8","amount":161625,"currency":"KES","bank_reference":"SBXB2FACAB615"}}'
t=$(date +%s)
v1=$(printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "$BANK_WEBHOOK_SECRET" | sed 's/^.* //')

curl -X POST "https://nexgapp.com/api/payments/bank/webhook" \
  -H "Content-Type: application/json" \
  -H "X-Bank-Signature: t=$t,v1=$v1" \
  -H "X-Bank-Event-Id: evt_Q1rM7kHf0b2vYp3n" \
  --data "$body"

Verifying

NexG signs the events it sends in the same way: a bank adapter fetching the latest state of a collection verifies the answer with this code, and so will you, when NexG starts sending events to partners. Verify the raw body before parsing it, and compare in constant time.

import { createHmac, timingSafeEqual } from 'node:crypto';

/** True when header is a valid signature of rawBody, made in the last five minutes. */
export function verifySignature(rawBody, header, secret, now = Date.now()) {
  const m = /^t=(\d{9,12}),v1=([0-9a-f]{64})$/.exec(header ?? '');
  if (!m) return false;
  if (Math.abs(Math.floor(now / 1000) - Number(m[1])) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest();
  const given = Buffer.from(m[2], 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

Responses and retries

NexG's answer tells you exactly what to do next.

StatusBodyWhat to do
200{ "ok": true, "result": … }Stop. NexG handled it, already had it, or refused it for good; result says which.
400{ "ok": false, "reason": "bad_signature" }Do not resend the same bytes. Check the secret and your clock, then sign again.
500{ "ok": false, "reason": "not_recorded" }Retry with backoff.
503{ "ok": false, "reason": "no_bank_configured" }Bank payments are off on this deployment. Retry later.

Retry on 5xx and on network errors, with growing gaps. The NexG Sandbox Bank waits 2 seconds, 10 seconds, 1 minute, 5 minutes and 30 minutes, then gives up and keeps the delivery for a manual resend. That schedule is a good default.

What a 200 can mean

resultMeaning
"state": "paid"The payment and its order are now paid.
"state": "failed"The payment failed; the guest can pay again.
"duplicate": trueNexG already has this event id. Nothing changed.
"ignored": trueA status NexG does not act on, such as processing.
"reason": "…"Refused for good: unknown_reference, collection_mismatch, currency_mismatch, invalid_amount, amount_mismatch, incomplete_event or unreadable. Fix the cause; resending will not help.

Duplicates and order

Deliveries can repeat: you retry, or NexG asks your API for the latest state when an event is late. NexG records each event id once, under a lock on the payment, so two copies arriving together are handled one after the other and the second is answered as a duplicate.

A repeat
HTTP/1.1 200 OK

{ "ok": true, "result": { "ok": true, "duplicate": true } }

A payment settles once. NexG acts on final statuses (succeeded, failed, expired, cancelled), and once a payment is paid, any later event for it is answered as a duplicate and changes nothing. If money is reported on a collection NexG has since replaced, the event is kept and raised to NexG finance to refund or apply by hand. It never quietly settles an order.

Testing

  • Check the endpoint is reachable: curl https://nexgapp.com/api/payments/bank/webhook.
  • Run the NexG Sandbox Bank: it signs and retries events exactly as described here, lists every delivery, and resends one on request.
  • Never test with a live secret, and never put a secret in a URL, a log or a ticket.

Coming soon

Events from more partners

Today only banks send events to NexG. Connections for POS, ERP, booking and inventory systems already have a place for a per-connection webhook secret; the endpoints that accept their events will be listed in the reference when they ship.

Events NexG sends you

NexG does not push order changes to merchants yet. Until it does, poll GET /v1/orders; once a minute costs 3 tokens.