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.
- 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.
- Fill in a mapping from the starter template for that family below, replacing every
UPPERCASEplaceholder with your column name. - Dry-run it against a sample:
POST /v1/imports/dry-runwith{ mapping, sample }. Nothing is created, nothing is stored, no import row exists. This is the iteration loop. - 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.
- 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
{
"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"
}
}
}| Key | Required | Meaning |
|---|---|---|
mapping_version | yes | The literal "1". Old mappings keep working under their version. |
family | yes | One of party, account, instrument, movement, relationship, control_event. Every row of the file becomes this. |
format | yes | The dialect — see below. |
fields | yes | Target path → rule. external_id and occurred_at are required; schema_version is filled in for you. |
row_filter | no | Rows 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 key | Required | Meaning |
|---|---|---|
kind | yes | The literal "csv". |
delimiter | no | Field separator. Must differ from quote. |
quote | no | Quote character. |
has_header | no | The first record of chunk 0 is the header row. When false, columns is required. |
header_per_chunk | no | Every chunk repeats the header — some split tools do this. |
columns | no | Explicit column names. The escape hatch for a header you cannot change; also overrides a present header. |
skip_leading_rows | no | Physical records to drop before the header (banner lines in bank extracts). |
comment_prefix | no | Lines starting with this are skipped. |
null_values | no | Source values equal to one of these are treated as absent. |
| JSONL key | Required | Meaning |
|---|---|---|
kind | yes | The literal "jsonl". |
comment_prefix | no | Lines starting with this are skipped. |
skip_leading_rows | no | Physical lines to drop before the data. |
null_values | no | Source values equal to one of these are treated as absent. An explicit JSON null is already "absent". |
- UTF-8 only. Invalid UTF-8 fails the chunk rather than being replaced with
U+FFFD; a leading BOM is stripped once. \n,\r\nand a lone\rall end a CSV record, including across a chunk boundary. Quoted fields may contain the delimiter, newlines and""escapes.- Fewer fields than the header → the trailing columns are absent (and the row rejects by itself if a required mapped column is among them). More fields than the header → the row is rejected: an extra delimiter means every later value may belong to a different column, and importing that would silently corrupt your data.
- JSONL is one JSON object per line. A line that is not valid JSON, or is a scalar or an array, is a per-row reject; the file keeps going.
#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:
| Key | Applies to | Meaning |
|---|---|---|
from | all but money | Source column (CSV) or dotted path into the line’s JSON (JSONL); numeric segments index arrays (parties.0.id). |
const | all but money | A literal written on every row. Mutually exclusive with from. |
type | all | string (default) · integer · number · boolean · datetime · money · json. |
optional | all | true → an absent source leaves the target unset instead of rejecting the row. |
default | all | Value used when the source is absent or empty. null means "leave unset". |
trim | all | Trim the source value. |
case | all | upper or lower, applied before map. |
map | all | Exact-value remap, e.g. {"P2P": "p2p"}. Applied after trim/case. |
map_default | with map | Value for a source not in map. Without it, an unmapped value rejects the row — a fabricated neutral value is never substituted. |
format | datetime | rfc3339 (default) · epoch_s · epoch_ms · pattern. |
pattern | datetime | Tokens 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. |
tz | datetime | Z, ±hh:mm, or an IANA name. Used when the parsed value carries no offset, and for the emitted RFC 3339 string. |
true_values | boolean | Default vocabulary is true/t/yes/y/1, case-insensitive. |
false_values | boolean | Default vocabulary is false/f/no/n/0, case-insensitive. |
value | money | {"from": "col"} or {"const": "…"} — the amount. |
asset | money | {"from": "col"} or {"const": "DOP"} — the currency. |
scale | money | Minor-unit exponent, 0–18. Must match the asset registry. |
units | money | decimal (default, "1234.56") or minor ("123456", already in minor units). |
decimal_separator | money | For locales that write 1.234,56. |
group_separator | money | Thousands 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
{
"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
- A value that already carries an offset keeps its instant.
- A bare local time (
2026-03-01 10:00:00, or2026-03-01) is interpreted in the rule’stz. The emitted RFC 3339 string is written intzwhen one is given, and in UTC otherwise. - Prefer a fixed offset for a backfill. An IANA zone is ambiguous for one hour a year inside a DST gap. The Dominican Republic has no DST, so
"tz": "-04:00"is exact and reproducible. - A row older than the agreed backfill window (730 days by default) is rejected rather than quietly widening the history the baselines are built from.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
| Code | When |
|---|---|
kytrix:validation/schema | The 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_range | occurred_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_mismatch | The money value is not a number at the declared scale — including "more fractional digits than scale". |
kytrix:validation/pan_detected | A column of the row matches a full-PAN pattern. |
kytrix:validation/unsupported_schema_version | The mapping pins a schema_version this build does not accept. |
kytrix:sequence/conflict | The same identity was already accepted with different content and no higher sequence. |
kytrix:import/invalid_chunk | Chunk-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
- No
sequenceon 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. - Money by multiplication.
AMOUNT * 100on a float is wrong in a way you will not see for months. Usetype: "money"withscale, or send minor units directly. - A local timestamp with no
tz. It is interpreted in the rule’stz, and if you did not set one, in UTC — four hours off, quietly, for the whole backfill. - Filtering out failed and blocked movements. Those are the rows detectors care about most. Send them with
status: "failed", not not at all. - A
mapwithout amap_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
- One mapping JSON file per family, named for the source file it maps.
- A sample of 200–1,000 real rows per file (or scrubbed rows with the same shape) so the dry-run means something.
- Declared control totals per feed and window — record count and monetary total. KYTRIX compares them against what it observed and opens a finding on a delta, which is how a silently broken feed gets noticed rather than assumed fine.
- The go-live watermark: the instant after which movements arrive through the live API instead of the backfill. A historical import rejects rows at or after it, because such a row is live traffic.
Full endpoint list, chunking rules, resumability and configuration surfaces: packages/imports/README.md. The import API itself is summarised on the API reference.