KYTRIX Developer documentation Financial Crime Control Plane

Your columns → the canonical schema

Field-mapping worksheet

Fill this in once per source file. The result is a mapping document the import API accepts as-is, and the same document drives both the dry-run and the real load.

Target paths generated from EVENT_SCHEMAS · rule and dialect keys generated from ImportMappingSchema · every template on this page is parsed by that schema at build time

#What you are filling in

A mapping file is one JSON document that says: this file is CSV or JSONL, every row of it becomes a <family> event, and here is which of your columns produces each canonical field. One file per family — transactions, customers, accounts and so on get their own mapping.

The same document drives the dry-run and the real import, and the two share one row engine — so "the dry run said it was fine" is a promise the import keeps.

  1. Export one file per family from your source system, with a header row. Keep the column names you actually have; do not rename them for us.
  2. Fill in a mapping from the starter template for that family below, replacing every UPPERCASE placeholder with your column name.
  3. Dry-run it against a sample: POST /v1/imports/dry-run with { mapping, sample }. Nothing is created, nothing is stored, no import row exists. This is the iteration loop.
  4. Read the reject report in the response, fix the mapping, repeat. A typo in a target path is refused before the import runs, with the nearby real fields listed.
  5. Create the import, upload chunks, dry-run the whole staged file, then submit. Rows that already landed come back as duplicates; nothing is double-posted.

#Anatomy of a mapping

json — the shape
{
  "mapping_version": "1",
  "family": "movement",
  "format": {
    "kind": "csv",
    "delimiter": ",",
    "quote": "\"",
    "has_header": true
  },
  "row_filter": {
    "from": "RECORD_TYPE",
    "op": "in",
    "values": [
      "TXN"
    ]
  },
  "fields": {
    "external_id": {
      "from": "TXN_ID"
    },
    "occurred_at": {
      "from": "POSTED_AT",
      "type": "datetime",
      "tz": "-04:00"
    }
  }
}
KeyRequiredMeaning
mapping_versionyesThe literal "1". Old mappings keep working under their version.
familyyesOne of party, account, instrument, movement, relationship, control_event. Every row of the file becomes this.
formatyesThe dialect — see below.
fieldsyesTarget path → rule. external_id and occurred_at are required; schema_version is filled in for you.
row_filternoRows that do not match are skipped — counted separately, never rejected. This is how one mixed-record extract is split per family.

#The format block

CSV keyRequiredMeaning
kindyesThe literal "csv".
delimiternoField separator. Must differ from quote.
quotenoQuote character.
has_headernoThe first record of chunk 0 is the header row. When false, columns is required.
header_per_chunknoEvery chunk repeats the header — some split tools do this.
columnsnoExplicit column names. The escape hatch for a header you cannot change; also overrides a present header.
skip_leading_rowsnoPhysical records to drop before the header (banner lines in bank extracts).
comment_prefixnoLines starting with this are skipped.
null_valuesnoSource values equal to one of these are treated as absent.
JSONL keyRequiredMeaning
kindyesThe literal "jsonl".
comment_prefixnoLines starting with this are skipped.
skip_leading_rowsnoPhysical lines to drop before the data.
null_valuesnoSource values equal to one of these are treated as absent. An explicit JSON null is already "absent".

#The fields rules

The key is a dotted path into the canonical envelope: external_id, occurred_at, recorded_at, sequence, schema_version, or any data.… path of the family’s schema. Valid paths are derived from the frozen event schemas at validation time, so a typo like data.ammount is refused before the import runs, with the nearby real fields listed. The value is one rule object:

KeyApplies toMeaning
fromall but moneySource column (CSV) or dotted path into the line’s JSON (JSONL); numeric segments index arrays (parties.0.id).
constall but moneyA literal written on every row. Mutually exclusive with from.
typeallstring (default) · integer · number · boolean · datetime · money · json.
optionalalltrue → an absent source leaves the target unset instead of rejecting the row.
defaultallValue used when the source is absent or empty. null means "leave unset".
trimallTrim the source value.
caseallupper or lower, applied before map.
mapallExact-value remap, e.g. {"P2P": "p2p"}. Applied after trim/case.
map_defaultwith mapValue for a source not in map. Without it, an unmapped value rejects the row — a fabricated neutral value is never substituted.
formatdatetimerfc3339 (default) · epoch_s · epoch_ms · pattern.
patterndatetimeTokens YYYY MM DD HH mm ss SSS; every other character is a literal that must match exactly. No customer-supplied regex ever reaches a regex engine.
tzdatetimeZ, ±hh:mm, or an IANA name. Used when the parsed value carries no offset, and for the emitted RFC 3339 string.
true_valuesbooleanDefault vocabulary is true/t/yes/y/1, case-insensitive.
false_valuesbooleanDefault vocabulary is false/f/no/n/0, case-insensitive.
valuemoney{"from": "col"} or {"const": "…"} — the amount.
assetmoney{"from": "col"} or {"const": "DOP"} — the currency.
scalemoneyMinor-unit exponent, 0–18. Must match the asset registry.
unitsmoneydecimal (default, "1234.56") or minor ("123456", already in minor units).
decimal_separatormoneyFor locales that write 1.234,56.
group_separatormoneyThousands separator to strip before parsing.

Unknown keys are rejected — a mistyped option is a loud error, never a silently ignored one.

#Money in a mapping

json — 1,234.56 DOP from a column with thousands separators
{
  "data.amount": {
    "type": "money",
    "value": {
      "from": "AMOUNT"
    },
    "asset": {
      "const": "DOP"
    },
    "scale": 2,
    "units": "decimal",
    "group_separator": ","
  }
}

That produces {"value": "123456", "asset": "DOP", "scale": 2}. Accounting negatives ((1,000.00)) and a leading + are understood. If your ledger already stores minor units, set "units": "minor" and skip the conversion entirely — that is the safest option when it is available.

#Timestamps in a mapping

json — a non-ISO source format
{
  "occurred_at": {
    "from": "POSTED_AT",
    "type": "datetime",
    "format": "pattern",
    "pattern": "DD/MM/YYYY HH:mm:ss",
    "tz": "-04:00"
  }
}

#Starter templates

One per family. Replace every UPPERCASE placeholder with your column name, delete the rules you have no column for (unless they are required), and add the optional fields you do have — the more context you send, the fewer detectors sit in insufficient_data.

#party

Typical source file: customers.csv. Entities are versioned: map sequence, or only the first version of each entity will ever land.

json — party mapping
{
  "mapping_version": "1",
  "family": "party",
  "format": {
    "kind": "csv",
    "has_header": true
  },
  "fields": {
    "external_id": {
      "from": "CUSTOMER_ID"
    },
    "sequence": {
      "from": "VERSION_NO",
      "type": "integer"
    },
    "occurred_at": {
      "from": "UPDATED_AT",
      "type": "datetime",
      "tz": "-04:00"
    },
    "data.type": {
      "from": "CUSTOMER_KIND",
      "case": "lower",
      "map": {
        "individual": "person",
        "company": "business",
        "agent": "agent"
      },
      "map_default": "other"
    },
    "data.status": {
      "from": "STATUS"
    },
    "data.created_at": {
      "from": "ONBOARDED_AT",
      "type": "datetime",
      "tz": "-04:00"
    },
    "data.segment": {
      "const": "consumer"
    },
    "data.jurisdictions.residency": {
      "from": "COUNTRY",
      "case": "upper",
      "optional": true
    }
  }
}

#account

Typical source file: accounts.csv. Entities are versioned: map sequence, or only the first version of each entity will ever land.

json — account mapping
{
  "mapping_version": "1",
  "family": "account",
  "format": {
    "kind": "csv",
    "has_header": true
  },
  "fields": {
    "external_id": {
      "from": "ACCOUNT_ID"
    },
    "sequence": {
      "from": "VERSION_NO",
      "type": "integer"
    },
    "occurred_at": {
      "from": "UPDATED_AT",
      "type": "datetime",
      "tz": "-04:00"
    },
    "data.type": {
      "const": "wallet"
    },
    "data.holder_party_id": {
      "from": "CUSTOMER_ID"
    },
    "data.asset": {
      "const": "DOP"
    },
    "data.status": {
      "from": "STATE",
      "case": "lower",
      "map": {
        "open": "active",
        "dormant": "dormant",
        "closed": "closed",
        "frozen": "frozen"
      }
    },
    "data.opened_at": {
      "from": "OPENED_AT",
      "type": "datetime",
      "tz": "-04:00"
    }
  }
}

#instrument

Typical source file: instruments.csv. Entities are versioned: map sequence, or only the first version of each entity will ever land.

json — instrument mapping
{
  "mapping_version": "1",
  "family": "instrument",
  "format": {
    "kind": "csv",
    "has_header": true
  },
  "fields": {
    "external_id": {
      "from": "INSTRUMENT_ID"
    },
    "sequence": {
      "from": "VERSION_NO",
      "type": "integer"
    },
    "occurred_at": {
      "from": "UPDATED_AT",
      "type": "datetime",
      "tz": "-04:00"
    },
    "data.type": {
      "const": "card_token"
    },
    "data.status": {
      "from": "STATE",
      "case": "lower"
    },
    "data.first_seen_at": {
      "from": "FIRST_SEEN_AT",
      "type": "datetime",
      "tz": "-04:00"
    },
    "data.masked_identifier": {
      "from": "MASKED_PAN",
      "optional": true
    }
  }
}

#movement

Typical source file: transactions.csv. Facts carry no sequence — they are immutable, and a correction is a new row with a new external_id.

json — movement mapping
{
  "mapping_version": "1",
  "family": "movement",
  "format": {
    "kind": "csv",
    "delimiter": ",",
    "quote": "\"",
    "has_header": true
  },
  "row_filter": {
    "from": "RECORD_TYPE",
    "op": "in",
    "values": [
      "TXN"
    ]
  },
  "fields": {
    "external_id": {
      "from": "TXN_ID"
    },
    "occurred_at": {
      "from": "POSTED_AT",
      "type": "datetime",
      "format": "pattern",
      "pattern": "DD/MM/YYYY HH:mm:ss",
      "tz": "-04:00"
    },
    "recorded_at": {
      "from": "LEDGER_WRITTEN_AT",
      "type": "datetime",
      "optional": true,
      "tz": "-04:00"
    },
    "data.type": {
      "from": "KIND",
      "case": "upper",
      "map": {
        "P2P": "p2p",
        "CASHOUT": "cash_out",
        "CASHIN": "cash_in",
        "MERCHANT": "merchant_payment"
      },
      "map_default": "other"
    },
    "data.status": {
      "from": "STATE",
      "case": "lower",
      "map": {
        "ok": "completed",
        "posted": "completed",
        "pend": "pending",
        "rej": "failed",
        "rev": "reversed"
      }
    },
    "data.amount": {
      "type": "money",
      "value": {
        "from": "AMOUNT"
      },
      "asset": {
        "const": "DOP"
      },
      "scale": 2,
      "units": "decimal",
      "group_separator": ","
    },
    "data.debit.account_id": {
      "from": "FROM_ACCOUNT"
    },
    "data.credit.account_id": {
      "from": "TO_ACCOUNT"
    },
    "data.cash": {
      "from": "IS_CASH",
      "type": "boolean",
      "true_values": [
        "Y"
      ],
      "false_values": [
        "N"
      ],
      "default": false
    },
    "data.channel": {
      "from": "CHANNEL",
      "optional": true
    },
    "data.memo": {
      "from": "DESCRIPTION",
      "optional": true
    }
  }
}

#relationship

Typical source file: relationships.csv. Entities are versioned: map sequence, or only the first version of each entity will ever land.

json — relationship mapping
{
  "mapping_version": "1",
  "family": "relationship",
  "format": {
    "kind": "csv",
    "has_header": true
  },
  "fields": {
    "external_id": {
      "from": "LINK_ID"
    },
    "sequence": {
      "from": "VERSION_NO",
      "type": "integer"
    },
    "occurred_at": {
      "from": "DECLARED_AT",
      "type": "datetime",
      "tz": "-04:00"
    },
    "data.type": {
      "const": "owns_instrument"
    },
    "data.from.kind": {
      "const": "party"
    },
    "data.from.external_id": {
      "from": "CUSTOMER_ID"
    },
    "data.to.kind": {
      "const": "instrument"
    },
    "data.to.external_id": {
      "from": "INSTRUMENT_ID"
    },
    "data.valid_from": {
      "from": "DECLARED_AT",
      "type": "datetime",
      "tz": "-04:00"
    },
    "data.source": {
      "const": "kyc"
    }
  }
}

#control_event

Typical source file: control-events.csv. Facts carry no sequence — they are immutable, and a correction is a new row with a new external_id.

json — control_event mapping
{
  "mapping_version": "1",
  "family": "control_event",
  "format": {
    "kind": "csv",
    "has_header": true
  },
  "fields": {
    "external_id": {
      "from": "EVENT_ID"
    },
    "occurred_at": {
      "from": "HAPPENED_AT",
      "type": "datetime",
      "tz": "-04:00"
    },
    "data.type": {
      "const": "withdrawal.blocked"
    },
    "data.subject.kind": {
      "const": "account"
    },
    "data.subject.external_id": {
      "from": "ACCOUNT_ID"
    },
    "data.outcome": {
      "const": "blocked"
    },
    "data.reason_code": {
      "from": "REASON"
    },
    "data.actor_kind": {
      "const": "customer_system"
    }
  }
}

#Valid target paths

Generated from the frozen event schemas by the same walker @kytrix/imports validates mappings with. If a path is in this list, the importer accepts it; if it is not, the importer refuses the mapping before the import runs.

Envelope targets, valid for every family: external_id (required), occurred_at (required), recorded_at, sequence, schema_version.

#party — data paths

  • data.birth_or_inc_date
  • data.correction_reason
  • data.created_at
  • data.expected_profile.cash_intensity
  • data.expected_profile.declared_monthly_volume.asset
  • data.expected_profile.declared_monthly_volume.scale
  • data.expected_profile.declared_monthly_volume.value
  • data.external_ref
  • data.industry_code
  • data.jurisdictions.nationality
  • data.jurisdictions.registration
  • data.jurisdictions.residency
  • data.kyc.tier
  • data.kyc.updated_at
  • data.occupation
  • data.screening_status_reported.as_of
  • data.screening_status_reported.pep
  • data.screening_status_reported.provider
  • data.screening_status_reported.status
  • data.segment
  • data.status
  • data.type

Collection targets — any path beneath these is accepted, because the shape below them is the event schema’s business: data.addresses, data.expected_profile.corridors, data.identifiers, data.legal_names.

Shapes: packages/contracts/src/entities/party.ts · see also the generated schema.

#account — data paths

  • data.agent_of_opening_party_id
  • data.asset
  • data.closed_at
  • data.closure_reason
  • data.correction_reason
  • data.expected_use
  • data.external_ref
  • data.holder_party_id
  • data.jurisdiction
  • data.opened_at
  • data.product_code
  • data.purpose
  • data.status
  • data.type

Shapes: packages/contracts/src/entities/account.ts · see also the generated schema.

#instrument — data paths

  • data.country
  • data.external_ref
  • data.fingerprint
  • data.first_seen_at
  • data.holder_name_as_entered
  • data.issuer_ref
  • data.masked_identifier
  • data.status
  • data.type
  • data.verification_status

Shapes: packages/contracts/src/entities/instrument.ts · see also the generated schema.

#movement — data paths

  • data.agent_party_id
  • data.amount.asset
  • data.amount.scale
  • data.amount.value
  • data.cash
  • data.channel
  • data.correction_reason
  • data.credit.account_id
  • data.credit.counterparty.country
  • data.credit.counterparty.institution
  • data.credit.counterparty.instrument_ref
  • data.credit.counterparty.name_as_given
  • data.credit.party_id
  • data.debit.account_id
  • data.debit.counterparty.country
  • data.debit.counterparty.institution
  • data.debit.counterparty.instrument_ref
  • data.debit.counterparty.name_as_given
  • data.debit.party_id
  • data.device_ref
  • data.external_ref
  • data.geo.country
  • data.geo.lat
  • data.geo.lon
  • data.geo.region
  • data.instrument_ref
  • data.ip
  • data.links.correction_of
  • data.links.part_of_batch
  • data.links.refunds
  • data.links.reverses
  • data.links.settles
  • data.memo
  • data.product
  • data.rail
  • data.status
  • data.type

Collection targets — any path beneath these is accepted, because the shape below them is the event schema’s business: data.fees.

Shapes: packages/contracts/src/entities/movement.ts · see also the generated schema.

#relationship — data paths

  • data.confidence
  • data.correction_reason
  • data.external_ref
  • data.from.external_id
  • data.from.kind
  • data.ownership_pct
  • data.source
  • data.to.external_id
  • data.to.kind
  • data.type
  • data.valid_from
  • data.valid_to

Shapes: packages/contracts/src/entities/relationship.ts · see also the generated schema.

#control_event — data paths

  • data.actor_kind
  • data.customer_severity
  • data.device_ref
  • data.external_ref
  • data.ip
  • data.linked_movement_id
  • data.outcome
  • data.reason_code
  • data.subject.external_id
  • data.subject.kind
  • data.type

Shapes: packages/contracts/src/entities/control-event.ts · see also the generated schema.

#What a reject looks like

A bad row never fails an import. It becomes one reject row carrying the original row number (global to the file, not to the chunk), the chunk, the object type and external id where known, a governed catalog code, and a detail with the message, the failing field paths and the raw source row.

CodeWhen
kytrix:validation/schemaThe mapping could not produce a value (missing required source, an unmapped enum value with no map_default, a bad integer/boolean/JSON); the row has more fields than the header; a JSONL line is not a JSON object; or the assembled event failed its schema.
kytrix:validation/timestamp_out_of_rangeoccurred_at unparseable, in the future beyond the skew tolerance, older than the backfill window, or at/after this import’s go-live watermark.
kytrix:validation/money_scale_mismatchThe money value is not a number at the declared scale — including "more fractional digits than scale".
kytrix:validation/pan_detectedA column of the row matches a full-PAN pattern.
kytrix:validation/unsupported_schema_versionThe mapping pins a schema_version this build does not accept.
kytrix:sequence/conflictThe same identity was already accepted with different content and no higher sequence.
kytrix:import/invalid_chunkChunk-level, not per row: a header/mapping mismatch, an unterminated quote, invalid UTF-8, a record past the size cap, or a missing staged object.

Download rejects with GET /v1/imports/{id}/rejects — streamed CSV (row_no,chunk_no,code,object_type,external_id,message,raw) or ?format=json for a paginated page. Fix the rows, put them in a new file, import again.

#The five that cost a day

  1. No sequence on an entity family. Parties, accounts, instruments and relationships dedup on (external_id, sequence). Without one, only the first version of each entity lands, and nothing complains — the mapping validator emits a warning, so read it.
  2. Money by multiplication. AMOUNT * 100 on a float is wrong in a way you will not see for months. Use type: "money" with scale, or send minor units directly.
  3. A local timestamp with no tz. It is interpreted in the rule’s tz, and if you did not set one, in UTC — four hours off, quietly, for the whole backfill.
  4. Filtering out failed and blocked movements. Those are the rows detectors care about most. Send them with status: "failed", not not at all.
  5. A map without a map_default. An unmapped source value rejects the row on purpose — KYTRIX will not substitute a plausible neutral value. Decide what the default should be, or fix the source vocabulary.

#What to hand back

Full endpoint list, chunking rules, resumability and configuration surfaces: packages/imports/README.md. The import API itself is summarised on the API reference.