Skip to content

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-id header — 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.

idempotency.sh
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.

pagination.sh
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.

1 · Get a session token
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.

2 · Call with the token
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.

Money
{ "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.

quote → execute
# 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.

webhooks.sh
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.

delivery payload
{
  "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.

verify-signature.mjs
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.

CodeHTTPMeaning
validation_error400The request body or query failed schema validation.
unauthorized401Missing or invalid bearer token.
forbidden403Token is valid but not allowed for this resource.
not_found404Unknown route or resource id for this tenant.
conflict409State conflict, e.g. executing an already-settled payment.
unprocessable422Semantically invalid request.
insufficient_funds422Debit refused: balance below the requested amount.
compliance_hold423Payment blocked pending a compliance decision.
rate_limited429Over 100 requests per minute for this tenant.
provider_error502An upstream provider (mock adapter) failed.
internal_error500Unexpected 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.

install.sh
pnpm add @crossnetting/sdk   # workspace package: packages/sdk
client.ts
import { 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.