https://sandbox-api.nuvante.ioGuides
Event notifications (webhooks)
Get notified the moment a transaction changes state, so you don't have to wait for your next poll. Treat each webhook as a prompt to fetch the transaction again. The transaction resource is still the source of truth.
When polling gets expensive
The other guides tell you to poll GET /api/v1/transactions/{id} until terminal is true. That's fine for a Mode 1 redemption that settles in seconds. Under Mode 2 or Mode 3, an issuer's approvalSlaSeconds can stretch to minutes or hours, and polling for that long adds up quickly. See Issuer capability declaration.
Webhooks let you react to a change as soon as it happens. They work alongside polling, which still works and is still the source of truth. When a webhook arrives, fetch the transaction to see its current state. Expect some deliveries to go missing, arrive twice or arrive out of order. That's normal.
Registering an endpoint
POST https://sandbox-api.nuvante.io/api/v1/webhooksThis needs the webhooks:manage scope.
{
"url": "https://hooks.partner-bank.test/nuvante",
"events": [
"transaction.settled",
"transaction.parked",
"transaction.cancelled"
],
"description": "Production settlement listener"
}Response:
{
"data": {
"id": "wh_523e4567-e89b-42d3-a456-426614174004",
"url": "https://hooks.partner-bank.test/nuvante",
"events": ["transaction.settled", "transaction.parked", "transaction.cancelled"],
"status": "active",
"signingSecret": "whsec_…",
"createdAt": "2026-08-04T09:00:00.000Z"
},
"meta": { "commandId": "a23e4567-e89b-42d3-a456-426614174010" }
}Event catalogue
Webhook event names are different from the saga step names in saga.steps[].name. They're also different from the normalised issuer events in Issuer instruction lifecycle (Burned, Minted, InstructionRejected and so on), which are internal events for conformance testing. Webhooks fire only on public transaction lifecycle changes:
transaction.receivedreceivedtransaction.in_flightin-flighttransaction.settledsettledtransaction.cancelledcancelledtransaction.parkedfailed, parked: truetransaction.failedfailed, parked: falseYou can tell transaction.parked and transaction.failed apart by the parked field, just like on the polled transaction resource. A parked transaction may well be recoverable, but an operator has to act before it can move. As far as your integration is concerned, both are final, and neither will change without action outside the API. See Rejection and failure are different.
Delivery payload
{
"id": "evt_623e4567-e89b-42d3-a456-426614174005",
"type": "transaction.settled",
"createdAt": "2026-08-04T09:04:12.000Z",
"data": {
"transactionId": "123e4567-e89b-42d3-a456-426614174000",
"paymentReference": "DVP-REDEEM-0001",
"lifecycleStatus": "settled",
"terminal": true,
"parked": false
}
}We keep the payload small on purpose. It has an ID and just enough context for you to decide whether to act. Always call GET /api/v1/transactions/{id} after a delivery, before you change anything in your own system. It's the same principle as on-chain proof, where we never treat an intermediate signal as final, applied one layer up.
Verifying signatures
Every delivery comes with two headers:
Nuvante-Signature: t=1754297052,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Nuvante-Webhook-Id: evt_623e4567-e89b-42d3-a456-426614174005v1 is HMAC-SHA256(signingSecret, timestamp + "." + rawBody), hex encoded. Compute it yourself and compare the two values with a constant-time comparison. Please avoid comparing the strings with ===.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, signingSecret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const expected = createHmac("sha256", signingSecret)
.update(parts.t + "." + rawBody)
.digest("hex");
const ok =
expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!ok) throw new Error("invalid signature");
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
throw new Error("stale delivery");
}
}Idempotency and delivery guarantees
Delivery is at least once, so you will get duplicates. Deduplicate on Nuvante-Webhook-Id. A transaction can move back into in_flight after a parked transaction is fixed, so if you deduplicate on type and transactionId, you'd wrongly treat that real event as a duplicate.
Delivery isn't ordered. When retries happen, a later event can arrive before an earlier one, so please don't use arrival order in place of saga.currentStepIndex. If order matters to you, fetch the transaction on every delivery and drive your state machine from lifecycleStatus and terminal.
If your endpoint returns anything other than a 2xx, we retry immediately, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours. That's seven attempts over roughly 38 hours. After that, the delivery is marked failed and we stop.
If every delivery to a subscription fails for 72 hours straight, we set it to status: disabled and stop sending new events. We can't tell you about this by webhook, since the webhook is what's broken. Check GET /api/v1/webhooks/{id} from time to time, or set up an alert on your side for when deliveries go quiet for longer than you'd expect.
Endpoints
Webhook subscription endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/webhooks | Create a subscription. |
| GET | /api/v1/webhooks | List your subscriptions. |
| GET | /api/v1/webhooks/{id} | Read one subscription, including whether it's been disabled after failures. |
| DELETE | /api/v1/webhooks/{id} | Remove a subscription. |
Managing subscriptions needs webhooks:manage. Remember to fetch GET /transactions/{id} after each delivery, and to deduplicate on Nuvante-Webhook-Id.
