Docs
Quickstart
Conventions
One gateway, one response shape
Every request goes to the gateway. It verifies your bearer token, derives your tenant from the token subject, and routes /v1/... to the right service. You never talk to the services directly.
- All endpoints live under
/v1/. - Success responses are always
{ "data": ... }. - Failure responses are always
{ "error": { code, message, details? } }. - Rate limit: 100 requests per minute, per tenant.
- Every response carries an
x-request-idheader — yours if you sent a valid one, otherwise a gateway-minted UUID. The same id is forwarded to the services, so quote it in support tickets and it matches our logs.
In local development the gateway listens on http://localhost:3000. The examples below use https://api.crossnetting.test as the placeholder host.
Idempotency — safe retries on payment creation
POST /v1/payments/cross-border and POST /v1/payments/batch accept an Idempotency-Key header: 8–128 printable ASCII characters, unique per tenant. If your request times out, retry with the same key and same body — you get the stored response back unchanged instead of a second payment. Reusing a key with a different body is a 409 conflict. Without the header the request runs normally, with no deduplication.
curl -X POST https://api.crossnetting.test/v1/payments/cross-border \
-H "authorization: Bearer $TOKEN" \
-H "Idempotency-Key: nordwind-inv-2026-1042" \
-H "content-type: application/json" \
-d '{ "fromAccountId": "acct_01", ... }'
# Timed out? Repeat the identical call — the stored response is replayed.
# Same key, different body -> 409 conflict.Cursor pagination on list endpoints
GET /v1/accounts and GET /v1/compliance/screenings / transactions paginate with an opaque cursor. Pass limit (1–100, default 20) and, for the next page, cursor from the previous page's meta.nextCursor. Accounts and transactions are ordered oldest first; screenings newest first. Invalid cursors or limits are rejected with 400 validation_error.
curl "https://api.crossnetting.test/v1/accounts?limit=20" \
-H "authorization: Bearer $TOKEN"
# 200 — data keeps its shape; meta is a sibling of data:
{
"data": [ ... ],
"meta": { "nextCursor": "MjAyNi0xMC0xM1QxMjo1NjAxX2FjY3RfMDE", "hasMore": true }
}
# next page
curl "https://api.crossnetting.test/v1/accounts?limit=20&cursor=MjAyNi0xMC0xM1QxMjo1NjAxX2FjY3RfMDE" \
-H "authorization: Bearer $TOKEN"Authentication
Session tokens, then Bearer
Exchange a BankID/MitID verification result for an HS256 JWT session token. The token subject becomes your tenant id on every subsequent call — tenants are isolated by construction.
curl -X POST https://api.crossnetting.test/v1/auth/session \
-H "content-type: application/json" \
-d '{"issuer":"bankid","subject":"nordwind-atelier"}'
# 201
{
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"issuer": "bankid",
"tenantId": "nordwind-atelier",
"expiresAt": "2026-10-13T13:37:00.000Z"
}
}Then send the token on every /v1 call. Requests without it, or with a token that fails verification, are rejected with 401 unauthorized. Tokens expire after one hour.
curl https://api.crossnetting.test/v1/accounts \
-H "authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
# 200
{ "data": [ { "id": "acct_01", "ibans": [ ... ], "status": "active" } ] }Money
Integer minor units, always
Amounts are integers in the currency's minor unit — cents, øre. There is no float anywhere in the money path, so 0.1 + 0.2 never becomes a rounding bug.
{ "currency": "NOK", "minor": 31000000 } // 310,000.00 NOK
{ "currency": "EUR", "minor": 2999500 } // 29,995.00 EUR- ISO-4217 three-letter codes; supported: EUR, GBP, NOK, SEK, DKK, USD, CHF, PLN, CZK.
- Never sum or compare amounts across currencies — convert through an FX quote first.
- The same rules apply in the SDK, the REST wire format and webhook payloads.
Payments
Your first cross-border payment
Cross-border payments are two-step: creating one pins a firm FX quote with an expiry, executing it settles at exactly that rate. Nothing converts until you say so.
# 1 · create — pins a firm FX quote (status: fx_quoted)
curl -X POST https://api.crossnetting.test/v1/payments/cross-border \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{
"fromAccountId": "acct_01",
"creditorName": "Zürich Präzision AG",
"creditorIban": "CH56048350123B2E39508",
"sourceAmountMinor": 31000000,
"sourceCurrency": "NOK",
"targetCurrency": "EUR"
}'
# 200 → { "data": { "id": "pay_7f3a", "status": "fx_quoted",
# "fxQuote": { "id": "q_9d2c", "rate": 0.0968, ... } } }
# 2 · execute — settles at the quoted rate
curl -X POST https://api.crossnetting.test/v1/payments/pay_7f3a/execute \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{ "quoteId": "q_9d2c" }'
# 200 → { "data": { "id": "pay_7f3a", "status": "settled", ... } }Payment status moves initiated → fx_quoted → submitted → settled — or failed. Every transition is emitted on the event bus and delivered to your webhooks. For many payments at once, POST /v1/payments/batch validates each item independently and reports per-item rejections alongside the accepted ones. Both creation calls accept the Idempotency-Key header described under conventions.
Webhooks
Subscribe to state changes
Register a URL for the event types you care about. The gateway POSTs the event envelope to your endpoint whenever one of your tenant's resources changes state.
curl -X POST https://api.crossnetting.test/v1/webhooks \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{
"url": "https://your-app.example/webhooks/crossnetting",
"events": ["payments.payment.settled", "payments.payment.failed"]
}'Deliveries are POST with content-type: application/json, plus x-crossnetting-event-type, x-crossnetting-event-id and x-crossnetting-signature headers. Reply 2xx to acknowledge.
{
"id": "8f14e45f-...-uuid",
"type": "payments.payment.settled",
"tenantId": "nordwind-atelier",
"occurredAt": "2026-10-13T12:37:00.000Z",
"producer": "payments",
"data": { "paymentId": "pay_7f3a", ... }
}Verify the signature
The signature header looks like v1=<hex>, where hex is HMAC-SHA256 over "<event-id>.<raw JSON body>" keyed by the secret configured on the gateway (WEBHOOK_SECRET, falling back to GATEWAY_SECRET). Recompute it over the raw bytes you received — not a re-serialised object — and compare in constant time. The v1= prefix leaves room for future algorithms.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the exact request bytes; eventId: the x-crossnetting-event-id header.
function validSignature(rawBody, eventId, signatureHeader) {
const mac = createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(eventId + "." + rawBody, "utf8")
.digest("hex");
const expected = "v1=" + mac;
return (
signatureHeader.length === expected.length &&
timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected))
);
}Retries and delivery state
A network failure or non-2xx reply is retried up to 3 more times with 250 ms, 1 s and 4 s backoff — always the same event id, so the signature stays valid across the chain, and deliveries for the same event never overlap. The webhook resource reports the outcome of the latest chain: lastDeliveryStatus, lastDeliveryAttempts, lastDeliveryStatusCode and lastDeliveryError. Retries are best-effort, not a queue: keep polling the resource endpoint as the ultimate fallback.
All subscribable event types (17)
- accounts.account.opened
- accounts.account.status_changed
- payments.payment.initiated
- payments.payment.fx_quoted
- payments.payment.submitted
- payments.payment.settled
- payments.payment.failed
- payments.batch.created
- payments.fx.rate_refreshed
- compliance.screening.completed
- compliance.case.escalated
- compliance.csrd.report_generated
- compliance.dora.status_changed
- compliance.mica.token_classified
- treasury.forecast.generated
- treasury.fx.hedge_recommended
- analytics.snapshot.ready
Errors
Stable codes, never parsed messages
Build your logic on the error code. The message is for humans and may change.
| Code | HTTP | Meaning |
|---|---|---|
| validation_error | 400 | The request body or query failed schema validation. |
| unauthorized | 401 | Missing or invalid bearer token. |
| forbidden | 403 | Token is valid but not allowed for this resource. |
| not_found | 404 | Unknown route or resource id for this tenant. |
| conflict | 409 | State conflict, e.g. executing an already-settled payment. |
| unprocessable | 422 | Semantically invalid request. |
| insufficient_funds | 422 | Debit refused: balance below the requested amount. |
| compliance_hold | 423 | Payment blocked pending a compliance decision. |
| rate_limited | 429 | Over 100 requests per minute for this tenant. |
| provider_error | 502 | An upstream provider (mock adapter) failed. |
| internal_error | 500 | Unexpected platform error — safe to retry idempotently. |
SDK
TypeScript client
@crossnetting/sdk is a zero-dependency client for the gateway. Same routes, same money rules, plus types for every resource.
pnpm add @crossnetting/sdk # workspace package: packages/sdkimport { CrossnettingClient } from "@crossnetting/sdk";
const cx = new CrossnettingClient({
baseUrl: "https://api.crossnetting.test",
apiKey: process.env.CX_API_KEY, // sent as Authorization: Bearer
tenantId: "nordwind-atelier",
});
const q = await cx.payments.crossBorder({
fromAccountId: "acct_01",
creditorName: "Zürich Präzision AG",
creditorIban: "CH56048350123B2E39508",
sourceAmount: { currency: "NOK", minor: 31000000 },
targetCurrency: "EUR",
});
await cx.payments.execute(q.id, q.fxQuote.id);Resources: accounts, payments, fx, compliance, treasury and analytics. For other languages, the endpoint reference maps the full OpenAPI contract in packages/api-contracts/openapi.yaml.