</>CodeWithKarani

KRA eTIMS Survival Guide: OSCU, cmcKey and the Rules the Spec Leaves Out

Karani GeoffreyKarani Geoffrey7 min read

From the 2026 year of income, KRA's position is blunt: an expense not supported by a valid electronic tax invoice is not a deductible expense. That turns eTIMS from a compliance nicety into a line item on your customers' tax bill, and it turns "we should integrate eTIMS someday" into a P1.

The docs you get after signing up are a specification document and a Postman collection. They tell you the fields. They do not tell you the model. Here is the sentence that would have saved me three weeks:

eTIMS is not an invoicing API. It is a fiscal device protocol. Your application is impersonating a tax register, and every quirk in the API makes sense once you accept that.

OSCU or VSCU

Two system-to-system routes. OSCU (Online Sales Control Unit) signs each invoice against KRA's servers in real time - right for anything that is always online. VSCU (Virtual Sales Control Unit) runs a local component that can sign offline and sync in batches - right for high-volume or intermittently connected environments, and it typically listens on port 8088 locally.

Most SaaS and web products want OSCU. Choose it unless you genuinely invoice offline, because VSCU adds a deployed component you now have to operate at every customer site.

Base URLs and the device handshake

; sandbox
etims_base_url = https://etims-api-sbx.kra.go.ke/etims-api

; production
etims_base_url = https://etims-api.kra.go.ke/etims-api

Before anything else you initialise a device. This is a one-time call per branch, and it is the only call that does not need a communication key, because its whole purpose is to give you one.

import requests

BASE = "https://etims-api-sbx.kra.go.ke/etims-api"

def initialise(tin: str, bhf_id: str, dvc_srl_no: str) -> dict:
    r = requests.post(
        f"{BASE}/selectInitOsdcInfo",
        json={"tin": tin, "bhfId": bhf_id, "dvcSrlNo": dvc_srl_no},
        headers={"Content-Type": "application/json"},
        timeout=30,
    )
    body = r.json()
    if body["resultCd"] != "000":
        raise RuntimeError(f"init failed {body['resultCd']}: {body['resultMsg']}")
    info = body["data"]["info"]
    return {
        "cmc_key": info["cmcKey"],
        "trade_name": info.get("tradeNm"),
        "branch_name": info.get("bhfNm"),
        "sdc_id": info.get("sdcId"),
        "mrc_no": info.get("mrcNo"),
    }

Store cmcKey like a private key. It is the credential for every subsequent call, it is bound to the exact dvcSrlNo you sent, and re-initialising with a different serial invalidates the old one. Teams that generate dvcSrlNo from a container hostname or a random UUID at boot discover this on their first redeploy, in production, mid-month.

After init, every request carries three headers:

curl -X POST "$BASE/selectItemClsList" \
  -H "tin: P000000045R" \
  -H "bhfId: 00" \
  -H "cmcKey: 3F2A9C...." \
  -H "Content-Type: application/json" \
  -d '{"tin":"P000000045R","bhfId":"00","lastReqDt":"20260101000000"}'

Note there is no OAuth, no bearer token, no expiry. Just a long-lived key in a custom header. Treat it accordingly: secrets manager, not .env in the repo.

The endpoint map

PathPurpose
/selectInitOsdcInfoDevice initialisation, returns cmcKey
/selectCodeListAll code lists (tax types, payment types, statuses)
/selectItemClsListItem classification codes
/selectBhfListBranches on the PIN
/selectCustomerLook up a customer by PIN
/saveBhfCustomerRegister a customer on the branch
/saveItemRegister a product before you can sell it
/selectItemListItems already registered
/saveTrnsSalesOsdcSubmit a sales invoice or credit note
/insertTrnsPurchaseRecord a purchase
/selectTrnsPurchaseSalesListPurchases your suppliers filed against you
/insertStockIOStock in and out movements
/saveStockMasterSet current stock levels
/saveItemCompositionBill of materials for manufactured items
/selectNoticeListKRA notices - actually worth polling

Submitting a sale

{
  "tin": "P000000045R",
  "bhfId": "00",
  "invcNo": 1042,
  "orgInvcNo": 0,
  "trdInvcNo": "INV-2026-01042",
  "custTin": "P051234567X",
  "custNm": "Acme Supplies Ltd",
  "salesTyCd": "N",
  "rcptTyCd": "S",
  "pmtTyCd": "01",
  "salesSttsCd": "02",
  "cfmDt": "20260723143000",
  "salesDt": "20260723",
  "totItemCnt": 1,
  "taxblAmtA": 0, "taxblAmtB": 10000, "taxblAmtC": 0, "taxblAmtD": 0, "taxblAmtE": 0,
  "taxRtA": 0, "taxRtB": 16, "taxRtC": 0, "taxRtD": 0, "taxRtE": 8,
  "taxAmtA": 0, "taxAmtB": 1600, "taxAmtC": 0, "taxAmtD": 0, "taxAmtE": 0,
  "totTaxblAmt": 10000,
  "totTaxAmt": 1600,
  "totAmt": 11600,
  "prchrAcptcYn": "N",
  "regrId": "karani",
  "regrNm": "karani",
  "modrId": "karani",
  "modrNm": "karani",
  "receipt": {
    "custTin": "P051234567X",
    "rcptPbctDt": "20260723143000",
    "trdeNm": "",
    "adrs": "",
    "topMsg": "Thank you",
    "btmMsg": "",
    "prchrAcptcYn": "N"
  },
  "itemList": [
    {
      "itemSeq": 1,
      "itemCd": "KE1NTXU0000001",
      "itemClsCd": "5059690800",
      "itemNm": "Consulting - integration",
      "bcd": null,
      "pkgUnitCd": "NT",
      "pkg": 1,
      "qtyUnitCd": "U",
      "qty": 1,
      "prc": 10000,
      "splyAmt": 10000,
      "dcRt": 0,
      "dcAmt": 0,
      "taxTyCd": "B",
      "taxblAmt": 10000,
      "taxAmt": 1600,
      "totAmt": 11600
    }
  ]
}

POST to /saveTrnsSalesOsdc. A success returns the fiscal stamp:

{
  "resultCd": "000",
  "resultMsg": "Successful",
  "resultDt": "20260723143002",
  "data": {
    "curRcptNo": 87,
    "totRcptNo": 1042,
    "intrlData": "T5NGXQ2ZLM6RJP4A",
    "rcptSign": "K9F2QW7ZC1XB4T8H",
    "sdcDateTime": "20260723143002"
  }
}

Those five values are what makes the invoice legal. Persist all of them against the invoice row, and build the verification QR from them:

from urllib.parse import quote

def etims_qr_url(pin: str, bhf_id: str, rcpt_sign: str, sandbox: bool = False) -> str:
    host = "etims-sbx.kra.go.ke" if sandbox else "etims.kra.go.ke"
    data = f"{pin}{bhf_id}{rcpt_sign}"
    return (f"https://{host}/common/link/etims/receipt/"
            f"indexEtimsReceiptData?Data={quote(data)}")

The tax type codes

taxTyCdMeaningRate
AExempt0%
BStandard VAT16%
CZero rated0%
DNon-VAT0%
EReduced rate8%

A and C and D all compute to zero tax but they are not interchangeable - they land in different boxes on the VAT return. Let the finance lead map your product catalogue, not a developer guessing.

What the docs do not tell you

1. invcNo is a sequence, not an identifier. It must be a monotonically increasing integer per branch, starting at 1, with no gaps. It is not your order ID and it is not a UUID. Keep it in its own table with a row lock, and allocate it only when you are actually about to transmit - if you allocate on invoice creation and the invoice is later voided, you have a permanent gap that will show up in an audit.

CREATE TABLE etims_sequence (
    tin     TEXT   NOT NULL,
    bhf_id  TEXT   NOT NULL,
    next_no BIGINT NOT NULL,
    PRIMARY KEY (tin, bhf_id)
);

-- allocate under a lock, inside the same transaction as the outbox write
UPDATE etims_sequence
SET next_no = next_no + 1
WHERE tin = $1 AND bhf_id = $2
RETURNING next_no - 1 AS invc_no;

2. There is no cancel. Once an invoice is signed it exists forever. Corrections are credit notes: set rcptTyCd to R, put the original invoice's invcNo in orgInvcNo, and consume a fresh invcNo for the credit note itself. Also, a credit note can only be raised from the same solution that raised the original - you cannot invoice on the KRA portal and credit from your ERP.

3. You cannot invent itemClsCd. It must come from /selectItemClsList. These are 10-digit UNSPSC-derived codes. Cache the whole list at deploy time and force product setup to choose from it, because a wrong classification code is rejected with a code that will not tell you which field is wrong.

4. Items must be registered before they can be sold. /saveItem first, then /saveTrnsSalesOsdc. For a product catalogue that changes daily this needs to be an automatic sync, not a manual step.

5. Arithmetic must tie exactly. taxblAmt + taxAmt must equal totAmt per line; line totals must equal the header totals; the per-rate buckets must equal the sum of their lines. Round with Decimal and ROUND_HALF_UP to 2dp at the line level, then sum. If you sum first and round after, you will drift by a cent and get an unexplained rejection.

6. resultCd is a string. "000" is success. "001" is a general failure. Codes in the 900 range are validation problems. Never compare it as an integer and never truncate the leading zeros - a JSON parser that coerces it to 0 will make your success check silently wrong.

Architecture: never block checkout on KRA

KRA's servers have bad days. If your checkout calls eTIMS synchronously, KRA's latency becomes your conversion rate. Use the outbox pattern.

def on_invoice_finalised(conn, invoice):
    with conn.transaction():
        invc_no = allocate_etims_seq(conn, TIN, BHF)
        conn.execute("""
            INSERT INTO etims_outbox (invoice_id, invc_no, payload, state, attempts)
            VALUES (%s, %s, %s, 'QUEUED', 0)
        """, [invoice.id, invc_no, build_etims_payload(invoice, invc_no)])
    # customer is done. worker handles KRA.

def worker_tick(conn, client):
    for job in claim_queued(conn, limit=25):
        try:
            res = client.post("/saveTrnsSalesOsdc", job["payload"])
        except requests.RequestException:
            reschedule(conn, job, backoff(job["attempts"]))
            continue

        log_raw(conn, job["id"], res)          # always keep request + response
        if res["resultCd"] == "000":
            mark_signed(conn, job, res["data"])
        elif res["resultCd"] in RETRYABLE:
            reschedule(conn, job, backoff(job["attempts"]))
        else:
            mark_failed_for_human(conn, job, res)   # never silently retry a rejection

Log the full request and response for every attempt. When KRA support asks what you sent on 14 March, you want to answer in thirty seconds, not reconstruct it from a serialiser that has since changed.

Start here

Request sandbox access early - the approval is the long pole, not the code. Then, in order: initialise a device, cache the code lists and item classifications, register your catalogue, submit one invoice end to end, and only then wire it into your checkout behind an outbox. The API is small. The sequencing rules are what will get you.

#etims#kra#kenya#tax#compliance#api
Keep reading

Related articles