Skip to content

pacs.008 Webhook Integration Guide

Endpoint Verb Purpose
/v1/webhook/pacs008 POST Submit a pacs.008 credit transfer
/v1/webhook/pacs008/status/{publicId} GET Poll the status of a submitted payment group
/v1/webhook/banks GET List your banks, counterparties, and the global BIC directory
  • One auth scheme for all endpoints: HMAC-SHA256 over a deterministic canonical string, with timestamp-based replay protection
  • Idempotent POST: the same MsgId cannot be ingested twice — duplicates return 409 Conflict
  • GET endpoints return JSON and use the same HMAC auth (with an empty body in the canonical string)

sequenceDiagram diagram from the “1. At a glance” section of the pacs.008 webhook guide

2.1 Submit payment — POST /v1/webhook/pacs008

Section titled “2.1 Submit payment — POST /v1/webhook/pacs008”
Value
Method POST
Path /v1/webhook/pacs008
Sandbox base URL <nupont-api-base-url> (provided during onboarding)
Production base URL <nupont-api-base-url> (provided during onboarding)
Content-Type application/xml
Body Raw pacs.008.001.08 XML, byte-for-byte the same XML you sign
Max body size 5 MiB by default — contact us if you need more
Idempotency key MsgId from the <GrpHdr> element

2.2 Payment status — GET /v1/webhook/pacs008/status/{publicId}

Section titled “2.2 Payment status — GET /v1/webhook/pacs008/status/{publicId}”
Value
Method GET
Path /v1/webhook/pacs008/status/{publicId}
{publicId} UUID returned as public_id in the 201 response from the POST endpoint
Body None — canonical string uses empty body (see §3.1)

2.3 Bank directory — GET /v1/webhook/banks

Section titled “2.3 Bank directory — GET /v1/webhook/banks”
Value
Method GET
Path /v1/webhook/banks
Body None — canonical string uses empty body (see §3.1)

Returns two lists: banks scoped to your organization (your own banks and counterparties) and a global directory of all known BICs.

Every request must carry exactly three custom headers:

Header Format Description
X-Org-Id string The public identifier nupont issued you, e.g. nupont_org_demo
X-Org-Timestamp unix epoch seconds (integer) Request creation time, e.g. 1746000000
X-Org-Signature lowercase hex (64 chars) HMAC-SHA256(secret, canonical_string)
canonical_string = X-Org-Timestamp + "\n" + raw_request_body
  • The separator is a single LF byte (0x0A), not CRLF.
  • raw_request_body is the exact bytes you will put on the wire. Do not normalise whitespace, re-encode, or pretty-print after signing — the server hashes whatever it receives and a single byte difference will fail verification.
  • For GET requests (no body): raw_request_body is the empty string, so canonical_string = X-Org-Timestamp + "\n".
  • secret is your shared HMAC key. It is never sent over the wire.

Requests whose X-Org-Timestamp differs from server clock by more than ±300 seconds are rejected with 401 timestamp_out_of_window. Keep your servers synced with NTP. The 5-minute window is wide enough to absorb clock skew and brief retries.

The server uses a constant-time comparison (PHP hash_equals). Send the signature lowercase. Uppercase is also accepted because the server normalises the incoming header before comparing.

Terminal window
URL="<nupont-api-base-url>/v1/webhook/pacs008"
ORG_ID="your_org_identifier"
SECRET="your_shared_secret"
XML_FILE="payment.pacs008.xml"
TS="$(date +%s)"
# canonical string is "${TS}\n${body}" — printf, NOT echo, to avoid extra newlines
SIG="$(
{ printf '%s\n' "$TS"; cat "$XML_FILE"; } \
| openssl dgst -sha256 -hmac "$SECRET" -hex \
| awk '{print $NF}'
)"
curl -sS --fail-with-body \
-X POST "$URL" \
-H "Content-Type: application/xml" \
-H "X-Org-Id: $ORG_ID" \
-H "X-Org-Timestamp: $TS" \
-H "X-Org-Signature: $SIG" \
--data-binary @"$XML_FILE"
Terminal window
URL="<nupont-api-base-url>/v1/webhook/pacs008/status/${PUBLIC_ID}"
ORG_ID="your_org_identifier"
SECRET="your_shared_secret"
TS="$(date +%s)"
# GET has no body — canonical string is just "${TS}\n"
SIG="$(
printf '%s\n' "$TS" \
| openssl dgst -sha256 -hmac "$SECRET" -hex \
| awk '{print $NF}'
)"
curl -sS --fail-with-body \
-H "X-Org-Id: $ORG_ID" \
-H "X-Org-Timestamp: $TS" \
-H "X-Org-Signature: $SIG" \
"$URL"
Terminal window
URL="<nupont-api-base-url>/v1/webhook/banks"
# Same signing as any GET — use the pattern from §4.1 GET above

A ready-to-run version of the POST script is available from nupont and is the authoritative reference — when in doubt, copy it.

import hashlib
import hmac
import time
from pathlib import Path
import requests
BASE_URL = "<nupont-api-base-url>"
ORG_ID = "your_org_identifier"
SECRET = b"your_shared_secret" # bytes — never decode
def sign(ts: str, body: bytes = b"") -> str:
"""Compute HMAC-SHA256 over the canonical string."""
canonical = ts.encode() + b"\n" + body
return hmac.new(SECRET, canonical, hashlib.sha256).hexdigest()
def auth_headers(ts: str, sig: str) -> dict:
return {
"X-Org-Id": ORG_ID,
"X-Org-Timestamp": ts,
"X-Org-Signature": sig,
}
# --- POST: submit payment ---
xml = Path("payment.pacs008.xml").read_bytes()
ts = str(int(time.time()))
sig = sign(ts, xml)
resp = requests.post(
f"{BASE_URL}/v1/webhook/pacs008",
data=xml,
headers={"Content-Type": "application/xml", **auth_headers(ts, sig)},
timeout=10,
)
resp.raise_for_status()
result = resp.json()
print("Submitted:", result)
# --- GET: poll status ---
public_id = result["public_id"]
ts = str(int(time.time()))
sig = sign(ts) # no body for GET
resp = requests.get(
f"{BASE_URL}/v1/webhook/pacs008/status/{public_id}",
headers=auth_headers(ts, sig),
timeout=10,
)
resp.raise_for_status()
print("Status:", resp.json())
# --- GET: list banks ---
ts = str(int(time.time()))
sig = sign(ts)
resp = requests.get(
f"{BASE_URL}/v1/webhook/banks",
headers=auth_headers(ts, sig),
timeout=10,
)
resp.raise_for_status()
print("Banks:", resp.json())
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.*;
import javax.crypto.*;
import javax.crypto.spec.SecretKeySpec;
// --- Shared signing helper ---
static String sign(String ts, byte[] body, String secret) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((ts + "\n").getBytes(StandardCharsets.UTF_8));
if (body.length > 0) mac.update(body);
return HexFormat.of().formatHex(mac.doFinal());
}
// --- POST: submit payment ---
byte[] body = Files.readAllBytes(Path.of("payment.pacs008.xml"));
String ts = String.valueOf(System.currentTimeMillis() / 1000);
String sig = sign(ts, body, "your_shared_secret");
HttpRequest req = HttpRequest.newBuilder(URI.create("<nupont-api-base-url>/v1/webhook/pacs008"))
.header("Content-Type", "application/xml")
.header("X-Org-Id", "your_org_identifier")
.header("X-Org-Timestamp", ts)
.header("X-Org-Signature", sig)
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
HttpResponse<String> resp = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(resp.statusCode() + " " + resp.body());
// --- GET: poll status ---
String tsGet = String.valueOf(System.currentTimeMillis() / 1000);
String sigGet = sign(tsGet, new byte[0], "your_shared_secret");
HttpRequest statusReq = HttpRequest.newBuilder(URI.create("<nupont-api-base-url>/v1/webhook/pacs008/status/" + publicId))
.header("X-Org-Id", "your_org_identifier")
.header("X-Org-Timestamp", tsGet)
.header("X-Org-Signature", sigGet)
.GET().build();
const crypto = require('node:crypto');
const fs = require('node:fs');
const https = require('node:https');
const BASE = '<nupont-api-base-url>';
const SECRET = 'your_shared_secret';
const ORG_ID = 'your_org_identifier';
function sign(ts, body = Buffer.alloc(0)) {
return crypto.createHmac('sha256', SECRET)
.update(ts + '\n')
.update(body)
.digest('hex');
}
function authHeaders(ts, sig) {
return {
'X-Org-Id': ORG_ID,
'X-Org-Timestamp': ts,
'X-Org-Signature': sig,
};
}
// --- POST: submit payment ---
const body = fs.readFileSync('payment.pacs008.xml');
const ts = Math.floor(Date.now() / 1000).toString();
const sig = sign(ts, body);
const req = https.request({
method: 'POST',
hostname: BASE,
path: '/v1/webhook/pacs008',
headers: {
'Content-Type': 'application/xml',
'Content-Length': body.length,
...authHeaders(ts, sig),
},
}, (res) => {
let chunks = '';
res.on('data', (c) => chunks += c);
res.on('end', () => console.log(res.statusCode, chunks));
});
req.write(body);
req.end();
// --- GET: poll status ---
const tsGet = Math.floor(Date.now() / 1000).toString();
const sigGet = sign(tsGet); // no body
const statusReq = https.request({
method: 'GET',
hostname: BASE,
path: `/v1/webhook/pacs008/status/${publicId}`,
headers: authHeaders(tsGet, sigGet),
}, (res) => {
let chunks = '';
res.on('data', (c) => chunks += c);
res.on('end', () => console.log(res.statusCode, chunks));
});
statusReq.end();

5.1 POST /v1/webhook/pacs008201 Created

Section titled “5.1 POST /v1/webhook/pacs008 — 201 Created”
{
"message_id": "20260401110215394242587731",
"public_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "received",
"transactions": [
{
"public_id": "f9c10e78-afee-4a31-b427-96080a106a84",
"end_to_end_id": "NOTPROVIDED",
"status": "received"
}
]
}
Field Description
message_id Echoes the <MsgId> from your <GrpHdr>
public_id UUID assigned to this payment group — use it to poll status (§5.2)
status Initial group status (always received on creation)
transactions Array of individual credit transfers parsed from <CdtTrfTxInf> elements

5.2 GET /v1/webhook/pacs008/status/{publicId}200 OK

Section titled “5.2 GET /v1/webhook/pacs008/status/{publicId} — 200 OK”

Poll this endpoint to track the lifecycle of a submitted payment group. The {publicId} is the public_id UUID returned in the 201 response.

{
"message_id": "20260401110215394242587731",
"public_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "executing",
"transactions": [
{
"public_id": "f9c10e78-afee-4a31-b427-96080a106a84",
"end_to_end_id": "NOTPROVIDED",
"status": "executing",
"tx_hash": "0xabc123...def456",
"error_context": null
}
]
}
Field Description
status Current group status — see §7 for the full lifecycle
tx_hash EVM transaction hash, populated once the transaction is broadcast on-chain
error_context Error details if the transaction failed (e.g. signing_failed, execution_failed); null when healthy

Possible status values (group and transaction level):

Status Meaning
received Payment accepted, queued for processing
mapping_missing Debtor/creditor IBAN could not be resolved to a blockchain address — awaiting operator action
ready_for_signing All mappings resolved, ready for HSM signing
in_signing Signing in progress
signed Signature obtained, ready for broadcast
signing_failed HSM signing failed — will be retried or archived
waiting_for_funds Awaiting sufficient on-chain balance
executing Transaction broadcast to the blockchain
successfully_executed Transaction confirmed on-chain
confirmed Settlement fully confirmed
execution_failed On-chain execution failed
archive Terminal state — payment archived

Error responses:

HTTP error Cause
400 INVALID_FORMAT {publicId} is not a valid UUID
404 Operation not allowed No payment group found with that public_id (or it belongs to a different organization)

Returns banks and counterparties scoped to your organization, plus a global BIC directory for receiver-bank lookup.

{
"banks": [
{
"bic": "BSBSDEMMXXX",
"name": "Demo Bank AG",
"type": "own",
"nostro_iban": "DE89370400440532013000",
"address": "0x1234...abcd"
},
{
"bic": "CHASUS33XXX",
"name": "JPMorgan Chase",
"type": "counterparty",
"nostro_iban": "US33123456789012345678",
"address": "0x5678...ef01"
}
],
"directory": [
{
"bic": "BSBSDEMMXXX",
"name": "Demo Bank AG",
"nostro_iban": "DE89370400440532013000"
},
{
"bic": "CHASUS33XXX",
"name": "JPMorgan Chase",
"nostro_iban": "US33123456789012345678"
}
]
}

banks array — your organization’s banks:

Field Description
bic SWIFT/BIC code
name Human-readable bank name
type "own" = your bank; "counterparty" = a bank you transact with
nostro_iban IBAN of the nostro account at this bank
address Active blockchain address (EVM) for this bank, or null if none configured

directory array — global BIC directory (all active banks across all organizations, deduplicated by BIC). Useful for building a receiver-bank picker in your payment UI. Does not include type or address since these are org-specific.

All errors are JSON with the shape { "error": "<machine-readable-code>" }.

HTTP error Cause Action
400 missing_headers One of the three X-Org-* headers is absent or non-numeric Check your client emits all three on every request
400 (XML error message) Body is not parseable as a valid pacs.008.001.08 document (POST only) Validate against the official ISO 20022 XSD
401 timestamp_out_of_window X-Org-Timestamp is more than 300s away from server time Sync your clock (NTP) and retry
401 unknown_identifier X-Org-Id does not match any provisioned organization, or your secret has been revoked Contact nupont integration support
401 signature_mismatch HMAC verification failed (wrong secret, wrong canonical string, or body was modified after signing) Re-check your signing implementation; the bash script in §4.1 is the reference
409 (duplicate message) A pacs.008 with the same MsgId was already accepted for your organization (POST only) If this is a retry, the previous call already succeeded — use the public_id from that response to poll status
5xx Internal error Server-side issue Retry with exponential backoff; alert nupont if it persists

flowchart diagram from the “5.4 Error responses (all endpoints)” section of the pacs.008 webhook guide

  • Safe to retry on network errors and 5xx. Use exponential backoff (e.g. 1s, 5s, 30s, 5min).
  • Do not retry 4xx (other than 5xx → success on the eventual retry). Fix the offending request and re-send with a fresh timestamp + signature.
  • Idempotency is keyed on MsgId (POST only). If you retry the same MsgId after a successful 201, you will receive 409. Treat that as “the previous attempt did succeed” — do not consider it a failure.
  • Re-signing after retry: because the timestamp must be within ±300s of server time, you must compute a new X-Org-Timestamp and a new X-Org-Signature for every retry attempt.
  • GET endpoints are naturally idempotent — poll as often as you need.

7. Settlement model — what happens after 201

Section titled “7. Settlement model — what happens after 201”

The 201 Created response means the message is accepted, archived, and queued for settlement. It does not mean the funds have moved yet. Settlement is asynchronous and proceeds via:

  1. Mapping check — for each transaction, nupont resolves debtor and creditor IBANs to blockchain addresses against your organization’s counterparty table. If a mapping is missing, the transaction lands in mapping_missing state and waits for operator action.
  2. Signing — nupont’s HSM service produces an EIP-3009 transferWithAuthorization signature using your organization’s signing key.
  3. Broadcast — the relayer submits the transaction to the configured EVM network (Sepolia / Ethereum / Polygon).
  4. Confirmation — once the transaction is mined, nupont generates a camt.053.001.08 statement and (in a future phase) calls back to your statement endpoint.

Poll the status endpoint (GET /v1/webhook/pacs008/status/{publicId}) to track progress. The tx_hash field is populated once the transaction is broadcast, and the status field transitions through the lifecycle below.

The full payment lifecycle:

received
|
+---> mapping_missing --> ready_for_signing (operator links counterparty)
+---> ready_for_signing
|
v
in_signing --> signing_failed --> archive
|
v
signed --> waiting_for_funds --> executing
| |
v |
executing <-----------------------------+
|
+------+------+
v v
successfully execution
executed failed
| |
v v
confirmed archive
|
v
archive
  1. Ask nupont for sandbox credentials (staging org_identifier + secret + base URL).
  2. Use a sample XML similar to tests/SampleData/pacs008_valid.xml.
  3. Run your client against the sandbox until you see 201 responses.
  4. Save the public_id from the 201 response and poll GET /v1/webhook/pacs008/status/{publicId} to observe the lifecycle.
  5. Call GET /v1/webhook/banks to verify your bank and counterparty configuration.
  • Initial provisioning: handled manually by nupont integration during onboarding. You receive your org_identifier and secret over a secure out-of-band channel.
  • Rotation: contact integration support. We will issue a new secret and coordinate a cutover window. Until self-service is available you should plan rotations in advance.
  • Revocation: in case of suspected compromise, request immediate revocation. nupont sets the HMAC secret to NULL, after which all subsequent requests with that org_identifier return 401 unknown_identifier.
  • TLS only. nupont rejects plain HTTP at the load balancer.
  • Never log secrets. Treat the HMAC secret like a database password. Store it in a secret manager (Vault, AWS Secrets Manager, etc.), not in your repo.
  • Never log signatures alongside the secret. A signature alone is harmless; a signature next to the canonical string and the secret is a leaked credential.
  • Restrict outbound IPs if your environment supports it. nupont can allow-list your egress ranges on request.

The 300-second replay window is generous, but it is not a license to drift. We recommend:

  • Keep production servers within ±5 seconds of UTC via NTP.
  • If your clock drifts past 60 seconds, page on-call.
  • If a request fails with timestamp_out_of_window, do not patch the timestamp by hand — fix the clock and retry.
POST /v1/webhook/pacs008 HTTP/1.1
Host: <nupont-api-base-url>
Content-Type: application/xml
Content-Length: 2904
X-Org-Id: nupont_org_demo
X-Org-Timestamp: 1746000000
X-Org-Signature: 4f2c8a1e9b3d7f0a6c2e5b8d1f4a7c9e2b5d8f1a4c7e0b3d6f9a2c5e8b1d4f7a0
<RequestPayload>
<AppHdr xmlns="urn:iso:std:iso:20022:tech:xsd:head.001.001.02">
<Fr><FIId><FinInstnId><BICFI>BSBSDEMMXXX</BICFI></FinInstnId></FIId></Fr>
<To><FIId><FinInstnId><BICFI>CHASUS33XXX</BICFI></FinInstnId></FIId></To>
<BizMsgIdr>20260401110215394242587731</BizMsgIdr>
<MsgDefIdr>pacs.008.001.08</MsgDefIdr>
<BizSvc>swift.cbprplus.03</BizSvc>
<CreDt>2026-04-01T09:02:15.408+00:00</CreDt>
<Prty>NORM</Prty>
</AppHdr>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08">
<FIToFICstmrCdtTrf>
<GrpHdr>
<MsgId>20260401110215394242587731</MsgId>
<CreDtTm>2026-04-01T09:02:15.440+00:00</CreDtTm>
<NbOfTxs>1</NbOfTxs>
<SttlmInf><SttlmMtd>INGA</SttlmMtd></SttlmInf>
</GrpHdr>
<CdtTrfTxInf>
<PmtId>
<InstrId>260401CC900929</InstrId>
<EndToEndId>NOTPROVIDED</EndToEndId>
<UETR>f9c10e78-afee-4a31-b427-96080a106a84</UETR>
</PmtId>
...
</CdtTrfTxInf>
</FIToFICstmrCdtTrf>
</Document>
</RequestPayload>
{
"message_id": "20260401110215394242587731",
"public_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "received",
"transactions": [
{
"public_id": "f9c10e78-afee-4a31-b427-96080a106a84",
"end_to_end_id": "NOTPROVIDED",
"status": "received"
}
]
}
GET /v1/webhook/pacs008/status/a1b2c3d4-e5f6-7890-abcd-ef1234567890 HTTP/1.1
Host: <nupont-api-base-url>
X-Org-Id: nupont_org_demo
X-Org-Timestamp: 1746000060
X-Org-Signature: 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b
{
"message_id": "20260401110215394242587731",
"public_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "successfully_executed",
"transactions": [
{
"public_id": "f9c10e78-afee-4a31-b427-96080a106a84",
"end_to_end_id": "NOTPROVIDED",
"status": "successfully_executed",
"tx_hash": "0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890",
"error_context": null
}
]
}
GET /v1/webhook/banks HTTP/1.1
Host: <nupont-api-base-url>
X-Org-Id: nupont_org_demo
X-Org-Timestamp: 1746000120
X-Org-Signature: 0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b
{
"banks": [
{
"bic": "BSBSDEMMXXX",
"name": "Demo Bank AG",
"type": "own",
"nostro_iban": "DE89370400440532013000",
"address": "0x1234567890abcdef1234567890abcdef12345678"
},
{
"bic": "CHASUS33XXX",
"name": "JPMorgan Chase",
"type": "counterparty",
"nostro_iban": "US33123456789012345678",
"address": "0xabcdef1234567890abcdef1234567890abcdef12"
}
],
"directory": [
{
"bic": "BSBSDEMMXXX",
"name": "Demo Bank AG",
"nostro_iban": "DE89370400440532013000"
},
{
"bic": "CHASUS33XXX",
"name": "JPMorgan Chase",
"nostro_iban": "US33123456789012345678"
}
]
}
Sandbox onboarding
Production credentials
Incident response
API contract questions This document is the source of truth — open an issue if it diverges from observed behaviour
Date Change
2026-05-05 Add GET /v1/webhook/pacs008/status/{publicId} and GET /v1/webhook/banks endpoints; update POST response to include public_id, status, and per-transaction details; fix auth headers from X-Bank-* to X-Org-*; add GET signing examples for all reference implementations
2026-04-08 Initial release: HMAC auth, public app subdomain, MinIO-backed dev storage, 201/4xx error matrix