# Reconciliation

A partner integration is correct when every business order in the partner system has exactly one matched record in Spike, and final amounts agree on both sides. This page lists the identifiers, the fields to record per flow, and the patterns that keep reconciliation tight.

## Identifiers

Pick the right identifier for each join.

| Identifier | Where it lives | Use for |
|---|---|---|
| `externalReference.externalId` | Set by the partner on `POST /v1/exchanges` | Joining a Spike exchange back to a partner business order. The partner's system of record. |
| `externalReference.externalUserId` | Set by the partner on exchange or deposit-address creation | Grouping all activity for one end user across exchanges. |
| `exchangeId` | Returned on every exchange creation and lookup | Spike's primary identifier. Always store it on every order. |
| `depositAddressId` | Returned on `POST /v1/deposit-addresses` | Off-ramp: linking deposits, exchanges, and refunds to the address they originated from. |
| `cryptoDeposit.transactionHash` | Populated after a deposit is detected | Off-ramp: the on-chain proof of inbound funds. |
| `cryptoWithdrawals[].withdrawalId`, `cryptoWithdrawals[].transactionHash` | Populated after a crypto withdrawal is initiated and confirmed | On-ramp: the outbound delivery record. |
| `fiatWithdrawals[].transactionHash` | Populated after a fiat withdrawal/payout is initiated | Off-ramp: the partner-side fiat settlement record. |
| `cryptoRefunds[].refundId`, `cryptoRefunds[].transactionHash` | Populated after a refund is issued | Off-ramp: the refund record per refund event. |

## What to record

For every exchange the partner system creates, persist at minimum:

### On-ramp orders

- `externalReference.externalId` (partner business identifier)
- `exchangeId` (Spike identifier)
- Final `status`
- `amounts.from`, `amounts.to`, `amounts.rate` after `exchange.completed`
- `cryptoWithdrawals[]` after `withdraw.completed`: `withdrawalId`, `transactionHash`, `networkFee`, `netAmount`
- On terminal failure: `failReason` (machine-readable) and `failDescription` (human-readable). The aggregate `onramp.failed` webhook also carries `failureContext.failureReason` and `failedPhase` — see [Events and Webhooks — Failure context](../concepts/events-and-webhooks.md#failure-context).

### Off-ramp orders

- `depositAddressId` (the address the deposit arrived at)
- `exchangeId` (one per deposit)
- `externalReference.externalUserId`
- `cryptoDeposit.transactionHash` (inbound proof)
- Final `status`
- `amounts.from`, `amounts.to`, `amounts.rate` after `exchange.completed`
- `fiatWithdrawals[]` after `offramp.completed` (fiat settlement to the partner)
- `cryptoRefunds[]` if any refunds were issued: `refundId`, `amount`, `transactionHash`, `networkFee`, `netAmount`, `status`, `completedAt`
- On terminal failure: `failReason`, `failDescription`, and the aggregate `offramp.failed` `failureContext`.

## Reconciliation cadence

Webhooks reduce latency. Polling is the safety net. Use both.

### Real-time path (webhooks)

Drive most state updates from webhook events. Apply the [idempotent handler pattern](../concepts/events-and-webhooks.md#idempotent-handler-pattern): verify the signature, dedupe on event ID, refetch state, persist.

The **aggregate terminal events** are the canonical reconciliation triggers — each ramp emits exactly one in its lifetime:

- `onramp.completed` / `onramp.failed`
- `offramp.completed` / `offramp.failed`

Failure events carry `failureContext.failureReason` so the partner can branch deterministically (suppress dust, escalate withdrawal failures, etc.). See [Events and Webhooks](../concepts/events-and-webhooks.md).

### Periodic reconciliation (polling)

Every 5–15 minutes, fetch partner orders that are not in a terminal state and call `GET /v1/exchanges/by-id` for each. This catches webhooks that were dropped, out-of-order events that left the partner state lagging, and slow-moving exchanges. A partner endpoint that is healthy will see a non-zero inbound webhook count whenever exchanges are progressing; if the count flips to zero while you have non-terminal exchanges, reconcile via the API for all of them.

### End-of-day reconciliation (list endpoint)

Once per day, compare the count and total amounts of partner orders against `GET /v1/exchanges` for the same window.

Filters and pagination on `GET /v1/exchanges`:

- `type` — `on-ramp` or `off-ramp`. **Defaults to `off-ramp`** if omitted, so run the EOD job per type or pass `type` explicitly.
- `status`, `fromCurrency`, `toCurrency`, `fromDateTime`, `toDateTime`, `externalId`, `externalUserId`, `depositAddressId` — all optional.
- `limit` — default 50, max 100.
- `cursor` — opaque pagination token; omit for the first page. Use `pagination.nextCursor` from the response while `pagination.hasMore == true`.

Investigate any of:

- Orders in the partner system with no matching `exchangeId`.
- `exchangeId`s in Spike with no matching partner order (would indicate an `externalId` collision or unexpected creation).
- Per-order amount mismatches.

For dust deposits and other intentionally suppressed failure modes, see [Failure handling](failure-handling.md).

## How to read the exchange response

The exchange resource contains everything needed for reconciliation. The most relevant fields:

- `status`, `failReason`, `failDescription` — current state.
- `amounts` (`from`, `to`, `rate`) — final converted values.
- `cryptoDeposit` — inbound crypto deposit (off-ramp).
- `cryptoWithdrawals[]` — outbound crypto withdrawals (on-ramp).
- `fiatWithdrawals[]` — outbound fiat payouts (off-ramp).
- `cryptoRefunds[]` — refunds issued against this exchange.
- `externalReference` — partner identifiers.
- `authorizationContext` — present only while the exchange is `AWAITING_AUTHORIZATION` (on-ramp).

Field-level schemas are in the [API Reference](../../../api.html).

## Common questions

**Should I trust webhook payload amounts or refetch?**
Refetch. Webhook payloads are triggers; `GET /v1/exchanges/by-id` is the source of truth.

**What happens if my partner system creates two orders with the same `externalId`?**
Spike returns the existing exchange on the second `POST /v1/exchanges` (with `409` if the `Idempotency-Key` differs). The partner system should treat this as a programming error and prevent it upstream — `externalId` must be unique per logical order. See [Idempotency](../concepts/idempotency.md).

**How do I reconcile multiple deposits on a single reusable address?**
Each deposit creates an independent exchange. Use `cryptoDeposit.transactionHash` plus `depositAddressId` to map each deposit to the user and treat each `exchangeId` as its own line item.
