Skip to content
Last updated

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:

FlowOriginDestinationOrigin signalDestination signal
kaito → kaitoFiat country AFiat country B (kaito-owned rail)sourceCountry: "<A>"targetAccountId of a local_* account
kaito → soyfriFiat countrySoyFri GTsourceCountry: "<country>"targetAccountId of a soyfri_gt account
crypto → kaitoWalletFiat country (kaito-owned rail)senderWalletAddresstargetAccountId of a local_* account
crypto → soyfriWalletSoyFri GTsenderWalletAddresstargetAccountId 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:

HeaderPurpose
x-kaito-api-keyPublic api-key identifier (rk_test_… / rk_live_…). Identifies the calling client.
x-kaito-timestampUnix timestamp (seconds or milliseconds). Must be within ±300s of server time.
x-kaito-trace-idCaller-generated correlation id. Echoed in the response header and in error envelopes.
x-kaito-signatureHex HMAC-SHA256 of the canonical request, signed with the per-key secret.
x-kaito-contentHex 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

<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. 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

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:

{
  "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.