Developers

API reference

Anything marked Live answers today. Anything marked With the engine move is specified here as the contract and is being built as the Registry moves beside its database. Nothing on this page is described as working before it does.

One signed request asks the network. The request fields match the bureau's, so an existing bureau integration keeps its parsing code. What changes is the signature, which no one can forge, and the answer, which comes back already read.

Overview

Base URL and versions

EnvironmentBase URLPortVersionState
Productionhttps://interchange.servicesuitecloud.com/api/v1443v1Live
Sandboxhttps://interchange.servicesuitecloud.com/sandbox/v1443v1With the engine move

All calls are HTTPS POST with a JSON body, except the health and catalogue reads. Versions change only by path. Fields are added inside a version and never removed or repurposed.

Your credential pack

ItemWhat it is
Member codeYour X-Road style identity, for example KE/LENDER/3002. Sent in x-interchange-member.
Public keyEd25519, 32 bytes, hex. Registered with the Interchange; it is how we recognise your signature.
Private keyA 32-byte Ed25519 seed, hex. Generated on your side and never sent to us, so nobody can sign as you, including us. In-portal generation arrives with the engine move.
IP allowlistThe addresses your systems call from. Required for production keys. With the engine move.
Webhook secretUsed to sign the events we send you: report.ready, crunch.completed, cap.reached. With the engine move.

Authentication

Every member call carries four headers. The signature covers the method, the path, the SHA-256 of the exact body bytes, the timestamp, a random nonce and your member code, so a request cannot be replayed, altered in transit, or sent in another member's name.

HeaderValue
x-interchange-memberYour member code.
x-interchange-timestampISO 8601 UTC, e.g. 2026-09-17T08:15:59.212Z. Refused outside a 60-second window (IX004).
x-interchange-nonce32 random hex characters, fresh for every request.
x-interchange-signatureEd25519 signature over the canonical string, lowercase hex.

The canonical string

Six lines joined with a single newline, no trailing newline:

canonical string
POST
/api/v1/report
<sha256 of the body bytes, lowercase hex>
2026-09-17T08:15:59.212Z
9f1c2b7e4d0a4c3b8e6f1a2d3c4b5a69
KE/LENDER/3002

The most common failure is hashing one serialisation of the body and sending another. Serialise once, hash those bytes, send those bytes.

Signing in your language

Node.js
import { createHash, randomBytes } from "node:crypto";
import { ed25519 } from "@noble/curves/ed25519.js";

const BASE = "https://interchange.servicesuitecloud.com";
const MEMBER = "KE/LENDER/3002";
const SECRET_KEY_HEX = process.env.INTERCHANGE_SECRET_KEY; // 32-byte Ed25519 seed, hex

export async function interchange(path, payload) {
  const body = JSON.stringify(payload);
  const timestamp = new Date().toISOString();
  const nonce = randomBytes(16).toString("hex");
  const canonical = [
    "POST",
    path,
    createHash("sha256").update(body, "utf8").digest("hex"),
    timestamp,
    nonce,
    MEMBER,
  ].join("\n");
  const signature = Buffer.from(
    ed25519.sign(new TextEncoder().encode(canonical), Buffer.from(SECRET_KEY_HEX, "hex")),
  ).toString("hex");

  const res = await fetch(BASE + path, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-interchange-member": MEMBER,
      "x-interchange-timestamp": timestamp,
      "x-interchange-nonce": nonce,
      "x-interchange-signature": signature,
    },
    body, // send exactly the bytes you hashed
  });
  return res;
}
C# / .NET
// dotnet add package BouncyCastle.Cryptography
using System.Security.Cryptography;
using System.Text;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;

static HttpRequestMessage Sign(string path, string body, string member, string secretKeyHex, string baseUrl)
{
    var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
    var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
    var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
    var canonical = string.Join("\n", "POST", path, digest, timestamp, nonce, member);

    var signer = new Ed25519Signer();
    signer.Init(true, new Ed25519PrivateKeyParameters(Convert.FromHexString(secretKeyHex), 0));
    var bytes = Encoding.UTF8.GetBytes(canonical);
    signer.BlockUpdate(bytes, 0, bytes.Length);
    var signature = Convert.ToHexString(signer.GenerateSignature()).ToLowerInvariant();

    var req = new HttpRequestMessage(HttpMethod.Post, baseUrl + path)
    {
        Content = new StringContent(body, Encoding.UTF8, "application/json"),
    };
    req.Headers.Add("x-interchange-member", member);
    req.Headers.Add("x-interchange-timestamp", timestamp);
    req.Headers.Add("x-interchange-nonce", nonce);
    req.Headers.Add("x-interchange-signature", signature);
    return req;
}
Python
# pip install cryptography requests
import hashlib, json, os, secrets, requests
from datetime import datetime, timezone
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

BASE = "https://interchange.servicesuitecloud.com"
MEMBER = "KE/LENDER/3002"
KEY = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(os.environ["INTERCHANGE_SECRET_KEY"]))

def interchange(path, payload):
    body = json.dumps(payload, separators=(",", ":"))
    timestamp = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
    nonce = secrets.token_hex(16)
    canonical = "\n".join(["POST", path, hashlib.sha256(body.encode()).hexdigest(), timestamp, nonce, MEMBER])
    signature = KEY.sign(canonical.encode()).hex()
    return requests.post(BASE + path, data=body.encode(), headers={
        "content-type": "application/json",
        "x-interchange-member": MEMBER,
        "x-interchange-timestamp": timestamp,
        "x-interchange-nonce": nonce,
        "x-interchange-signature": signature,
    })
PHP
<?php
// Requires the sodium extension (bundled with PHP 7.2+).
function interchange(string $path, array $payload): string {
    $base = "https://interchange.servicesuitecloud.com";
    $member = "KE/LENDER/3002";
    $seed = hex2bin(getenv("INTERCHANGE_SECRET_KEY"));          // 32-byte seed
    $secret = sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair($seed));

    $body = json_encode($payload, JSON_UNESCAPED_SLASHES);
    $timestamp = gmdate("Y-m-d\\TH:i:s.v\\Z");
    $nonce = bin2hex(random_bytes(16));
    $canonical = implode("\n", ["POST", $path, hash("sha256", $body), $timestamp, $nonce, $member]);
    $signature = bin2hex(sodium_crypto_sign_detached($canonical, $secret));

    $ch = curl_init($base . $path);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            "content-type: application/json",
            "x-interchange-member: $member",
            "x-interchange-timestamp: $timestamp",
            "x-interchange-nonce: $nonce",
            "x-interchange-signature: $signature",
        ],
    ]);
    return curl_exec($ch);
}

Quickstart

  1. Sign in to the member portal, open API keys, and generate a key pair. The private half downloads to your machine; the public half is registered.
  2. Add your server's address to the allowlist.
  3. Call the sandbox with the test ID 880000088. It answers with the same anonymised file the live samples are built from, and costs nothing.
  4. Capture consent from a real borrower, then switch the base URL to production.
POST /sandbox/v1/report
{
  "report_type": 12,
  "identity_number": "880000088",
  "identity_type": "001",
  "loan_amount": 8000,
  "report_reason": 1,
  "consent_ref": "csn_…",
  "format": "json"
}

Endpoints

CallPurposeState
POST /api/v1/reportAny Interchange report, in json, html, pdf or bundle (JSON with the PDF inside).Live
POST /api/v1/bureau/reportBureau Direct: any entitled bureau report type, by the bureau's own integer.With the engine move
POST /api/v1/statement/crunchMultipart: the M-PESA PDF, its password, and the name to check against.With the engine move
POST /api/v1/exposureHosted exposure query with a per-member answer status.With the engine move
GET /api/v1/report/{reference}Download a past document again, in any format, within retention.With the engine move
POST /api/consentRecord a consent against a subject token. Signed.Live
POST /api/consent/{ref}/revokeWithdraw a consent. Prospective and idempotent.Live
POST /api/oprf/evaluateBlinded token evaluation for members running their own node.Live
GET /api/filtersPublished screening filters, for local screening before a fan-out.Live
GET /api/log/verifyRe-verify the hash-chained message log.Live

Interchange reports

Sent to /api/v1/report. Ecosystem reports are free to a contributing member. Bureau-backed reports carry the bureau's cost and a stated fee, shown as separate lines on the invoice.

TypeReportAnswersSourceState
1Identity VerificationIs this ID real, and is it the person in front of me?BureauLive
2Delinquency StatusAre they in default right now, anywhere in this ecosystem?Member booksLive
3Interchange ScoreWhat is the network's number on this person, and why?BothSpecified
11Cashflow & AffordabilityWhat can they actually afford to repay each month?BothSpecified
8Credit InfoEvery credit account on file, and how each one is performing.BureauLive
12Credit FileEverything on file, read as a decision rather than as a dump.BothLive
20Ecosystem ExposureIs this borrower, who looks clean to me, servicing loans elsewhere right now?Member booksLive
21Intent SignalWho is this borrower shopping with right now?Member booksSpecified
22ContactabilityWhen and how do we actually reach this person?Member booksSpecified
23Cohort BenchmarkHow does this borrower compare with their peers across the ecosystem?Member booksSpecified

Interchange report numbers follow the bureau's where the product is the same, and start at 20 where no bureau has an equivalent. Two numbers collide by meaning: Interchange 2 reads live member books, while bureau report 2 is the bureau's own delinquency flag; and Interchange 22 is Contactability, while bureau report 22 is a 12-month account history. That is why bureau products have their own path.

Bureau Direct

Sent to /api/v1/bureau/report with the bureau's own report_type integer. Every pull returns four things: the document on the Interchange letterhead, the Interchange JSON, the bureau's response exactly as received with its SHA-256, and for account reports a CSV of every account.

TypeReportWhat the lender learnsNeeds loan context
1Identity VerificationWhether the ID exists and who it belongs to.no
2Delinquency StatusWhether the person is in default now, or has been.loan_amount, report_reason
3Metro / Mobile ScoreThe bureau's score for the person, or the mobile-lending variant.no
4PDF Credit ReportThe bureau's own printed credit report.loan_amount, report_reason
5JSON Credit ReportThe full credit file as structured data.loan_amount, report_reason
6Identity ScrubEvery contact and address trace lenders have reported.no
8Credit InfoEvery credit account on file, with the headline score.loan_amount, report_reason
10Enhanced Credit InfoCredit Info plus who guarantees the person and who holds a stake in them.loan_amount, report_reason
11Credit Info with Income EstimateCredit Info plus the bureau's modelled monthly income.loan_amount, report_reason
12Full Enhanced Credit InfoThe complete picture: identity, traces, accounts and a year of score history.loan_amount, report_reason
13Minified Credit InfoOne line: worst arrears, whether non-performing, total owed.loan_amount, report_reason
14Full JSON ReportThe older full-file format: identity, traces, accounts, guarantors.loan_amount, report_reason
16Credit Accounts SummaryCounts of mobile against generic loans, arrears, NPLs and the monthly instalment.loan_amount, report_reason
22Accounts Info (12-month history)not in contractMonth by month status of every account over the last year.loan_amount, report_reason

The response envelope

The top-level has_error, api_code and api_code_description keep the bureau's meaning. Everything the bureau said sits under data, restructured but never recomputed. Everything the Interchange worked out from it sits under reading, so the two can never be confused. This is the real sample for bureau report 2:

200 OK · application/json
{
  "has_error": false,
  "api_code": null,
  "api_code_description": null,
  "api_version": "v1",
  "reference": "IX-69RQ-JBKC-20260918",
  "report": {
    "type": 2,
    "name": "Delinquency Status",
    "product": "bureau_direct"
  },
  "report_date": "2026-09-18 14:17:45 EAT",
  "requested_by": {
    "person": "Sample Analyst",
    "organisation": "Sample Lender Limited",
    "member_code": "KE/LENDER/SAMPLE"
  },
  "subject": {
    "identity_type": {
      "code": "001",
      "label": "National ID"
    },
    "identity_number": "880000088"
  },
  "provenance": {
    "provider": "Metropol Credit Reference Bureau",
    "bureau_report_types": [
      2
    ],
    "bureau_transaction_ids": [
      "07b8de7d-377a-4146-a818-3bb85687b00c"
    ],
    "responses": [
      {
        "report_type": 2,
        "sha256": "44dde58b5e1ddb223557140e067b09b8f9d0ddac0a3534bc5cee37e08aa9d51d",
        "capture": "unwrapped"
      }
    ],
    "interchange_role": "Retrieved, kept unchanged, and presented. The Interchange is not a credit reference bureau."
  },
  "data": {
    "delinquency": {
      "code": "004",
      "label": "Currently delinquent",
      "meaning": "At least one account is non-performing right now."
    }
  },
  "reading": {},
  "notices": {
    "regulation_40_1": "…",
    "bureau_disclaimer": [
      "…"
    ],
    "interchange": "…"
  },
  "verify": {
    "url": "https://interchange.servicesuitecloud.com/verify/IX-69RQ-JBKC-20260918",
    "content_sha256": "3a12270f60885014644722769aa54aef118c068a3355b5c54f2dff4592f8a34f"
  },
  "sample": true
}

No consent reference, no answer. A consent is recorded against the borrower's token, never a national ID, and a consent issued for one borrower cannot be used to ask about another (IX104). The scopes a consent can carry:

ScopeLabelMandatory
mpesa.crunchM-Pesa statement analysisyes
kyc.verifyIdentity verificationyes
bureau.pullCredit reference bureau checkyes
ecosystem.exposureLending ecosystem checkyes
outcome.labelLoan outcome recordingyes
model.trainImproving credit scoringyes
collections.contactContact about repaymentyes
identity.discloseLender name disclosureno
marketing.offersOffers from other lendersno

Code tables

Every code value the bureau returns is passed through unchanged, so switch statements written against the bureau keep working. The descriptions below are the Interchange's own.

Report reasons

CodeReasonWhen to use it
1New credit applicationThe person has applied for credit and the lender is deciding.
2Review of existing creditThe lender is reviewing a facility the person already holds.
3Verify customer detailsConfirming identity or contact details, with no credit decision attached.
4Customer's own requestThe person asked for their own report.

Identity types

CodeTypeMeaning
001National IDKenyan national identity card number.
002PassportPassport number.
003Service IDArmed or disciplined services identity number.
004Alien registrationForeign national registration certificate.
005Company registrationBusiness or company registration number.

Delinquency

CodeStatusMeaning
001Identity not foundThe bureau holds no record of this ID at all.
002No credit historyThe ID is known but no lender has reported an account against it — a thin file, not a clean one.
003No delinquencyCredit history exists and no account has ever been non-performing.
004Currently delinquentAt least one account is non-performing right now.
005Delinquent in the pastNo account is non-performing now, but at least one was.

Lender sectors

KeySectorCovers
sector_bankBanksCommercial banks.
sector_mfbMicrofinance banksDeposit-taking microfinance banks.
sector_mfiMicrofinance institutionsCredit-only microfinance institutions.
sector_saccoSaccosSavings and credit co-operatives.
sector_otherOther lendersEvery other reporting lender, including digital credit providers.

Product types

IdProductMeaning
1UnknownThe reporting lender did not classify the facility.
2Current AccountA transactional account with a credit element.
3Loan AccountA general term loan.
4Credit CardCard credit line.
5Line of CreditA drawable limit.
6Revolving CreditCredit that is repaid and redrawn.
7OverdraftAn overdraft, including mobile overdraft products.
8Credit CardCard credit line (second code in use for the same product).
9Business Working CapitalShort-term business finance.
10Business Expansion LoanBusiness growth finance.
11MortgageProperty-secured lending.
12Asset Finance LoanFinance secured on the asset bought.
13Trade Finance FacilityImport, export or supply-chain finance.
14Personal LoanUnsecured consumer loan.
18Mobile Banking LoanA loan disbursed and repaid through a mobile channel.
19OtherClassified by the lender as none of the above.

Ids 15, 16 and 17 are not used.

Account status

CodeStatusMeaningCounts as
-UnknownStatus not reported.open
AClosedThe facility is closed.finished
BDormantOpen but not used.open
CPerformingOpen and being repaid on terms.open
DNon-PerformingOpen and in default.adverse
EWrite-OffThe lender has written the debt off as a loss.adverse
FLegalThe debt is in legal proceedings.adverse
GCollectionThe debt has been passed to collections.adverse
HActiveOpen.open
ITerms ExtendedRepayment terms were extended or restructured.open
JEarly SettlementRepaid in full before term.finished
KFully SettledRepaid in full.finished
LRevokedThe facility was withdrawn.finished
MSuspendedUse of the facility is suspended.adverse
NNot UpdatedThe lender has not refreshed this account.open
PPaid UpNothing further is owed.finished
QDisability, Deceased, Insurance ClaimClosed through an insurance event.open
RDeferredRepayments are deferred by agreement.open

Response codes

Every error says whose code it is. api_code_source is "interchange" when the Interchange decided, and "bureau" when the bureau produced the code, which is then passed through untouched.

Interchange (IX)

CodeHTTPMeaningWhat to do
IX001401Request not signed. One or more of the member, timestamp, nonce or signature headers is missing.Sign every request with the member's private key. See Authentication.
IX002401Member has no registered key. The member code is unknown, or no public key has been registered for it.Register the public key in the portal under API keys.
IX003401Signature does not verify. The signature was not made over this exact request with the registered key.Sign the canonical string for the bytes actually sent. A proxy that rewrites the body breaks the signature.
IX004401Request too old. The timestamp is outside the 60-second window.Synchronise the server clock (NTP) and send a freshly signed request.
IX005403Member not active. The member is suspended or not yet admitted.Contact the ecosystem governor.
IX006403Address not allowed. The call came from an address outside the member's allowlist.Add the address in the portal, or call from an allowed host.
IX101403No consent presented. The request carried no consent_ref, or the Registry does not know it.Capture consent and send its reference.
IX102403Consent revoked or expired. The borrower withdrew consent, or it has passed its expiry.Capture fresh consent from the borrower.
IX103403Consent does not cover this product. The consent exists but its scopes do not include what this report reads.Capture consent with the missing scopes named in the description.
IX104403Consent belongs to another borrower. The consent_ref was issued for a different subject than the one asked about.Use the consent captured from this borrower.
IX201403Member in shadow period. The member contributes its book but may not query yet.Promotion is a governance decision once the shadow period is served.
IX202403Contribution has lapsed. The member's published book is too old to keep query rights.Publish the book again; access returns automatically.
IX301429Daily bureau cap reached. The member has used today's allowance of bureau pulls.Wait for the 24-hour window to roll, or ask the governor to raise the cap.
IX302429Free tier used up. Today's free calls for this product are used.Wait for the window to roll, or move to paid calls.
IX303403Product not subscribed. The member is not subscribed to this product.Enable it in the portal, or ask the governor.
IX401503Bureau unreachable. The bureau did not answer, or the contract holder's node is down.Retry shortly. Nothing was billed.
IX402502Bureau failed without a code. The bureau answered with an error that carried no bureau code of its own.Retry once. If it repeats, report the reference to support.
IX403403Bureau report not available to this member. Bureau reports are not enabled for this member, or not in the bureau contract.Ask the governor which bureau products are enabled for you.
IX404503Bureau selling switched off. Bureau pulls are disabled on this deployment.Try again later, or use the ecosystem products.
IX501422Statement password needed. The PDF is encrypted and no password was sent.Send the password from Safaricom's SMS.
IX502422Statement password wrong. The password did not open the PDF.Check the SMS. On older statements it is the ID number.
IX503422Not an M-PESA statement. The file opened but holds no M-PESA transactions that could be read.Upload the full statement PDF emailed by Safaricom.
IX504422Statement holder mismatch. The name on the statement does not match the name on the enquiry.Confirm the statement belongs to this borrower.
IX505413File not accepted. The upload is not a PDF, or is larger than 15 MB.Upload the original PDF.
IX601200Partial answer. Some members did not answer in time. The result is a floor, not a total.Read `silent` for who and why. Retry if the decision depends on completeness.
IX602503Product specified, not live. The product is in the catalogue but does not answer yet.Check /api/v1/catalogue for its state.
IX603503Format not available here. This deployment cannot render the format asked for, usually PDF.Request json, or call the engine host.
IX701409Token and ID disagree. The subject_token sent does not match the token derived from identity_number.Your node's normalisation has drifted from the Registry's. Send the ID alone.
IX702400Bureau product needs the ID. A bureau cannot look up a subject token, so this product needs identity_number.Send identity_number and identity_type.

Bureau (E)

CodeMeaningRetry
E200Service available. The bureau's health check is answering.no
E001Bureau internal error. The bureau failed internally. Nothing is wrong with the request.yes
E002API key missing. The request carried no public key.no
E003Key not authorised. The key exists but may not use this service or port.no
E004Public key invalid. The public key has expired or been disabled.no
E005Wrong content type. The body was not sent as application/json.no
E006Body is not valid JSON. The request body could not be parsed.no
E007report_type missing. The report_type field was absent.no
E008identity_number missing. The identity_number field was absent.no
E009identity_type missing. The identity_type field was absent.no
E010report_type empty. The report_type field was present but blank.no
E011Unknown report type. The report_type value is not one the bureau serves.no
E012identity_number empty. The identity_number field was present but blank.no
E013identity_type empty. The identity_type field was present but blank.no
E014Unknown identity type. The identity_type value is not in the identity type list.no
E015Fields do not fit this report. The combination of fields is not valid for the report type asked for.no
E016Service not open yet. The bureau has not released this service for use.no
E017No record for this ID. On an entitled report this is a thin or unknown file — record it as a finding, not an error. On a dummy ID it proves entitlement for free.no
E018Not a test identity. Test keys only answer for the bureau's test IDs.no
E019Identity type restricted. This identity type is not open to the account.no
E020loan_amount missing. This report type needs loan_amount.no
E021loan_amount empty. The loan_amount field was present but blank.no
E022loan_amount invalid. loan_amount is not a whole positive number.no
E023Timestamp missing. The timestamp header was absent.no
E024Timestamp invalid. The timestamp header is not in the expected format.yes
E025Clock out of step. The request arrived too long after it was signed. Check the server clock and send a fresh request.yes
E026Hash missing. The hash header was absent.no
E027Hash does not match. The hash was computed over different bytes, or with the wrong private key.no
E028Account not fully set up. The subscription is not yet activated at the bureau.no
E029Report not in contract. The subscription does not include this report type. Answered before the subject is looked at.no
E030Private key invalid. The private key has been revoked at the bureau.no
E031report_reason missing. This report type needs report_reason.no
E032report_reason invalid. report_reason is not a number.no
E033report_reason not recognised. report_reason is not in the report reason list.no
E409Duplicate request. The same request reached the bureau within the last minute. Wait, or use the answer already received.yes

Bureau behaviour worth knowing

Learned from live production responses, and handled by the Interchange so your integration does not have to be:

  • api_code arrives as a number or a string depending on the report. The Interchange always returns a string or null.
  • E017 on an entitled report is a thin or unknown file. It is an answer, not a failure.
  • Days in arrears count to the lender's last upload (loaded_at), not to today. The Interchange carries the date with every figure.
  • An account reported open with a nil balance is settled but not yet re-flagged. It is not counted as live exposure.
  • Enquiry counters are not real-time. Several pulls on one identity did not move them within half an hour.
  • For the same person on the same day, the bureau's printed report and its API can list different account counts. Every Interchange figure states which source it came from.
  • Report 12 nests identity fields inside the verification block while leaving the outer fields empty. The Interchange reads the nested values.