Zero to an ingested movement
Integration guide
Everything an engineer needs to sign a request, map money and time correctly, retry safely, handle errors, receive webhooks, and prove the integration with the conformance CLI before real traffic touches it.
Payload schema_version 2026-09-01 · signing vectors from packages/signing/vectors · code snippets executed against those vectors at build time
#Before you start
You need four things, and three of them come from your KYTRIX console under Settings → API keys.
| What | Looks like | Notes |
|---|---|---|
| API base URL | https://api.<your-domain> | The public API host. The console lives on a different host; do not send API traffic there. |
| Key id | kx_test_… / kx_live_… | Sent as Kytrix-Key-Id. kx_test_ is a sandbox credential — start there. |
| Key secret | 32 bytes, base64url | Shown exactly once, at creation or rotation. Straight into your secret manager; never into git, a ticket, or a log line. |
| Scopes | ingest, read, webhook_mgmt | Separate credentials per scope. An ingest-only key cannot read alerts or repoint your webhook egress, and that is the point. |
#The shape of the integration
Six object families go in; alerts, episodes and posture come out. You send facts about your business — you do not send judgements, and KYTRIX does not send commands back. It advises; you enforce.
| Family | Endpoint | What it is |
|---|---|---|
party | POST /v1/parties | A customer, business, agent or institution you onboarded. |
account | POST /v1/accounts | A wallet or ledger account held by a party. |
instrument | POST /v1/instruments | A card token, device or phone number. |
movement | POST /v1/movements | A value transfer — including failed, pending and reversed ones. Blocked attempts are evidence, not noise. |
relationship | POST /v1/relationships | A declared link: ownership, signatory, shared device. |
control_event | POST /v1/control-events | Something your own controls did: a blocked withdrawal, a limit hit, a manual review. |
- Sign a request and get a 200. Nothing else works until this does. Verify against the published test vectors first, offline.
- Dry-run one event of each family.
POST /v1/validateruns the exact validation the live endpoints run and stores nothing. Iterate here, not against the live endpoint. - Send one real movement and follow it with
GET /v1/events/{event_id}. - Turn on your mapper and batch, up to 1000 mixed events per request.
- Register a webhook endpoint and verify a signed delivery end to end.
- Run the conformance CLI until it says
CONFORMANT. That is the Connect-stage exit criterion — not "it seems to work".
#Authenticating every request
KYTRIX authenticates with HMAC request signing, not a bearer token. The signature covers the method, the path, the query, the body bytes, a timestamp, a nonce and the key id — so a captured request cannot be replayed, retargeted at another endpoint, or edited in flight.
| Header | Value |
|---|---|
Kytrix-Key-Id | Your key id, e.g. kx_test_…. |
Kytrix-Timestamp | Unix seconds, decimal. Not milliseconds. |
Kytrix-Nonce | 1–64 characters of A-Z a-z 0-9 . _ ~ -. Single-use per key for 600 seconds. |
Kytrix-Signature | v1=<64 lower-case hex characters>. |
#The canonical string
Eight lines joined with \n, with no trailing newline. Get this byte-exact and everything else follows.
| # | Line | Rule |
|---|---|---|
| 1 | KYTRIX-HMAC-SHA256 | The literal scheme label. |
| 2 | method | Upper-cased. |
| 3 | path | Path only — no query, no fragment. Percent-encoding normalized (RFC 3986 §6.2.2): a %XX encoding an unreserved character (A-Z a-z 0-9 - . _ ~) is decoded, every other %XX is upper-cased. Nothing else changes — no dot-segment removal, no // collapsing. A leading / is ensured. |
| 4 | canonical query | Empty string when there is no query. Otherwise: split the raw query on & (drop empty segments), split each segment at its first = (a bare key has an empty value), form-decode both halves (+ → space, then %XX; a malformed %XX stays literal), re-encode both with RFC 3986, sort pairs by encoded key then encoded value, join key=value with &. Repeated keys are all kept. |
| 5 | body hash | Lower-case hex SHA-256 of the raw body bytes. For a body-less request, the SHA-256 of the empty string: e3b0c442…b855. |
| 6 | timestamp | Exactly the Kytrix-Timestamp value you send. |
| 7 | nonce | Exactly the Kytrix-Nonce value you send. |
| 8 | key id | Exactly the Kytrix-Key-Id value you send. |
Then signature = hex(HMAC-SHA256(secret_bytes, canonical)), where secret_bytes are the raw bytes of your secret — base64url-decode the string the API gave you; do not HMAC the base64url text.
#A reference implementation
Dependency-free, ~130 lines. This exact file is executed by the docs build against the published vectors below — if it stopped matching the server, this page would not build.
// KYTRIX request signing — a complete, dependency-free reference implementation.
//
// This file is published verbatim on docs.<domain> AND executed by the docs build against the
// published test vectors in packages/signing/vectors/request-vectors.json. If it stopped
// agreeing with the server's signer byte for byte, the docs build would fail.
//
// Use @kytrix/sdk if you are on Node — it does all of this for you. This exists for anyone who
// is not, and as the specification in executable form.
import { createHash, createHmac, randomBytes } from 'node:crypto';
export const SCHEME = 'KYTRIX-HMAC-SHA256';
export const SIGNATURE_VERSION = 'v1';
/** RFC 3986 unreserved characters: the only bytes that stay bare in a canonical query. */
const UNRESERVED = /^[A-Za-z0-9\-._~]$/;
/** `encodeURIComponent` leaves !'()* alone; RFC 3986 does not. */
export function rfc3986Encode(value: string): string {
return encodeURIComponent(value).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
/**
* Percent-encoding normalization (RFC 3986 §6.2.2): a %XX that encodes an unreserved character
* is decoded, every other %XX is upper-cased. Nothing else changes — no dot-segment removal,
* no "//" collapsing. The request-target is signed as sent.
*/
export function normalizePath(path: string): string {
const withoutQuery = path.split('#')[0]?.split('?')[0] ?? '';
const slashed = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`;
return slashed.replace(/%[0-9A-Fa-f]{2}/g, (match) => {
const char = String.fromCharCode(parseInt(match.slice(1), 16));
return UNRESERVED.test(char) ? char : match.toUpperCase();
});
}
/** application/x-www-form-urlencoded decoding; a malformed %XX is kept literally. */
function formDecode(value: string): string {
const spaced = value.replace(/\+/g, ' ');
try {
return decodeURIComponent(spaced);
} catch {
return spaced;
}
}
/**
* The canonical query line. Split on "&" (empty segments dropped), split each segment at its
* FIRST "=", form-decode both halves, re-encode with RFC 3986, then sort by encoded key and
* then by encoded value. Repeated keys are all kept. Returns "" when there is no query.
*/
export function canonicalQuery(rawQuery: string | undefined | null): string {
if (rawQuery === undefined || rawQuery === null) return '';
const raw = rawQuery.startsWith('?') ? rawQuery.slice(1) : rawQuery;
const pairs: [string, string][] = [];
for (const segment of raw.split('&')) {
if (segment.length === 0) continue;
const eq = segment.indexOf('=');
const key = eq === -1 ? segment : segment.slice(0, eq);
const value = eq === -1 ? '' : segment.slice(eq + 1);
pairs.push([rfc3986Encode(formDecode(key)), rfc3986Encode(formDecode(value))]);
}
pairs.sort((a, b) =>
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0,
);
return pairs.map(([key, value]) => `${key}=${value}`).join('&');
}
export interface SignInput {
/** The 32 raw bytes of your API secret. The API returns it once as base64url. */
secret: Uint8Array;
/** Your key id, e.g. the value shown in the console next to the secret. */
keyId: string;
method: string;
/** Request path only — no query, no fragment. */
path: string;
/** Raw query string, with or without a leading "?". */
query?: string;
/** The EXACT bytes you will put on the wire. Serialize once; sign and send the same buffer. */
body?: Uint8Array | string;
/** Unix SECONDS (not milliseconds). */
timestamp: number;
/** 1–64 characters of A-Z a-z 0-9 . _ ~ - , single-use for 10 minutes per key. */
nonce: string;
}
export interface SignedRequest {
headers: Record<string, string>;
/** The exact string that was signed — the first thing to print when a 401 surprises you. */
canonical: string;
signature: string;
bodySha256: string;
}
const NONCE_PATTERN = /^[A-Za-z0-9._~-]{1,64}$/;
/** 16 random bytes as base64url. A fresh one per attempt, including per retry. */
export function generateNonce(): string {
return Buffer.from(randomBytes(16)).toString('base64url');
}
/** Decodes the base64url secret the API issued into the raw bytes the HMAC needs. */
export function decodeSecret(secret: string): Uint8Array {
if (!/^[A-Za-z0-9_-]+$/.test(secret)) {
throw new TypeError('secret must be base64url (A-Z a-z 0-9 - _), no padding');
}
return new Uint8Array(Buffer.from(secret, 'base64url'));
}
export function signRequest(input: SignInput): SignedRequest {
if (!NONCE_PATTERN.test(input.nonce)) {
throw new TypeError('nonce must be 1–64 characters of A-Z a-z 0-9 . _ ~ -');
}
const bodyBytes =
input.body === undefined
? new Uint8Array(0)
: typeof input.body === 'string'
? new TextEncoder().encode(input.body)
: input.body;
const bodySha256 = createHash('sha256').update(bodyBytes).digest('hex');
// The eight canonical lines, joined by "\n". No trailing newline.
const canonical = [
SCHEME,
input.method.toUpperCase(),
normalizePath(input.path),
canonicalQuery(input.query),
bodySha256,
String(input.timestamp),
input.nonce,
input.keyId,
].join('\n');
const signature = createHmac('sha256', input.secret).update(canonical, 'utf8').digest('hex');
return {
headers: {
'Kytrix-Key-Id': input.keyId,
'Kytrix-Timestamp': String(input.timestamp),
'Kytrix-Nonce': input.nonce,
'Kytrix-Signature': `${SIGNATURE_VERSION}=${signature}`,
},
canonical,
signature,
bodySha256,
};
}Using it:
import { signRequest, generateNonce, decodeSecret } from './sign-request.js';
const secret = decodeSecret(process.env.KYTRIX_API_SECRET!); // base64url → 32 raw bytes
const keyId = process.env.KYTRIX_KEY_ID!;
const baseUrl = process.env.KYTRIX_BASE_URL!; // https://api.<your-domain>
// Serialize ONCE.
const bodyBytes = Buffer.from(JSON.stringify(movementEvent), 'utf8');
const { headers, canonical } = signRequest({
secret,
keyId,
method: 'POST',
path: '/v1/movements',
body: bodyBytes,
timestamp: Math.floor(Date.now() / 1000),
nonce: generateNonce(), // a FRESH nonce per attempt, including per retry
});
const response = await fetch(`${baseUrl}/v1/movements`, {
method: 'POST',
headers: {
...headers,
'content-type': 'application/json',
'idempotency-key': outboxRow.id, // see "Idempotency" below
},
body: bodyBytes, // the SAME buffer that was hashed
});
if (response.status === 401) {
// The one debugging aid worth keeping: print the string you signed and compare it, line by
// line, with the canonical string from a vector. Nine failures out of ten are line 3, 4 or 5.
console.error(JSON.stringify(canonical));
}#Published test vectors
Assert against these before you send anything real. They are the shared contract: @kytrix/signing, @kytrix/sdk, the conformance CLI and the edge verifier all test against this same file, so matching them means matching the server.
Source: packages/signing/vectors/request-vectors.json — 8 vectors, scheme KYTRIX-HMAC-SHA256.
| Vector | Covers |
|---|---|
post-json-body | POST with a JSON body and no query string. |
get-sorted-query | GET with a query whose keys must be sorted; no body (SHA-256 of ""). |
get-unsorted-repeated-keys | Repeated keys are kept and sorted by key, then by value. |
encoding-normalization | Path: %2f upper-cased, %7E/%41 decoded (unreserved). Query: space/"+" -> %20, "/" stays %2F, ~ decoded. |
empty-body-lowercase-method | Lower-case method is upper-cased; "?" with nothing after it is the empty query; "" body hashes like no body. |
unicode-and-bare-keys | UTF-8 in query value and body; a bare key has the empty value; empty values are kept. |
max-length-nonce-delete | DELETE with a 64-character nonce (the maximum). |
url-input-with-encoded-cursor | Signed from an absolute URL: only path and query are signed; %3D stays encoded. |
Worked example — the post-json-body vector, in full:
secret (hex) 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
key id kx_test_0123456789abcdef
method POST
path /v1/movements
query (none)
body {"external_id":"mv-1","amount":{"value":"1800000","asset":"DOP","scale":2}}
timestamp 1767225600
nonce n-0001KYTRIX-HMAC-SHA256
POST
/v1/movements
45d2ce24e38a849ef25a5f7bb24f6466a5bda773e27fb14cc6294f51a723b928
1767225600
n-0001
kx_test_0123456789abcdefbody sha256 45d2ce24e38a849ef25a5f7bb24f6466a5bda773e27fb14cc6294f51a723b928
signature 9de89513533f95f91547633de1345a2e8f83306d7cdcb949a38707651ea80f9d
kytrix-key-id: kx_test_0123456789abcdef
kytrix-timestamp: 1767225600
kytrix-nonce: n-0001
kytrix-signature: v1=9de89513533f95f91547633de1345a2e8f83306d7cdcb949a38707651ea80f9dA vector-driven test in your own repository is worth writing on day one — the whole file is machine-readable and every vector carries its inputs, its canonical string, its body hash, its signature and its headers.
#When a request is refused
Checks run in this order, and the first failure is the one you get back:
| Check | Code |
|---|---|
| Any of the four headers missing or empty | kytrix:auth/unauthenticated |
| Timestamp malformed, or more than 300 s from KYTRIX time | kytrix:auth/timestamp_skew |
Nonce malformed, signature not v1=<64 hex>, or the HMAC does not match | kytrix:auth/invalid_signature |
| Nonce already used for this key within 600 s | kytrix:auth/nonce_replayed |
- The nonce is claimed after the signature verifies, so a forged request cannot burn a real client’s nonce.
invalid_signatureon every request usually means line 3, 4 or 5 of the canonical string: an unnormalized path, an unsorted query, or a body that was re-serialized between signing and sending.invalid_signatureon *some* requests usually means a query string — check the sorting and the encoding of repeated keys.- Retrying is not a fix for a 401. Re-sign with a fresh nonce and a correct clock.
#The SDK path (TypeScript / Node)
@kytrix/sdk handles the four things that are easy to get subtly wrong — signing, idempotency, retries and webhook verification — and its request and response types are inferred from the same frozen schemas the server validates against. Node 22+, ESM, no runtime dependencies outside the workspace.
import { KytrixClient, money, SCHEMA_VERSION } from '@kytrix/sdk';
export const kytrix = new KytrixClient({
baseUrl: process.env.KYTRIX_BASE_URL!, // https://api.<your-domain>
keyId: process.env.KYTRIX_KEY_ID!, // kx_test_… in the sandbox
secret: process.env.KYTRIX_API_SECRET!, // base64url, shown once at creation
});
const receipt = await kytrix.movements.submit({
external_id: 'ledger-8842', // YOUR id. KYTRIX dedupes on it.
schema_version: SCHEMA_VERSION, // '2026-09-01'
occurred_at: '2026-09-02T09:15:00-04:00', // when it happened, with the offset
recorded_at: '2026-09-02T09:15:02-04:00', // optional: when YOUR ledger wrote it
data: {
type: 'p2p',
status: 'completed',
amount: money.dop('1800.00'), // → { value: '180000', asset: 'DOP', scale: 2 }
debit: { account_id: 'wallet-1', party_id: 'user-1' },
credit: { counterparty: { institution: 'BPD', country: 'DO', name_as_given: 'J. Gómez' } },
cash: false,
channel: 'app',
memo: 'rent august', // untrusted text: never a card number
},
}, { idempotencyKey: outboxRow.id });
receipt.event_id; // 'evt_01k4…' — quote this in support tickets
receipt.idempotent_replay; // true when this exact event was already acceptedThe other five families are identical — kytrix.parties.submit, accounts, instruments, relationships, controlEvents — and a mapper that loops over rows can use kytrix.submitEvent(objectType, event). Dry-run and batch:
// Same validation as the live endpoint; stores nothing.
const report = await kytrix.validate({ object: 'movement', event: movementEvent });
if (!report.valid) {
console.error(report.results.filter((r) => r.outcome === 'rejected'));
}
// Up to 1000 mixed events. Acceptance is atomic, validation is per item:
// one bad row never loses the other 999.
const result = await kytrix.batches.submit({ events: [
{ object: 'party', event: partyEvent },
{ object: 'movement', event: movementEvent },
]});
result.accepted;
result.results.filter((r) => r.outcome === 'rejected'); // each has { index, problem }What the client covers, and what it deliberately does not:
| Covered | Not covered (use `kytrix.raw(...)`) |
|---|---|
All six ingest families · batch · dry-run · event status · canonical reads · posture · alerts and episodes · reconciliations · webhook endpoints and deliveries · healthz | Historical imports (POST /v1/imports, chunks, rejects) · API-key lifecycle · every /console/v1/* surface (those are OIDC console APIs, not API-key APIs) |
#The raw HTTP path
Nothing about the API requires the SDK. Sign the request, send JSON, read problem+json back. Here is a complete first movement with no dependencies beyond the reference signer above.
import { signRequest, generateNonce, decodeSecret } from './sign-request.js';
const baseUrl = process.env.KYTRIX_BASE_URL!;
const keyId = process.env.KYTRIX_KEY_ID!;
const secret = decodeSecret(process.env.KYTRIX_API_SECRET!);
const event = {
external_id: 'ledger-8842',
schema_version: '2026-09-01',
occurred_at: '2026-09-02T09:15:00-04:00',
data: {
type: 'p2p',
status: 'completed',
amount: { value: '180000', asset: 'DOP', scale: 2 }, // 1,800.00 DOP
debit: { account_id: 'wallet-1', party_id: 'user-1' },
credit: { account_id: 'wallet-2', party_id: 'user-2' },
},
};
const bodyBytes = Buffer.from(JSON.stringify(event), 'utf8');
const { headers } = signRequest({
secret, keyId,
method: 'POST',
path: '/v1/movements',
body: bodyBytes,
timestamp: Math.floor(Date.now() / 1000),
nonce: generateNonce(),
});
const response = await fetch(`${baseUrl}/v1/movements`, {
method: 'POST',
headers: { ...headers, 'content-type': 'application/json', 'idempotency-key': 'ledger-8842' },
body: bodyBytes,
});
const payload = await response.json();
if (response.status !== 202) {
// RFC 9457 problem+json. Branch on payload.code, never on payload.detail.
throw new Error(`${payload.code}: ${payload.detail ?? payload.title}`);
}
console.log(payload.event_id, payload.idempotent_replay);On the wire, that request looks like this:
POST /v1/movements HTTP/1.1
Host: api.<your-domain>
Content-Type: application/json
Kytrix-Key-Id: kx_test_<your-key-id>
Kytrix-Timestamp: 1788353711
Kytrix-Nonce: <22 base64url chars, single-use>
Kytrix-Signature: v1=<64 lower-case hex characters>
Idempotency-Key: ledger-8842
Kytrix-Correlation-Id: <your trace id>
{"external_id":"ledger-8842","schema_version":"2026-09-01", ...}HTTP/1.1 202 Accepted
Content-Type: application/json
Kytrix-RateLimit-Limit: 2000
Kytrix-RateLimit-Remaining: 1999
Kytrix-RateLimit-Reset: 1
{"event_id":"evt_01k4f2b9m7q0z8t3d5r6y7w8xc","object":"movement",
"external_id":"ledger-8842","received_at":"2026-09-05T14:22:11.412Z",
"idempotent_replay":false}Curl works too, once you have computed the signature elsewhere:
curl -sS -X POST "$KYTRIX_BASE_URL/v1/movements" \
-H "content-type: application/json" \
-H "kytrix-key-id: $KYTRIX_KEY_ID" \
-H "kytrix-timestamp: $TS" \
-H "kytrix-nonce: $NONCE" \
-H "kytrix-signature: v1=$SIG" \
-H "idempotency-key: ledger-8842" \
--data-binary @movement.jsonNote --data-binary @file rather than -d: -d strips newlines, which changes the bytes and therefore the body hash.
#Money
| Amount | Correct JSON |
|---|---|
| 1,800.00 DOP | {"value":"180000","asset":"DOP","scale":2} |
| 42.07 USD | {"value":"4207","asset":"USD","scale":2} |
| 0.05 USD | {"value":"5","asset":"USD","scale":2} |
| −250.00 DOP (a reversal leg) | {"value":"-25000","asset":"DOP","scale":2} |
| 7 JPY (scale 0) | {"value":"7","asset":"JPY","scale":0} |
| A non-ISO asset | {"value":"150000000","asset":"X-USDT","scale":6} |
And the ways it goes wrong:
| Wrong | Why it is refused |
|---|---|
{"value":1800.00,"asset":"DOP","scale":2} | A JSON number. value must be a string — kytrix:validation/schema. |
{"value":"1800.00","asset":"DOP","scale":2} | Minor units, not a decimal. "1800.00" is not an integer string. |
{"value":"1800","asset":"DOP","scale":2} | Accepted, and wrong: this is 18.00 DOP. Nothing can detect this for you — it is why the conversion belongs in one tested helper. |
{"value":"180000","asset":"DOP"} | scale is required. It must match the asset registry; a mismatch is kytrix:validation/money_scale_mismatch. |
{"value":"180000.5","asset":"DOP","scale":2} | More precision than the asset has. KYTRIX refuses rather than rounding your money. |
The safest pattern is to convert in exactly one place, with exact arithmetic, and to refuse rather than round. If you are on Node, use the SDK helpers:
import { money } from '@kytrix/sdk';
money.dop('1800.00') // { value: '180000', asset: 'DOP', scale: 2 }
money.usd('42.07') // { value: '4207', asset: 'USD', scale: 2 }
money.minor('180000', 'DOP', 2) // when your ledger already stores minor units
money.decimal('7', 'JPY', 0) // scale-0 assets
money.add(money.dop('10.10'), money.dop('0.20')) // exact — BigInt inside
money.sum(fees) // throws if assets or scales differ
money.toDecimalString(m) // '1800.00' — for DISPLAY only
money.parse(rowFromYourDb) // validate untrusted input
money.dop('1800.005') // THROWS. More precision than DOP has is a mapping bug,
// not a rounding decision for KYTRIX to make.Without the SDK, the conversion is a decimal-string shift, not a multiplication: split on ., right-pad the fraction to scale digits, refuse if it was longer, concatenate, and keep the sign. Never Math.round(amount * 100) — Math.round(1.005 * 100) is 100, and you will not find out for months.
#Timestamps
Every timestamp at the API is RFC 3339 with an explicit offset or Z. The colon in the offset is required (-04:00, not -0400), and lowercase t/z are refused — normalise on your side rather than letting two spellings of the same instant into a system that reconstructs decisions years later.
| Field | Who sets it | Meaning |
|---|---|---|
occurred_at | You | Event time — when the thing happened in the real world. This is what every detector window is measured against. Required on every envelope. |
recorded_at | You | Ledger time — when your system wrote the record. Optional, and worth sending: the gap between it and occurred_at is how a lagging feed becomes visible instead of silently skewing baselines. |
received_at | KYTRIX | When KYTRIX committed the event. It is on the receipt and on the event-status response. You never send it. |
occurred_atis the customer’s truth about the world. Backfilling last month’s movements with today’soccurred_atdestroys the history the baselines are built from — send the real instants and use the import endpoints for history.- A future
occurred_atbeyond the tolerated skew is rejected withkytrix:validation/timestamp_out_of_range. - Prefer a fixed offset over an IANA name for anything you generate. The Dominican Republic has no DST, so
-04:00is exact and reproducible year-round. - Windows in KYTRIX are typed — rolling (
PT2H), calendar (day/month in a named zone) or business-calendar — so a "daily" threshold means one specific thing rather than whatever the reader assumed.
{
"occurred_at": "2026-09-02T09:15:00-04:00", // good: explicit offset
"recorded_at": "2026-09-02T13:15:02Z" // good: Z, a different clock, same instant
}
// Refused:
// "2026-09-02T09:15:00" no offset — which 09:15?
// "2026-09-02T09:15:00-0400" offset without a colon
// "2026-09-02t09:15:00z" lowercase designators
// 1788353711 an epoch number is not RFC 3339#Idempotency and retries
Send an Idempotency-Key header on every write. It is scoped to (tenant, endpoint, key) and remembered for at least 24 hours, and it is what makes a lost response safe to retry.
| Situation | What happens |
|---|---|
| Same key, same body | The stored response is returned verbatim, with Idempotent-Replayed: true. Nothing is processed twice. |
| Same key, different body | 409 kytrix:idempotency/key_conflict. Use a new key, or resend the original body. |
| Key reused after it expired | Treated as a new request. Keys are retained ≥ 24 h. |
| Two concurrent requests, same key | First commit wins; the others block briefly on a per-key lock and then replay the stored response. Both callers get identical receipts and only one set of rows exists. |
| A batch | The key covers the batch acceptance. Per-event dedup still applies through the external ids. |
| No key at all | The write still happens, but a lost response is no longer safe to retry — you cannot tell a timeout from a success. |
Idempotency keys and the dedup keys built from your external_ids are two different safety nets, and both are working:
- The idempotency key protects the HTTP call: a retry after a lost response replays the original result.
- The dedup key protects the data: the same event submitted twice, even under a different idempotency key, is coalesced onto the original
event_idand comes back withidempotent_replay: true. - Together they mean a crash after the commit but before your client saw the response cannot create a second movement. This is tested end to end, with a fault injected on both sides of the COMMIT.
What to retry, and what never to retry:
- Retry
429,500,502,503,504and transport failures — only when the request carried anIdempotency-Key. Exponential backoff with full jitter; honourRetry-After. - Never retry
400,403,409or413. They cannot change on repeat. - Never retry a keyless write. A duplicated movement is worse than a failure you can see.
- Generate a fresh nonce for every attempt, including retries. Re-sending captured headers is
kytrix:auth/nonce_replayed.
#Duplicates, versions and corrections
Your external_id is the primary identity in every customer-facing surface. How resubmission behaves depends on whether the object is an entity or a fact.
| Situation | Outcome |
|---|---|
| Same identity, byte-identical content | duplicate — acknowledged and coalesced onto the ORIGINAL event_id. No new rows. The content hash is computed over the canonical JSON form, so key order and whitespace never make a duplicate look new. |
Entity (party, account, instrument, relationship) with a higher sequence | accepted — a new version. Versions slot by sequence, and every version is kept. |
Entity with the same sequence but different content | 409 kytrix:sequence/conflict, path event.sequence. Never a silent overwrite. |
| Fact (movement, control_event), any differing resubmission | 409 kytrix:sequence/conflict, path event.external_id. Facts are immutable. |
Entity families need a sequence per version. If your mapper omits it, only the first version of each entity ever lands — which looks like everything working until someone asks why a customer’s KYC tier never changed.
{
"external_id": "ledger-8842-corr-1",
"schema_version": "2026-09-01",
"occurred_at": "2026-09-02T09:15:00-04:00",
"data": {
"type": "p2p",
"status": "completed",
"amount": {
"value": "180000",
"asset": "DOP",
"scale": 2
},
"debit": {
"account_id": "wallet-1",
"party_id": "user-1"
},
"credit": {
"account_id": "wallet-2",
"party_id": "user-2"
},
"links": {
"correction_of": "ledger-8842"
},
"correction_reason": "debit account was mapped from the wrong column"
}
}#Errors
Every rejection is RFC 9457 application/problem+json carrying a code from a governed catalog. Branch on the code. Never on the message. detail is prose written to help a human fix a row and it changes as the wording improves; code is governed and is never repurposed.
{
"type": "kytrix:validation/schema",
"title": "Request does not match the schema",
"status": 400,
"code": "kytrix:validation/schema",
"detail": "data.amount.value: must be a string-encoded signed integer of minor units",
"instance": "/v1/movements",
"errors": [
{
"path": "data.amount.value",
"code": "kytrix:validation/schema",
"message": "must be a string-encoded signed integer of minor units"
}
],
"correlation_id": "6f9f0f2e-6b0e-4a1d-9d2f-2f6c3f2a1b77"
}errors[].path is a dotted path into the request body with numeric array indices (events.3.event.data.memo), so a batch reject points at the exact row and field. The full catalog, with which codes are retryable, is on the error catalog page.
#Receiving webhooks
Alerts arrive as signed HTTP POSTs to endpoints you register with a webhook_mgmt-scoped key. Three things matter on day one: verify over the raw bytes, expect replays, and pick the right channel.
const created = await kytrix.webhookEndpoints.create({
url: 'https://paydoly.example/kytrix/webhooks', // HTTPS only; private ranges refused
channel: 'compliance', // restricted AML content goes here
event_types: ['alert.opened', 'episode.escalated'],
});
await storeSecret(created.endpoint_id, created.secret); // this is your ONLY chance
await kytrix.webhookEndpoints.test(created.endpoint_id); // a real signed round tripVerification, dependency-free — this exact file is run by the docs build against the published webhook vectors:
// KYTRIX webhook verification — a complete, dependency-free reference implementation.
//
// Published verbatim on docs.<domain> AND executed by the docs build against every vector in
// packages/signing/vectors/webhook-vectors.json, accept and reject cases alike. If this stopped
// agreeing with the server's signer, the docs build would fail.
//
// @kytrix/sdk's `WebhookReceiver` does this plus envelope validation, channel enforcement and
// replay memory. This is for anyone not on Node, and as the specification in executable form.
import { createHmac, timingSafeEqual } from 'node:crypto';
/** KYTRIX's default tolerance on the signature timestamp. */
export const DEFAULT_TOLERANCE_SECONDS = 300;
export interface VerifyWebhookInput {
/** The `Kytrix-Webhook-Signature` header, verbatim. */
header: string;
/** The EXACT bytes received. Never re-serialize the parsed JSON — the bytes would change. */
rawBody: Uint8Array;
/** Your endpoint secrets. During rotation: [current, previous] — either may match. */
secrets: Uint8Array[];
/** Current time in unix SECONDS. */
nowSeconds: number;
toleranceSeconds?: number;
}
export type VerifyWebhookResult =
| { ok: true; timestamp: number }
| { ok: false; reason: 'malformed' | 'timestamp_out_of_tolerance' | 'no_matching_signature' };
/**
* Signature header: `t=<unix seconds>,v1=<hex>[,v1=<hex>]`.
* Signed payload: the bytes of `"<t>."` followed by the raw body bytes.
* A delivery verifies when ANY of your secrets matches ANY `v1=` entry within the tolerance.
*/
export function verifyWebhook(input: VerifyWebhookInput): VerifyWebhookResult {
const tolerance = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
let timestamp: string | undefined;
const candidates: string[] = [];
for (const part of input.header.split(',')) {
const trimmed = part.trim();
const eq = trimmed.indexOf('=');
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (key === 't' && timestamp === undefined) timestamp = value;
else if (key === 'v1') candidates.push(value);
}
if (timestamp === undefined || !/^\d{1,12}$/.test(timestamp) || candidates.length === 0) {
return { ok: false, reason: 'malformed' };
}
const t = Number(timestamp);
if (Math.abs(input.nowSeconds - t) > tolerance) {
return { ok: false, reason: 'timestamp_out_of_tolerance' };
}
const prefix = new TextEncoder().encode(`${timestamp}.`);
const signed = new Uint8Array(prefix.length + input.rawBody.length);
signed.set(prefix, 0);
signed.set(input.rawBody, prefix.length);
for (const secret of input.secrets) {
const expected = createHmac('sha256', secret).update(signed).digest();
for (const candidate of candidates) {
if (!/^[0-9a-fA-F]+$/.test(candidate) || candidate.length !== expected.length * 2) continue;
const got = Buffer.from(candidate, 'hex');
// Constant-time: a byte-by-byte early exit leaks the prefix a forger already guessed.
if (got.length === expected.length && timingSafeEqual(got, expected)) {
return { ok: true, timestamp: t };
}
}
}
return { ok: false, reason: 'no_matching_signature' };
}- Verify over the raw body bytes. Parsing and re-serializing changes the bytes and breaks the MAC. In Express that means
express.raw({ type: "*/*" })on this route. - A replay is expected. Delivery is at-least-once; dedupe on the envelope
id(also available as theKytrix-Webhook-Idheader) and acknowledge a repeat with2xxwithout reprocessing it. - Rotation overlaps. After
POST /v1/webhook-endpoints/{id}/rotate-secret, both secrets sign every delivery untilprevious_secret_valid_until. Deploy the two-secret configuration *before* you rotate, and verify against[new, previous]through the overlap. - The channel is a compliance control.
complianceendpoints receive restricted AML content;operationalendpoints receive only sanitized events with no typology names or narratives. Pointing an operational endpoint at a support tool is a tipping-off risk, and subscribing a restricted event on one is refused at registration. - Acknowledge fast and process asynchronously. A slow consumer is indistinguishable from a failing one, and non-2xx responses are retried for at least 24 hours before dead-lettering.
The full event catalog, headers, vectors and rotation walkthrough: Webhooks.
#Proving it: the conformance CLI
kytrix-conform is what proves your integration is correct before you send real traffic. It exercises the raw server behaviour with no retries, no backoff and no automatic idempotency key — all of which the SDK adds and all of which would mask the failures this tool exists to find.
export KYTRIX_API_SECRET='…' # never pass the secret on the command line in CI
node dist/cli.js \
--base-url https://api.<your-domain> \
--key-id kx_test_<your-sandbox-key> \
--json conformance-report.jsonKYTRIX conformance suite
target https://api.<your-domain>
key kx_test_… (sandbox)
run 6v27puw7 37 checks
PASS auth.signed_request_accepted A correctly signed request is accepted
PASS auth.unsigned_rejected An unsigned request is refused
…
FAIL idempotency.exact_replay Replaying a request with the same key returns the original result
expected the replay returns the ORIGINAL event_id (evt_01k4…)
received a different event_id (evt_01k5…) — the request was processed twice
fix A retry after a lost response must never create a second event; scope the
stored response by (tenant, endpoint, key) and return it verbatim (06 H.4).
defined 06 H.4 (exact replay → original result + `Idempotent-Replayed: true`)
36 passed · 1 failed · 0 skipped (33/34 required) in 4.1s
NOT CONFORMANT 1 required check(s) failed — the Connect stage gate (22 X.1) is not metEvery failure states what was expected, what was received, how to fix it, and which specification section defines the rule. The verdict is three-way:
| Verdict | Exit | Meaning |
|---|---|---|
CONFORMANT | 0 | Every required check passed. The Connect gate is met. |
INCOMPLETE | 1 | No required check failed, but some could not be run — no webhook receiver, a missing scope, --dry-run-only. "We could not check that" is never a green light. |
NOT CONFORMANT | 1 | A required check failed. |
| — | 2 | The suite could not run at all: bad usage, a refused credential, an unusable base URL. |
What it certifies — 37 checks in seven groups (34 required, 3 advisory, 14 of which write into the tenant):
| Group | Proves |
|---|---|
auth | A correct signature is accepted; unsigned, wrong-signature, tampered-body, stale-clock and replayed-nonce requests are all refused with the right code — and that the ±150 s window is real, not zero. |
validation | All six families dry-run clean; a PAN in a memo is refused with its field path; amount.value as a JSON number is refused; an unknown schema_version and an unrecognised envelope member are loud rejects; a malformed body is a governed problem, not a stack trace; a dry-run stores nothing. |
events | Each family returns a 202 with a receipt echoing the object and external id; GET /v1/events/{id} answers immediately after the 202 with stage_history; an unknown id is a governed 404. |
idempotency | Exact replay returns the ORIGINAL result with Idempotent-Replayed: true; same key + different body is a 409; the same event under a *different* key coalesces onto the original event id; a same-sequence conflict is refused rather than silently overwritten. |
batch | One bad row among three yields 202 / accepted: 2 / rejected: 1 with the reject carrying its index and field path; every accepted item gets its own receipt; 1,001 events is a 413. |
reads | *(advisory)* Posture returns a band and no reasons; an unknown external id is a governed 404, not an empty 200. Advisory because canonical reads are eventually consistent. |
webhooks | An SSRF endpoint is refused; a restricted event on an operational channel is refused; registration returns the secret once and lists; and a signed webhook.test round trip verifies with that secret over the raw bytes. |
The last two webhook checks need somewhere to deliver to. Either point it at your own endpoint, or let the CLI serve one and front it with your usual ingress:
# your own receiver
node dist/cli.js … --webhook-receiver https://paydoly.example/kytrix/conformance
# or the CLI's, behind your tunnel (KYTRIX refuses plaintext and private-range endpoints)
node dist/cli.js … --webhook-listen 8477 --webhook-receiver https://tunnel.example/hookWithout them both checks SKIP and the run is INCOMPLETE rather than conformant. Other useful flags: --only <ids-or-groups> to narrow a run, --dry-run-only to skip every check that writes (also reported as INCOMPLETE), --timeout <ms>, and --allow-live if you really mean to point it at a kx_live_… credential.
- It refuses production credentials by default — a key id that is not
kx_test_…stops the run. - It never deletes anything and never touches data it did not create. Objects it writes are named
conf-<run>-…, unique per run, so re-running is harmless. - Webhook endpoints it registers are
operationaland subscribed towebhook.testonly, so a throwaway receiver can never be handed restricted AML content — and they are disabled again when the run finishes. - The secret is never echoed, logged, or written into the report. Prefer
KYTRIX_API_SECRETover--secretso it does not land in shell history or CI logs.
Known gaps, so nobody discovers them at 2 a.m.: there are no import/backfill checks (that is the Backfill stage, not the Connect gate), no scope-matrix check (proving "an ingest key cannot read alerts" needs a second credential), no concurrency check for the idempotency matrix, no enterprise-tier signature check, no real restricted_aml delivery check, and no rate-limit behaviour under load — deliberately, since a conformance run must not be a load test against a shared sandbox.
#Before you point production at it
- Signing verified against the published vectors in your own test suite, not just once by hand.
- NTP running on every host that signs.
- The secret in a secret manager. Not in git, not in an environment file committed by accident, not in a ticket.
- One money-conversion helper, with tests, including a case that must throw rather than round.
Idempotency-Keyderived from a durable id, stable across retries.- A fresh nonce per attempt.
- Error handling that branches on
code. - Webhook verification over raw bytes, replay-tolerant, with the two-secret rotation path deployed before you need it.
external_idvalues that are stable forever — they are the identity KYTRIX dedupes and joins on, and they never change.sequencepresent on every entity family event.- Failed, pending and reversed movements being sent, not filtered out.
- Conformance
CONFORMANT, with the report archived.
#What is not available yet
Stated plainly, so you plan around it rather than discovering it:
- API-key lifecycle endpoints (create, rotate, revoke) do not exist. Keys are issued from the console.
GET /v1/coverageandGET /v1/usageare described in the architecture but have no server route.GET /v1/rulesets/activeis implemented, but it is composed into the core service, not the public API host — so it is not reachable onapi.<domain>in the current deployment.- Historical imports have no SDK methods. The endpoints exist (
POST /v1/imports, chunk upload, dry-run, submit, rejects); usekytrix.raw(...)or plain HTTP. They are the Backfill stage, not the Connect stage. POST /v1/webhook-endpoints/{id}/testcannot reach your endpoint in the current VPS deployment: it is the only outbound request the API host makes, and that container has no egress yet. It fails visibly with a 502 rather than silently. Verify signatures against the published vectors in the meantime.- Enterprise-tier signing (Ed25519 / RFC 9421) is deferred. The verifier fails that branch closed.
- Idempotency keys are honoured only by the ingest endpoints today. Reconciliations, webhook endpoint management and delivery replay accept no key and are therefore never retried automatically.
- Four response shapes — the dry-run response, the event-status body, webhook endpoint creation/rotation, and delivery rows — are not defined in
@kytrix/contracts, so the API reference mirrors them from the route handlers rather than generating them. They are marked as such wherever they appear.