Every canonical field below has a corresponding entry in the
Canonical Field Registry, which is also the contract import
profiles use to map external files into these models.
1. PaymentIntent — what should happen
APaymentIntent is the expectation: a client telling ReconLayer “this payment should occur,
here’s what it should look like.” It is created via
POST /v1/payment-intents, or
indirectly from a client_internal_ledger file import.
Key fields (from PaymentIntentCreateRequest / PaymentIntentSnapshot):
| Field | Type | Notes |
|---|---|---|
externalReference | string | Client’s stable business reference. Unique per (organizationId, externalReference) — this is the idempotency key for the intake endpoint. |
sourceAmount / sourceCurrency | decimal string / currency code | What leaves the source side. Currency can be a fiat ISO code or a token symbol (USDC). |
destinationAmount / destinationCurrency | decimal string / currency code | What the beneficiary should receive. |
paymentType | stablecoin | bank | cross_border | other | Broad category. Drives reconciliation rule selection. |
paymentSubtype | string | Narrower rail/corridor label, e.g. wire, swift, usd_mxn, polygon_usdc. |
direction | debit | credit | From the client’s perspective. Defaults to debit. |
beneficiaryAccount / beneficiaryName | string | Destination account/wallet identifier and display name. |
stablecoin / chain | string | Token symbol (USDC) and chain label (polygon) when relevant. |
status | PaymentIntentStatus | initiated, submitted, pending, sent, completed, failed, returned, reversed, cancelled, unknown. Set from statusOnClientSide on create. |
references | array of { type, value } | Structured identifiers, e.g. provider_transfer_id, tx_hash, written into PaymentIntentReference rows. |
metadata | JSON | Client context (cost center, batch id, corridor) plus ReconLayer bookkeeping fields like clientCompletedAt, statusOnClientSide, expectedFxRate, developerFeeExpected. |
Creating a
PaymentIntent always creates exactly one ReconciliationCase in the same
transaction (status: 'open', expectedAmount copied from sourceAmount). The
create response returns both
paymentIntentId and caseId, with status: 'reconciling' and verdict: null — the case
hasn’t been evaluated yet because no evidence has arrived.Replay and conflict behavior
POSTing the sameexternalReference again is idempotent at the business level:
- If the canonical fields (
sourceAmount,sourceCurrency,destinationAmount,destinationCurrency,beneficiaryAccount, and — if previously set —stablecoin/chain) match the stored intent, the request is treated as a replay: the existing intent is updated (status,beneficiaryName,paymentType, etc.), apayment_intent.replayedaudit event is written, and the response hasoutcome: 'reused'. - If those canonical fields don’t match, the service throws an
ExpectationConflictErrordescribing the field-by-fieldmismatchesbetween the existing and incoming request — the client is asked to resolve the discrepancy rather than silently overwriting it.
2. RawRecord — what arrived, untouched
ARawRecord is the immutable evidence layer. Every inbound payload — a provider webhook, an
on-chain indexer event, a row from an uploaded file, or a manual entry — is stored as one
RawRecord before any normalization happens.
| Field | Type | Notes |
|---|---|---|
source | file | api | webhook | manual | Ingestion channel. |
sourceType | ImportSourceType | null | Business category: client_transfer_report, client_internal_ledger, bank_statement, onchain_report, psp_report, manual. |
provider / integrationKey | string | null | Vendor label (bridge, alchemy, icici) and technical connector id (bridge:webhook, icici:daily-xlsx). |
sourceRef | string | Stable source-side identifier — provider transfer id, tx hash, or generated row reference. Unique per (organizationId, source, sourceRef) for idempotent ingestion. |
payload | JSON | The untouched source payload. Never edited. |
rowNumber | int | null | Source row number for file imports. |
normalizedType / normalizedId | string | null | What this record was normalized into, and its id. |
validationStatus | pending | valid | warning | failed | null | Set during import row validation. |
signature / signatureValid | string | boolean | null | Webhook signature verification result, when applicable. |
RawRecord.payload is the audit anchor: when something looks wrong in a FlowLeg, you can always
trace it back to the exact bytes that were ingested.
3. FlowLeg — where value actually moved
AFlowLeg is one normalized movement derived from a RawRecord. Complex cross-border
payments produce multiple legs for a single PaymentIntent.
| Field | Type | Notes |
|---|---|---|
type | provider_transfer | onchain_transfer | bank_transfer | What kind of movement this is. |
phase | source | intermediary_in | transfer | intermediary_out | destination | null | Role of this leg in a multi-hop route. |
status | pending | confirmed | failed | reversed | missing | Current state of the leg. Defaults to pending. |
reconciliationScope | required | optional | ignored | Whether this leg counts toward “the case is fully evidenced.” Defaults to required. |
routeGroupId | string | null | Groups legs that belong to the same routed path or retry. |
sequence | int | null | Order of this leg within its route group. |
providerTransferId | string | null | First-class provider transfer id (commonly queried for matching). |
txHash / chainId / fromAddress / toAddress / tokenAddress | on-chain identifiers | First-class for onchain_transfer legs. |
provider / integrationKey | string | null | Mirrors the normalized source’s vendor identity. |
amount / currency | decimal / string | null | The amount moved on this leg. |
occurredAt | datetime | null | When the movement happened (block time, bank posting time, provider event time). |
metadata | JSON | Extra non-queryable context. |
reconciliationScope and shouldMatchLeg
The reconciliation engine’s shouldMatchLeg() helper treats any leg with
reconciliationScope: 'ignored' as invisible to matching — it is never selected as a match
candidate and never blocks case completeness. required and optional legs are both eligible for
matching; the distinction matters for requiredLegsTotal / requiredLegsPresent reporting on the
case summary (see below).
4. FlowLegReference — typed long-tail identifiers
FlowLegReference rows attach typed identifiers to a FlowLeg without adding nullable columns to
the leg table itself. Each row is { type, value }, e.g. { type: "bank_reference", value: "BANK-OUT-789" } or { type: "external_reference", value: "ACME-2026-0001" }.
The matcher specifically looks for a leg reference with type: 'external_reference' as one of its
candidate identifiers (see Reconciliation Rules).
5. ReconciliationCase — the truth row
Exactly oneReconciliationCase exists per PaymentIntent (1:1, enforced by a unique constraint
on paymentIntentId). It is the record operators review, resolve, or archive.
| Field | Type | Notes |
|---|---|---|
status | open | resolved | archived | Workflow state. |
reconciliationStatus | unreconciled | tentatively_reconciled | reconciled | What the matcher/evaluator concluded. |
expectedAmount / actualAmount | decimal | null | Expected (copied from the intent’s sourceAmount) vs. observed amount. |
providerFee, networkFee, developerFee, fxSpread, roundingDelta | decimal | null | Individually-tracked components of any delta — never collapsed into one number. |
unexplainedDelta | decimal | null | What’s left after subtracting expected − actual − all the above fee/FX/rounding components. This is the field operations actually watches; it’s indexed. |
exceptionType | ExceptionType | null | Canonical exception category (see below). |
notes | string | null | Operator notes. |
reconciledAt / lastRunAt | datetime | null | When the case last reconciled, and when the evaluator last ran. |
The verdict field
verdict is not stored — it’s derived at read time from reconciliationStatus and
exceptionType:
sla_risk and delayed as verdict values for
filtering (ReconciliationCaseExportVerdict enum: matched, matched_with_exception,
needs_review, unreconciled, sla_risk, delayed). slaRisk itself is a separate boolean on
the case summary: true when the case is open and has been open for more than 240 minutes
(SLA_RISK_THRESHOLD_MINUTES).
ExceptionType enum
Evidence coverage and flow legs on the case summary
GET /v1/reconciliation-cases and
GET /v1/reconciliation-cases/{caseId}
both return a computed evidenceCoverage object and a flowLegs array summarizing the intent’s
legs:
evidenceCoverage values are present | missing | warning | failed | not_provided, derived from
leg status: confirmed → present, pending → warning, failed/reversed → failed,
missing → missing, and not_provided when no leg of that type exists at all (file coverage
is always not_provided today — no leg type maps to it yet).
MatchLink — why evidence was linked
MatchLink is the explainable join between a ReconciliationCase, a FlowLeg, and a
RawRecord. Each row records:
matchType:provider_id,tx_hash,reference_exact,amount_and_time_window, ormanual_override.matchReason: a human-readable sentence, e.g. “External reference ACME-2026-0001 matched the expected payment.”confidence:deterministic,heuristic, ormanual.
(caseId, legId, rawRecordId) triple is unique — re-processing the same evidence against the
same case is idempotent (upsert).
AuditEvent — the append-only narrative
Every meaningful state change writes anAuditEvent: payment_intent.created,
payment_intent.replayed, match.created, match.none, case.evaluated, and more. Each event
carries eventType, actor (e.g. system:matcher, user:<clerk-id>), an optional message, and
a payload JSON blob with event-specific context. Audit events are surfaced on both
PaymentIntentDetail.auditEvents and ReconciliationCaseDetail.auditEvents.
Putting it together
How ReconLayer Works
Conceptual walkthrough of the expectation → evidence → case lifecycle.
Reconciliation Rules
How tolerances, time windows, SLAs, and match strategies are configured.
Database Architecture
The Prisma schema behind these objects.
Canonical Field Registry
Every mappable field, by model and source type.
