Skip to content

IMC & Airtime ​

IMC (ImpalaCoin) is the engine's airtime-backed token. 1 IMC = $1, valued in KES at the Central Bank of Kenya's USD/KES rate (Exchange Rates →). It only ever exists because airtime exists behind it:

  • Top-up → mint. The platform buys airtime at the gateway (ImpalaPay). The gateway pays Airtel's merchant discount as bonus airtime — 6% by default (AIRTIME_BONUS_PCT) — so a 5,000 KES top-up is 5,300 KES of airtime. That airtime is minted as IMC: IMC = airtime_kes ÷ USD/KES, into the tenant's treasury, and distributed from there.
  • Redeem → burn. A user redeems IMC for Airtel airtime: their IMC is locked, the airtime is sent to their number from the gateway float, and the IMC is burned. If the send fails, the IMC goes straight back to the user — nothing is burned for airtime that wasn't sent.

In the Comet Wallet, a user's ARTM balance is the KES view of their on-chain IMC. ARTM with no IMC behind it can't be withdrawn as airtime or swapped.

▸API ConfigurationNot configured

Rules ​

Only the engine mints IMC

  • IMC is always minted with the engine's own relayer for the active network — never a tenant's wallet, whatever wallet a tenant has configured.
  • The airtime top-up is the only way IMC is minted — into the tenant's IMC treasury. Everything else draws on existing IMC: IMM cycles take their IMC from the IMC treasury (and stop with "book an airtime top-up" when it's short), vault funding moves it from the treasury, distributions pay it from the treasury. The manual mint endpoint (POST /admin/imc/mint) is retired and returns 410.
  • A tenant's own credentials can never trigger an IMC mint through the generic routes — direct mint, distribute top-ups, airtime tokenization, receipt payouts and treasury corridors all refuse with 403. The one tenant path into a mint is booking it with the gateway receipt for airtime they bought themselves, under Book an IMC mint →.
  • Proof of reserve: every IMC mint is refused unless the gateway float covers all IMC in circulation after it — float_kes ≥ (total_supply + new_imc) × USD/KES. With the gateway in simulated mode (testnet) the simulated gateway counts as backing.

Airtime Top-up (mint) ​

🔒 Super admin or service token. Books an airtime top-up made at the gateway and mints its IMC into the acting tenant's treasury (?tenantSlug= to pick the tenant). Each idempotencyKey can be booked once.

Preview a top-up

🔒 Admin operation — always requires a Service Token, regardless of the panel above.

curl -X GET "http://localhost:8100/api/v1/admin/imc/airtime-topup/quote?cashKes=5000" \
  -H "Content-Type: application/json" \
  -H "X-Service-Token: YOUR_SERVICE_TOKEN"
Response200
{
  "cashKes": 5000,
  "bonusPct": 6,
  "bonusKes": 300,
  "airtimeKes": 5300,
  "usdKes": 129.48,
  "rateDate": "2026-09-24",
  "imc": 40.932962
}
Book a top-up

🔒 Admin operation — always requires a Service Token, regardless of the panel above.

curl -X POST "http://localhost:8100/api/v1/admin/imc/airtime-topup?chain=celo" \
  -H "Content-Type: application/json" \
  -H "X-Service-Token: YOUR_SERVICE_TOKEN" \
  -d '{
  "cashKes": 5000,
  "reference": "IMPALA-RCPT-8812",
  "idempotencyKey": "topup-2026-09-24-001"
}'
Response200
{
  "txHash": "0x23b1…12e4",
  "chain": "celo",
  "symbol": "IMC",
  "treasury": "0xd459…fa9b",
  "preview": {
    "airtimeKes": 5300,
    "imc": 40.932962,
    "usdKes": 129.48
  },
  "actor": "ops@example.com"
}
StatusWhen
409This idempotencyKey was already booked
500The mint failed — including not enough airtime backing to mint IMC when the float doesn't cover it

GET /api/v1/admin/imc/airtime returns the gateway mode (simulated), bonus, CBK rate and — on a live gateway — the float balance. GET /api/v1/admin/imc/treasury?chain=celo returns the treasury's address and IMC balance.

Book an IMC mint (your own API keys) ​

🔑 API Key + Secret only. For an integrator who bought airtime at the gateway themselves: book that top-up here with its gateway receipt number and the engine mints the IMC into your treasury. A service token or a super-admin session is refused with 403 — those callers use the operator route above. Everything else is identical: the amount comes from the same float, the same engine relayer signs, and the same proof-of-reserve check gates it.

The receipt is required and it is the idempotency key. One gateway receipt books exactly once, ever — on either endpoint below, for any tenant. A repeat returns 409 with the original txHash.

Book a top-up with its receipt
curl -X POST "http://localhost:8100/api/v1/imc/book?chain=celo" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "receipt": "IMPALA-RCPT-8812",
  "cashKes": 5000
}'
Response200
{
  "receipt": "IMPALA-RCPT-8812",
  "txHash": "0x23b1…12e4",
  "chain": "celo",
  "symbol": "IMC",
  "treasury": "0xd459…fa9b",
  "preview": {
    "airtimeKes": 5300,
    "imc": 40.932962,
    "usdKes": 129.48
  },
  "actor": "acme API key"
}
FieldRequiredDescription
receiptyesThe gateway receipt number for the top-up being booked (≤ 128 chars)
cashKestestnetWhat was paid — required on testnet, where it sizes the mint; optional on a live gateway, where the float sizes it

Book an IMC mint and deliver it ​

🔑 API Key + Secret only. The same booking, plus one more field: after the mint lands in the treasury it is sent on to toAddress — a normal transfer, so the backing and the journal are exactly the same as above, with a second transfer entry for the delivery.

Book, mint and deliver
curl -X POST "http://localhost:8100/api/v1/imc/book-and-send?chain=celo" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "receipt": "IMPALA-RCPT-8813",
  "toAddress": "0x6087068115ac9F8D97F25B0B8089Ec77e7F194A6"
}'
Response200
{
  "receipt": "IMPALA-RCPT-8813",
  "txHash": "0x23b1…12e4",
  "sendTxHash": "0x9d02…4b71",
  "to": "0x6087068115ac9F8D97F25B0B8089Ec77e7F194A6",
  "amount": 40.932962,
  "chain": "celo",
  "symbol": "IMC",
  "treasury": "0xd459…fa9b",
  "preview": {
    "airtimeKes": 5300,
    "imc": 40.932962,
    "usdKes": 129.48
  },
  "actor": "acme API key"
}
FieldRequiredDescription
receiptyesThe gateway receipt number — same rules as /imc/book
toAddressyesWhere the minted IMC is delivered: a valid address on ?chain=, checked for format, EIP-55 checksum, the zero address and the token's own contract before anything is minted
cashKestestnetSame as /imc/book

If the mint succeeds but the delivery fails, the response is 502 with "minted": true and the mint's txHash — retry the identical request: the receipt is already spent so nothing mints again, and only the send is repeated.

StatusWhen
400receipt missing/over-long, toAddress unusable, or cashKes out of range
403Not a tenant's own X-API-Key/X-API-Secret
409This receipt was already booked (the original txHash is in the response), or a mint is still being confirmed on-chain
502The gateway/float couldn't size the mint, the mint failed, or the delivery failed ("minted": true — retry the same request)

Redeem Airtime (burn) ​

Any authenticated caller, for its own users. Locks the user's IMC, sends Airtel airtime from the gateway float, then burns the IMC.

FieldRequiredDescription
externalUserIdyesThe user whose IMC is redeemed
phoneyesAirtel Kenya number — 07xx, +2547xx or 2547xx (073x, 074x, 078x, 010x, 011x)
externalIdyesIdempotency key
airtimeKesone ofAirtime to send; IMC burned = airtimeKes ÷ USD/KES
amountBaseone ofIMC in base units instead; airtime = IMC × USD/KES
Redeem
curl -X POST "http://localhost:8100/api/v1/airtime/redeem?chain=celo" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "externalUserId": "1042",
  "phone": "+254733000000",
  "airtimeKes": 100,
  "externalId": "wd-1042-7781"
}'
Response200
{
  "status": "success",
  "airtimeKes": 100,
  "imcBurned": 0.772321,
  "usdKes": 129.48,
  "phone": "0733000000",
  "gatewayReference": "REF-…",
  "lockTxHash": "0x2c2f…7a32",
  "burnTxHash": "0xb243…b75f",
  "simulated": false
}
StatusWhen
400Not an Airtel Kenya number, or airtime outside 2–10,000 KES
402The user doesn't hold enough IMC (a 3% allowance covers USD/KES drift since the airtime was bought)
409This externalId was already redeemed
502 / 503The gateway send failed (the IMC is returned) or the float is empty

A redemption between 2 and 10,000 KES of airtime is sent synchronously; burnError appears in the response if the airtime went out but the burn needs operator attention.

Proof of reserve ​

GET /api/v1/admin/imc/reserve 🔒 super admin: IMC in circulation (on-chain total supply) next to the airtime behind it, both in KES at CBK's USD/KES.

FieldMeaning
imcInCirculation, imcValueKesIMC supply, and the airtime it needs (supply × USD/KES)
airtimeFloatKesThe real ImpalaPay float (null on testnet, where the gateway is simulated)
bookedAirtimeKes, redeemedAirtimeKes, booksAirtimeKesAirtime booked as IMC top-ups, redeemed back, and the difference: the books
differenceKes, coveragePctAirtime − IMC value, and airtime ÷ IMC value (the float on mainnet, the books on testnet)
statusmatched, unminted (airtime not yet issued as IMC; mintImc / mintAirtimeKes say how much to book), under_backed (an irregularity: IMC not covered by airtime), or unavailable

The engine checks the float every 5 minutes on mainnet. When the float grows, it records a top-up alert with the IMC to mint, and emails the super admins. Open alerts close themselves once the airtime is booked (POST /admin/imc/airtime-topup), or can be dismissed with POST /admin/imc/reserve/alerts/:id/dismiss. The response lists open alerts (?all=true includes closed ones).

A rising USD/KES raises the airtime every IMC needs, so an exactly-backed reserve can become under_backed when the rate moves.

Sending IMC (or any token) out ​

POST /api/v1/admin/transfers 🔒 super admin: moves tokens the engine already holds to any address, an external wallet or another engine wallet or relayer. Nothing is minted. The dashboard has it under Treasury → IMC & Airtime → Send IMC and Wallets → Send tokens.

json
{
  "chain": "celo",
  "asset": "IMC",
  "amount": "25.5",
  "from": { "kind": "wallet", "walletId": "treasury-imc-3", "tenantSlug": "super-admin" },
  "to": "0xRecipient…",
  "externalId": "payout-2026-09-25-1",
  "note": "optional, kept in the journal"
}
  • from.kind:

    • wallet: a custodial wallet (a user, treasury-<asset>-<tenant>, imm-vault-<chain>), identified by walletId and tenantSlug.
    • relayer: a tenant's own relayer, by tenantSlug and feature. The engine never substitutes its own relayer.
    • engine-relayer: the engine's relayer.
  • amount is in whole tokens.

  • externalId makes retries safe: the same id is only ever sent once.

  • The recipient is checked before anything is signed. The engine refuses:

    • an address with the wrong format
    • a mixed-case address whose checksum doesn't match (a typo)
    • the zero address
    • the token's own contract, or a Stellar issuer account

    POST /assets/:symbol/send applies the same checks.

  • Before signing, every contract call is simulated. A call that would revert is refused with the contract's own reason, e.g. Not authorized, and costs no gas.

Released under the MIT License.