# Kaito as a Payment Rail

Kaito's **Rail M2M API** is the partner-facing surface for moving value across corridors. A single API key + HMAC scheme lets partners discover supported rails, manage destination accounts, lock prices, create orders, and report deposits.

## What the rail provides

- Unified partner-facing endpoints for rails, beneficiary accounts, quotes, and orders.
- Standardized status model and progression for portal-parity M2M flows.
- Corridor routing across `kaito ↔ kaito` (fiat-fiat) and `crypto ↔ kaito`/`crypto ↔ soyfri` (crypto-fiat) flows.
- Separation between:
  - **catalog** (`/rails`, `/accounts`) — setup and beneficiary management
  - **pricing** (`/quotes`) — create a quote against a destination account
  - **execution** (`/orders`) — convert a quote into an order
  - **asynchronous state** (`/orders/{orderId}`, `/orders/{orderId}/deposit-reported`) — tracking and client-reported deposit


## Partner M2M flow

1. **Create a destination account** (one-time per beneficiary)
  - `POST /v1/accounts`
2. **Create a quote**
  - `POST /v1/quotes`
3. **Convert the quote into an order**
  - `POST /v1/orders` with `{ "quoteId": "<id>" }`
4. **Report client deposit** (body-less)
  - `POST /v1/orders/{orderId}/deposit-reported`
  - Maps internally to the Portal client action `mark_deposit_sent` with `operatorId=rail-api`
5. **Track order until terminal state**
  - `GET /v1/orders/{orderId}` (detail) or `GET /v1/orders` (summary list)


Operator actions (confirm deposit, mark completed, mark failed) live in backoffice and are intentionally **not** exposed to partners.

## Supported flow types

The same `POST /v1/quotes` endpoint covers all four flow types — the shape is determined by the request body:

| Flow | Origin | Destination | Origin signal | Destination signal |
|  --- | --- | --- | --- | --- |
| `kaito → kaito` | Fiat country A | Fiat country B (kaito-owned rail) | `sourceCountry: "<A>"` | `targetAccountId` of a `local_*` account |
| `kaito → soyfri` | Fiat country | SoyFri GT | `sourceCountry: "<country>"` | `targetAccountId` of a `soyfri_gt` account |
| `crypto → kaito` | Wallet | Fiat country (kaito-owned rail) | `senderWalletAddress` | `targetAccountId` of a `local_*` account |
| `crypto → soyfri` | Wallet | SoyFri GT | `senderWalletAddress` | `targetAccountId` of a `soyfri_gt` account |


`sourceCountry` and `senderWalletAddress` are **mutually exclusive** — sending both, or neither, returns `400 INVALID_REQUEST` with `details.field = "origin"`.

When `sourceCountry` is provided, the Rail API resolves Kaito's source bank account server-side from a per-country catalog. Partners never specify the source account directly.

## Partner-facing API surface

### Rails

- `GET /v1/rails`


### Accounts

- `GET /v1/accounts` (optional `?country=<ISO-2>`)
- `GET /v1/accounts/{accountId}`
- `POST /v1/accounts`
- `POST /v1/accounts/validate`
- `PATCH /v1/accounts/{accountId}`
- `DELETE /v1/accounts/{accountId}`


`PUT /v1/accounts/{accountId}` is intentionally rejected with `405`.

`POST /v1/accounts` follows Portal behavior for Bridge-backed countries/account types. If Bridge requires KYC/endorsement remediation, the account is **not** created and the API returns `409 BRIDGE_ENDORSEMENT_REQUIRED` with `error.details.endorsement` and (when available) `error.details.complianceUrl`. Send the user to `complianceUrl`, complete Bridge KYC, and retry the same create request.

### Quotes

- `POST /v1/quotes`


Quote list and quote-by-id endpoints are intentionally not exposed.

### Orders

- `POST /v1/orders` (with `{ "quoteId": "<id>" }`)
- `GET /v1/orders`
- `GET /v1/orders/{orderId}`
- `POST /v1/orders/{orderId}/deposit-reported` (body-less)


`GET /v1/orders?orderId=...` is intentionally rejected with `400 INVALID_REQUEST` — use the path variant for single-order lookup.

## Authentication

The Rail API is M2M-only. There is **no Bearer/JWT**. Every request is authorized by a custom Lambda authorizer that uses an API-key identifier plus a per-key HMAC signature.

### Required headers

Every Rail API request must carry **all** of the following headers:

| Header | Purpose |
|  --- | --- |
| `x-kaito-api-key` | Public api-key identifier (`rk_test_…` / `rk_live_…`). Identifies the calling client. |
| `x-kaito-timestamp` | Unix timestamp (seconds or milliseconds). Must be within ±300s of server time. |
| `x-kaito-trace-id` | Caller-generated correlation id. Echoed in the response header and in error envelopes. |
| `x-kaito-signature` | Hex HMAC-SHA256 of the canonical request, signed with the per-key secret. |
| `x-kaito-content` | Hex SHA-256 of the raw request body. Required for `POST`/`PATCH`; optional for `GET`/`HEAD`/`OPTIONS`/`DELETE` (the SHA-256 of the empty string is assumed when omitted). |


### Why both an api-key and a secret?

A plain bearer token would conflate **identity** (who you are) and **proof** (that this request really came from you) into one credential that travels on every request — every TLS-terminating proxy log, every dev-tools tab, every observability tool sees it.

The Rail API splits the two:

- **`x-kaito-api-key`** is the public identifier. It is sent on every request. It is used by the authorizer to look up the per-key HMAC secret in DynamoDB and decrypt it via KMS (with `apiKeyId/clientId/integrationId/purpose` as encryption context).
- **The secret never leaves the partner's side.** It is used only to compute the HMAC signature. The signature additionally covers the timestamp, method, path, query, body hash, and trace id — so a captured request can't be replayed past the 300-second window and a proxy can't tamper with the payload without invalidating the signature.


This is the same model as AWS SigV4, Stripe webhooks, and GitHub webhooks.

### Canonical signature

```text
<x-kaito-timestamp>.<HTTP_METHOD>.<rawPath>.<rawQueryString>.<x-kaito-trace-id>.<contentSha256>
```

- `HTTP_METHOD` is uppercase (e.g. `POST`).
- `rawPath` is the API Gateway raw path **after** the `/v1` mapping key is stripped. For example, calling `POST https://api.kaito.io/v1/quotes` signs `rawPath = "/quotes"`.
- `rawQueryString` is the raw query string (no canonicalization). If empty, sign an empty string.
- `contentSha256` is the hex SHA-256 of the raw request body. For empty-body requests this is the SHA-256 of the empty string: `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.


The signature itself is hex-encoded HMAC-SHA256 of the canonical string, computed with the per-key HMAC secret.

### Authorizer rejection reasons

The authorizer returns `403 Forbidden` with `error.details.reason` set to one of:

`missing_api_key`, `missing_hmac_headers`, `missing_hmac_content`, `api_key_not_found`, `api_key_not_active`, `api_key_expired`, `missing_secret`, `invalid_timestamp`, `timestamp_out_of_tolerance`, `invalid_signature`.

`401 Unauthorized` is returned by API Gateway itself when the required identity-source headers are absent before the authorizer is even invoked.

### Postman cookbook

A ready-to-use Postman collection is shipped with this portal: [`postman/kaito-rail-api.postman_collection.json`](/assets/kaito-rail-api.postman_collection.f0ddbd9e38b2539c7ca06847ddc207c7b0495bff3fafc59ce22f6232b7b4a9e9.843036ec.json). Import it, fill in `apiKeyId` / `hmacSecret` / `targetAccountId` under the **Variables** tab, and every request is signed automatically by the collection-level pre-request script. It covers all five flow examples (kaito↔kaito both directions, kaito→soyfri, crypto→kaito, crypto→soyfri), accounts CRUD, and the deposit-reported action.

### Example: signing in Node.js

```javascript
import crypto from 'node:crypto';

const apiKey = 'rk_test_...';
const secret = 'sk_...';
const method = 'POST';
const baseUrl = 'https://api.kaito.io/v1';
const path = '/quotes';
const body = JSON.stringify({
  sourceCountry: 'GT',
  targetAccountId: '019e4c4b-...-ba-...',
  amount: 20,
});

const timestamp = Math.floor(Date.now() / 1000).toString();
const traceId = `trc_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`;
const contentSha256 = crypto.createHash('sha256').update(body).digest('hex');

const canonical = [timestamp, method, path, '', traceId, contentSha256].join('.');
const signature = crypto.createHmac('sha256', secret).update(canonical).digest('hex');

await fetch(`${baseUrl}${path}`, {
  method,
  headers: {
    'content-type': 'application/json',
    'x-kaito-api-key': apiKey,
    'x-kaito-timestamp': timestamp,
    'x-kaito-trace-id': traceId,
    'x-kaito-content': contentSha256,
    'x-kaito-signature': signature,
  },
  body,
});
```

## Error format

All Rail M2M errors share the same envelope:

```json
{
  "traceId": "trc_01JH8K5P3V9M2K5Q7W6J3Z1A9B",
  "error": {
    "code": "INVALID_REQUEST",
    "message": "Missing required field",
    "details": {}
  }
}
```

`traceId` in the body matches the response header `x-kaito-trace-id` and the request's `x-kaito-trace-id` header.

## Public contract guarantees

Partner-facing responses are intentionally minimal. The following internal workflow fields are **never** present in any Rail M2M response:

`nextStep`, `operationMode`, `sourcePartner`, `destinationPartner`, `availableActions`, `history`, `eventStatus`, downstream client blobs, filter echoes, action-result item wrappers.

## Operator responsibilities

- Partners drive the client-facing steps: create accounts, create quotes, create orders, report deposits, and observe order state.
- Operator-only steps — confirming deposits, marking orders completed or failed — remain in backoffice and are not exposed in this API.