Tilt Pay API · Client Pack (IMT)

Send transfers and collect payments across Africa through the Tilt platform. Instant. Secure. Scalable.

REST · JSON OAuth 2.0 Bearer International Money Transfers Webhooks Need domestic transfers? See the Local pack → ⤓ Download Postman collection

Introduction

The Tilt Pay API is based on REST with resource-oriented URLs. It accepts JSON-encoded request bodies and returns JSON-encoded responses with standard HTTP response codes. Learn more about the Tilt platform at tiltafrica.com.

This client pack covers the International Money Transfer (IMT) flow: authenticating, discovering institutions, checking FX rates, looking up recipients, and creating, quoting and tracking transfers. Sending within a single country instead? See the Local client pack — most of the API is identical, but local transfers are a simpler single-step send with no FX or quoting.

Response envelope. Every JSON response carries the same shape: responseCode, responseMessage and a data payload.

Environments & variables

Replace the placeholders below with the values provided by your Tilt account manager.

VariableDescription
{baseUrl}Tilt Pay API base URL for your environment
{baseAuthUrl}Authentication server base URL
{username} / {password}Your API credentials
{client_id} / {audience}OAuth client ID and audience for your tenant
{account_institution}Institution ID of your Tilt account (e.g. gh_tilt)
{account_number}Your Tilt account number
Authentication. All endpoints except Get token require a Bearer token: Authorization: Bearer <access_token>.

Authentication

POST{baseAuthUrl}/oauth/token

Use your username and password to obtain an access token, which you need to authenticate your account when using the API.

Request
# cURL
curl -X POST "{baseAuthUrl}/oauth/token" \
  -H "Content-Type: application/json" \
  -d '{
    "username": "{username}",
    "password": "{password}",
    "audience": "{audience}",
    "grant_type": "password",
    "client_id": "{client_id}"
  }'
Response · 200
{
  "access_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 86400
}
Cache the token and reuse it until it expires — don't request a new token per call.

Resources

GET/v1/resources/institutions/:country/?list=true

List available institutions for a country. Required for account verification and creating transfers.

ParameterInDescription
countrypathrequiredCountry in lowercase ISO 3166-1 alpha-2 format (e.g. gh, zm)
listqueryoptionalSet true to return a flat id → name map
typequeryoptionalFilter by institution type, e.g. bank
Request
curl "{baseUrl}/v1/resources/institutions/gh/?list=true" \
  -H "Authorization: Bearer $TOKEN"
Response · 200 (truncated)
{
  "responseCode": 200,
  "responseMessage": "Fetched institutions",
  "data": {
    "gh_tilt": "Tilt Ghana",
    "gh_absa": "Absa Bank Ghana Limited",
    "gh_mtn": "MTN Mobile Money",
    "gh_ecobank": "Ecobank",
    "gh_stanbic": "Stanbic Bank",
    "gh_vodafone": "Vodafone Money",
    "gh_zenith": "Zenith Bank"
    // ... banks, mobile money and savings & loans institutions
  }
}

FX

GET/v1/rates/fx/:from_currency/:to_currency

Fetch the current exchange rate between two currencies.

ParameterInDescription
from_currencypathrequiredSource currency, lowercase ISO 4217 (e.g. usd)
to_currencypathrequiredTarget currency, lowercase ISO 4217 (e.g. ngn)
Request
curl "{baseUrl}/v1/rates/fx/usd/ngn" \
  -H "Authorization: Bearer $TOKEN"
Response · 200
{
  "responseCode": 200,
  "responseMessage": "Fetched currency exchange rate",
  "data": {
    "from": { "currency": "usd" },
    "to": { "currency": "ngn" },
    "rate": 1750.6489495892
  }
}

Your account

GET/v1/accounts/:institution/:account_number/balance

Gets the real-time balance of an account that the requesting client has permission to view.

Request
curl "{baseUrl}/v1/accounts/{account_institution}/{account_number}/balance" \
  -H "Authorization: Bearer $TOKEN"
Response · 200
{
  "responseCode": 200,
  "responseMessage": "Fetched account balance",
  "data": {
    "balance": 10250.75,
    "currency": "zmw"
  }
}
GET/v1/accounts/:institution/:account_number/transactions

Retrieve transaction data for an account that the requesting client has permission to view.

ParameterInDescription
from_datetimequeryoptionalStart of range, e.g. 2024-07-09T00:00:00+0000
to_datetimequeryoptionalEnd of range, e.g. 2024-07-11T00:00:00+0000
AcceptheaderoptionalResponse format — application/json (default) or text/csv
Request
curl "{baseUrl}/v1/accounts/{account_institution}/{account_number}/transactions?from_datetime=2024-07-09T00:00:00%2B0000&to_datetime=2024-07-11T00:00:00%2B0000" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"
Retention. Transaction data is available for up to 90 days, after which historical data must be requested from your account manager. Opening and closing balances are calculated nightly and are not available for transactions on the current day.
GET/v1/accounts/:institution/:account_number/statements

Retrieve a list of available statements for an account that the requesting client has permission to view. Statement transaction data is fetched separately from the referenced link — it is not included when listing statements.

ParameterInDescription
from_datetimequeryoptionalFilter start date/time. Open-ended if omitted
to_datetimequeryoptionalFilter end date/time. Open-ended if omitted
searchqueryoptionalFree-text search, e.g. weekly
Request
curl "{baseUrl}/v1/accounts/{account_institution}/{account_number}/statements" \
  -H "Authorization: Bearer $TOKEN"
Response · 200 (shape)
{
  "data": [
    {
      "statement_id": "stm_01HV...",
      "opening_balance": 9500.00,
      "closing_balance": 10250.75,
      "transactions_count": 42
    }
  ]
}
GET/v1/accounts/:institution/:account_number/statements/:statement_id

Retrieve a specific statement. Statement transaction data is included in the response when available, or a link is provided to an archived statement. Transaction data is generally available for 90 days before being archived.

Use the Accept header to specify the response format, such as text/csv.

Request
curl "{baseUrl}/v1/accounts/{account_institution}/{account_number}/statements/{statement_id}" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: text/csv"
POST/v1/accounts/:institution/:account_number/withdraw

Withdraw funds from your Tilt account to a predefined bank account, registered with Tilt. Supply a unique external_transaction_id (UUID) for idempotency and optional webhook notifications.

Request
curl -X POST "{baseUrl}/v1/accounts/{account_institution}/{account_number}/withdraw" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "instruction": {
      "external_transaction_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
      "amount": 100,
      "narration": "Send to bank",
      "notifications": [
        {
          "on_status": ["ACCEPTED", "SUCCESSFUL", "FAILED"],
          "to_url": "https://example.com/webhooks/tilt"
        }
      ]
    }
  }'

The response includes a transfer_id, the final status, the echoed external_transaction_id, the instruction (debtor/creditor), and an actions array tracing each state change.

Lookup recipient account

GET/v1/accounts/:institution/:account_number

Fetch information for an account number if the institution supports account discovery. Use this to verify a recipient before creating a transfer.

ParameterInDescription
institutionpathrequiredInstitution identifier as found in the institutions API
account_numberpathrequiredAccount number (or mobile number for a wallet) to look up
Request
curl "{baseUrl}/v1/accounts/zm_airtel/260970000100" \
  -H "Authorization: Bearer $TOKEN"
Response · 200 (shape)
{
  "data": {
    "institution": "zm_airtel",
    "account_number": "260970000100",
    "status": "ACTIVE"
  }
}

Transfers — Send IMT

The Transfers API allows you to send money directly into a recipient's account. There are two flows:

Auto-accept quotePOST /v1/transfers creates and immediately executes the transfer at the current rate.
Quote then settlePOST /v1/transfers/quote returns a quote first; you confirm it with PATCH /v1/transfers/:transfer_id/settle.

POST/v1/transfers

Create an IMT transfer (auto-accepting the quote). On creation the transfer status is PENDING; track it via webhooks or by fetching the transfer.

FieldDescription
instruction.debtorrequiredSender — institution, account_number, and kyc (first/last name, id_number, id_type e.g. passport, address, country)
instruction.creditorrequiredRecipient — institution, account_number, mobile_number, and kyc (id_type e.g. national_id)
instruction.additional_detailsrequiredpurpose_of_funds (e.g. GIFT_AND_DONATION), origin_currency, origin_amount
instruction.amount / currencyrequiredDestination amount and lowercase ISO 4217 currency (e.g. zmw)
instruction.external_transaction_idrequiredYour unique UUID for idempotency and reconciliation
instruction.schemerequiredimt_transfer
instruction.narrationoptionalFree-text reference shown on the transaction
instruction.notificationsoptionalWebhook subscriptions — see Webhooks
Request
curl -X POST "{baseUrl}/v1/transfers" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "instruction": {
      "debtor": {
        "institution": "{account_institution}",
        "account_number": "{account_number}",
        "kyc": {
          "first_name": "Roy",
          "last_name": "Milner",
          "id_number": "111111",
          "id_type": "passport",
          "address": "address",
          "country": "us"
        }
      },
      "creditor": {
        "institution": "zm_absa",
        "account_number": "123456",
        "mobile_number": "260960000001",
        "kyc": {
          "first_name": "Tanya",
          "last_name": "Winslow",
          "id_number": "222222222",
          "id_type": "national_id"
        }
      },
      "additional_details": {
        "purpose_of_funds": "GIFT_AND_DONATION",
        "origin_currency": "usd",
        "origin_amount": "50"
      },
      "amount": 1000,
      "currency": "zmw",
      "narration": "test",
      "external_transaction_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
      "scheme": "imt_transfer",
      "notifications": [
        {
          "on_status": ["ACCEPTED", "QUOTED", "QUOTE_EXPIRED", "PENDING", "PENDING_RETRY",
                        "PENDING_KYC", "KYC_EXPIRED", "SUCCESSFUL", "FAILED"],
          "to_url": "https://example.com/webhooks/tilt"
        }
      ]
    }
  }'
Response · 200/201 (shape)
{
  "data": {
    "transfer_id": "trf_01HV...",
    "status": "PENDING",
    "external_transaction_id": "9b1deb4d-...",
    "instruction": { "debtor": { ... }, "creditor": { ... } },
    "actions": [
      { "status": "ACCEPTED", "_meta": { "cause": "...", "trace_api_log_id": "..." } }
    ]
  }
}
POST/v1/transfers/quote

Create a quoted transfer without executing it. The request body is identical to POST /v1/transfers. The response returns a transfer_id and quote details — settle it with the endpoint below before the quote expires (QUOTE_EXPIRED).

Request
curl -X POST "{baseUrl}/v1/transfers/quote" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ /* same instruction body as POST /v1/transfers */ }'
PATCH/v1/transfers/:transfer_id/settle

Accept and settle a previously quoted transfer, using the transfer_id returned by the quote call.

Request
curl -X PATCH "{baseUrl}/v1/transfers/{transfer_id}/settle" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json"
GET/v1/transfers/verify/:external_transaction_id

Verify whether a particular external_transaction_id has been received and processed by Tilt. Use this for reconciliation or before retrying after a timeout — it prevents duplicate sends.

Request
curl "{baseUrl}/v1/transfers/verify/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" \
  -H "Authorization: Bearer $TOKEN"
Response · 200
{
  "responseMessage": "A request has been received for this external_transaction_id",
  "data": { "transfer_id": "trf_01HV...", "status": "...", ... }
}
GET/v1/transfers/:transfer_id

Fetch a transfer using the transfer_id issued by Tilt. The actions array lists every state change; the last action's status matches the transfer's current status.

Request
curl "{baseUrl}/v1/transfers/{transfer_id}" \
  -H "Authorization: Bearer $TOKEN"
POST/v1/transfers/:transfer_id/resend-notification

Resend the last webhook notification for a transfer — useful when your endpoint was down or you need to replay an event.

Request
curl -X POST "{baseUrl}/v1/transfers/{transfer_id}/resend-notification" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json"
Response · 200 (shape)
{
  "data": {
    "webhook": { "request_id": "...", "url": "...", "message": { ... } },
    "response": { "http_code": 200 }
  }
}

Transfer statuses

A transfer moves through four phases: create, wait, process and complete. Subscribe to any status via the notifications array on the transfer instruction.

1 · CREATE 2 · WAIT client may cancel 3 · PROCESS with credit institution 4 · COMPLETE final statuses auto-accept quote flow KYC required settle expires retry picked up KYC completed expires ACCEPTED QUOTED PENDING_RETRY PENDING_KYC ✕ cancellable ✕ cancellable PENDING SUCCESSFUL FAILED QUOTE_EXPIRED KYC_EXPIRED

PENDING means the transfer has been sent to the credit institution and Tilt is waiting for an outcome. A transfer drops back to PENDING_RETRY when delivery must be re-attempted, and returns to PENDING once it is picked up for processing. PENDING_KYC is entered from ACCEPTED or QUOTED when the recipient must complete in-market KYC requirements before the transfer can proceed to PENDING.

Transfers in the PENDING_RETRY and PENDING_KYC states can be cancelled by the client.
Final statuses. Statuses in the complete phase are terminal — a transfer that reaches one of them will not change state again.

Webhooks

Attach a notifications array to any transfer or withdrawal instruction to receive HTTP callbacks as the transfer changes state:

"notifications": [
  {
    "on_status": ["ACCEPTED", "PENDING_RETRY", "SUCCESSFUL", "FAILED"],
    "to_url": "https://example.com/webhooks/tilt"
  }
]

Subscribable statuses: ACCEPTED, QUOTED, QUOTE_EXPIRED, PENDING, PENDING_RETRY, PENDING_KYC, KYC_EXPIRED, SUCCESSFUL, FAILED.

Each webhook delivery includes a request_id, the destination url and the message payload. Missed a delivery? Replay it with resend-notification.

Sandbox mock samples

The sandbox environment includes magic account numbers that deterministically trigger specific outcomes, so you can exercise every branch of your integration. Only the last three digits of the account number matter — the institution and the leading digits can be anything, so these triggers work for any institution, not just the Zambian examples shown below. Any account number whose last three digits don't match one of the special values in the tables below returns a success response by default.

Recipient lookup — GET /v1/accounts/:institution/:account_number

Last 3 digitsOutcomeExample
600Account not foundzm_airtel / 0970000600
700Institution errorzm_airtel / 0970000700
anything elseSuccess — account foundzm_airtel / 260970000100

Send IMT — POST /v1/transfers (creditor values)

Last 3 digitsOutcomeExample
200PENDING → SUCCESSFUL (async)zm_mtn / 0961000200
300PENDING → FAILED (async)zm_mtn / 0961000300
500FAILED — institution errorzm_fnb / 100500
600FAILED — account not foundzm_absa / 100600
700FAILED — account errorzm_mtn / 0961000700
anything elseSUCCESSFULzm_absa / 123456
Use the async PENDING → samples to test your webhook handling end-to-end before going live.