KYTRIX Developer documentation Financial Crime Control Plane

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.

WhatLooks likeNotes
API base URLhttps://api.<your-domain>The public API host. The console lives on a different host; do not send API traffic there.
Key idkx_test_… / kx_live_…Sent as Kytrix-Key-Id. kx_test_ is a sandbox credential — start there.
Key secret32 bytes, base64urlShown exactly once, at creation or rotation. Straight into your secret manager; never into git, a ticket, or a log line.
Scopesingest, read, webhook_mgmtSeparate 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.

FamilyEndpointWhat it is
partyPOST /v1/partiesA customer, business, agent or institution you onboarded.
accountPOST /v1/accountsA wallet or ledger account held by a party.
instrumentPOST /v1/instrumentsA card token, device or phone number.
movementPOST /v1/movementsA value transfer — including failed, pending and reversed ones. Blocked attempts are evidence, not noise.
relationshipPOST /v1/relationshipsA declared link: ownership, signatory, shared device.
control_eventPOST /v1/control-eventsSomething your own controls did: a blocked withdrawal, a limit hit, a manual review.
  1. Sign a request and get a 200. Nothing else works until this does. Verify against the published test vectors first, offline.
  2. Dry-run one event of each family. POST /v1/validate runs the exact validation the live endpoints run and stores nothing. Iterate here, not against the live endpoint.
  3. Send one real movement and follow it with GET /v1/events/{event_id}.
  4. Turn on your mapper and batch, up to 1000 mixed events per request.
  5. Register a webhook endpoint and verify a signed delivery end to end.
  6. 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.

HeaderValue
Kytrix-Key-IdYour key id, e.g. kx_test_….
Kytrix-TimestampUnix seconds, decimal. Not milliseconds.
Kytrix-Nonce1–64 characters of A-Z a-z 0-9 . _ ~ -. Single-use per key for 600 seconds.
Kytrix-Signaturev1=<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.

#LineRule
1KYTRIX-HMAC-SHA256The literal scheme label.
2methodUpper-cased.
3pathPath 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.
4canonical queryEmpty 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.
5body hashLower-case hex SHA-256 of the raw body bytes. For a body-less request, the SHA-256 of the empty string: e3b0c442…b855.
6timestampExactly the Kytrix-Timestamp value you send.
7nonceExactly the Kytrix-Nonce value you send.
8key idExactly 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.

typescript — sign-request.ts
// 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:

typescript
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.

VectorCovers
post-json-bodyPOST with a JSON body and no query string.
get-sorted-queryGET with a query whose keys must be sorted; no body (SHA-256 of "").
get-unsorted-repeated-keysRepeated keys are kept and sorted by key, then by value.
encoding-normalizationPath: %2f upper-cased, %7E/%41 decoded (unreserved). Query: space/"+" -> %20, "/" stays %2F, ~ decoded.
empty-body-lowercase-methodLower-case method is upper-cased; "?" with nothing after it is the empty query; "" body hashes like no body.
unicode-and-bare-keysUTF-8 in query value and body; a bare key has the empty value; empty values are kept.
max-length-nonce-deleteDELETE with a 64-character nonce (the maximum).
url-input-with-encoded-cursorSigned from an absolute URL: only path and query are signed; %3D stays encoded.

Worked example — the post-json-body vector, in full:

text — inputs
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-0001
text — the canonical string (8 lines, "\n" separated, no trailing newline)
KYTRIX-HMAC-SHA256
POST
/v1/movements

45d2ce24e38a849ef25a5f7bb24f6466a5bda773e27fb14cc6294f51a723b928
1767225600
n-0001
kx_test_0123456789abcdef
text — expected output
body sha256   45d2ce24e38a849ef25a5f7bb24f6466a5bda773e27fb14cc6294f51a723b928
signature     9de89513533f95f91547633de1345a2e8f83306d7cdcb949a38707651ea80f9d

kytrix-key-id: kx_test_0123456789abcdef
kytrix-timestamp: 1767225600
kytrix-nonce: n-0001
kytrix-signature: v1=9de89513533f95f91547633de1345a2e8f83306d7cdcb949a38707651ea80f9d

A 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:

CheckCode
Any of the four headers missing or emptykytrix:auth/unauthenticated
Timestamp malformed, or more than 300 s from KYTRIX timekytrix:auth/timestamp_skew
Nonce malformed, signature not v1=<64 hex>, or the HMAC does not matchkytrix:auth/invalid_signature
Nonce already used for this key within 600 skytrix:auth/nonce_replayed

#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.

typescript
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 accepted

The 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:

typescript
// 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:

CoveredNot covered (use `kytrix.raw(...)`)
All six ingest families · batch · dry-run · event status · canonical reads · posture · alerts and episodes · reconciliations · webhook endpoints and deliveries · healthzHistorical 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.

typescript — first-movement.ts
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:

http
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
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:

bash
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.json

Note --data-binary @file rather than -d: -d strips newlines, which changes the bytes and therefore the body hash.

#Money

AmountCorrect 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:

WrongWhy 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:

typescript
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.

FieldWho sets itMeaning
occurred_atYouEvent time — when the thing happened in the real world. This is what every detector window is measured against. Required on every envelope.
recorded_atYouLedger 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_atKYTRIXWhen KYTRIX committed the event. It is on the receipt and on the event-status response. You never send it.
json
{
  "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.

SituationWhat happens
Same key, same bodyThe stored response is returned verbatim, with Idempotent-Replayed: true. Nothing is processed twice.
Same key, different body409 kytrix:idempotency/key_conflict. Use a new key, or resend the original body.
Key reused after it expiredTreated as a new request. Keys are retained ≥ 24 h.
Two concurrent requests, same keyFirst 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 batchThe key covers the batch acceptance. Per-event dedup still applies through the external ids.
No key at allThe 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:

What to retry, and what never to retry:

#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.

SituationOutcome
Same identity, byte-identical contentduplicate — 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 sequenceaccepted — a new version. Versions slot by sequence, and every version is kept.
Entity with the same sequence but different content409 kytrix:sequence/conflict, path event.sequence. Never a silent overwrite.
Fact (movement, control_event), any differing resubmission409 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.

json — a correction
{
  "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.

json — a rejection
{
  "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.

typescript
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 trip

Verification, dependency-free — this exact file is run by the docs build against the published webhook vectors:

typescript — verify-webhook.ts
// 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' };
}

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.

bash
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.json
text
KYTRIX 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 met

Every failure states what was expected, what was received, how to fix it, and which specification section defines the rule. The verdict is three-way:

VerdictExitMeaning
CONFORMANT0Every required check passed. The Connect gate is met.
INCOMPLETE1No 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 CONFORMANT1A required check failed.
2The 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):

GroupProves
authA 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.
validationAll 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.
eventsEach 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.
idempotencyExact 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.
batchOne 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.
webhooksAn 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:

bash
# 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/hook

Without 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.

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

#What is not available yet

Stated plainly, so you plan around it rather than discovering it:

  1. API-key lifecycle endpoints (create, rotate, revoke) do not exist. Keys are issued from the console.
  2. GET /v1/coverage and GET /v1/usage are described in the architecture but have no server route.
  3. GET /v1/rulesets/active is implemented, but it is composed into the core service, not the public API host — so it is not reachable on api.<domain> in the current deployment.
  4. Historical imports have no SDK methods. The endpoints exist (POST /v1/imports, chunk upload, dry-run, submit, rejects); use kytrix.raw(...) or plain HTTP. They are the Backfill stage, not the Connect stage.
  5. POST /v1/webhook-endpoints/{id}/test cannot 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.
  6. Enterprise-tier signing (Ed25519 / RFC 9421) is deferred. The verifier fails that branch closed.
  7. 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.
  8. 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.