Managed CIS API
Use this API when our service manages your catalogue, stock, receipt history and printing controls. Use your assigned service URL and environment-pinned X-API-Key. The operator must approve access, initialize the branch, configure its CIS receipt profile and register catalogue items before invoicing. Production requires separate enablement and device initialization.
The OpenAPI contract includes the invoice and print-preparation endpoints. The workflow guide describes receipt modes, receiving and reporting. Catalogue, stock, receiving, cash and reports are also documented below and in OpenAPI. Company settings, tax programming and recovery decisions require an owner/operator sign-in.
Issue a normal sale
The downloadable package includes ebm_client.py, a runnable
Python 3 standard-library example. Set EBM_BASE_URL and EBM_API_KEY in your
environment, replace the sample catalogue UUID/customer details, and run:
python3 ebm_client.py cis-sale-request.json --request-key order-0042-issue
Persist the original JSON and request key in your order system before sending.
The example never retries automatically and refuses redirects. EbmError
retains the HTTP status and JSON response, including business failures sent with
HTTP 200. A transport error or incomplete issuance response leaves the outcome
uncertain: retain the original input/key for recovery. Save successful responses
durably and check delivery status separately from issuance.
For embedding in a backend:
import os
from ebm_client import EbmClient
client = EbmClient(os.environ["EBM_BASE_URL"], os.environ["EBM_API_KEY"])
# original_body and persistent_key come from your stored order.
issued = client.issue_invoice(original_body, persistent_key)
receipt = client.get_receipt(issued["cisReceiptId"])
print(receipt["status"])
Persist the request body and a unique request key in your order database before sending. Catalogue UUIDs are available from GET/api/portal/items in the data array; choose the item registered for the intended branch. Prices and taxes are calculated on the server from that catalogue. Do not send replacement prices or fiscal signatures.
curl --request POST "$EBM_BASE_URL/api/portal/fiscal/invoices" \
--header "X-API-Key: $EBM_API_KEY" \
--header 'Content-Type: application/json' \
--header 'X-Idempotency-Key: order-0042-issue' \
--data-binary @cis-sale-request.json
Download cis-sale-request.json (replace the catalogue UUID, invoice number and customer details):
{
"branch": "00",
"invoice_number": "42",
"mode": "NS",
"customer_name": "Walk-in customer",
"customer_tin": "",
"customer_phone": "0781234567",
"payment_code": "01",
"lines": [
{
"item_id": "00000000-0000-0000-0000-000000000001",
"quantity": 1,
"discount_percent": 0
}
]
}
invoice_number is a positive 64-bit integer encoded as a JSON string. Quantities and discounts accept at most two decimal places. Goods require sufficient available stock; services do not. A new invoice requires a customer name and either a customer TIN or mobile telephone. Some customer TINs require a purchase code under the configured VSDC policy.
Inspect HTTP status and resultCd. HTTP 200 with a non-000 result is not successful issuance. A successful response contains the VSDC envelope (resultCd, resultMsg, resultDt, data) and a top-level cisReceiptId. Save the entire response. Use that UUID for receipt retrieval and print preparation.
Recover an interrupted request
Send the same body and the same X-Idempotency-Key to retrieve the stored outcome. Do not rebuild it with new invoice numbers, different lines or a different request key. The service retains the original fiscal payload so catalogue changes do not reprice an existing operation.
HTTP 409 can mean conflicting input, insufficient stock, a busy branch or an unresolved previous transaction. Preserve the original request and response. A busy-branch response includes code: "BRANCH_BUSY" and Retry-After: 1; the operation could not acquire its branch lock. Wait at least the indicated number of seconds, then retry the exact original body and request key with a bounded retry policy. An uncertain signing outcome requires operator reconciliation. Do not turn every 409 or timeout into a new sale.
Refunds and copies
Use the same invoice endpoint with a new invoice number and request key:
| Mode | Source and controls |
|---|---|
NR |
source_receipt is the original normal sale's UUID. Supply refund_reason, refund_explanation and payment_code; use empty lines. The complete original is cancelled once; partial refunds are not implemented. |
CS |
Source is a normal sale. Empty lines; copies retain the original values without changing stock. |
CR |
Source is a normal refund. Empty lines; copies retain the original values without changing stock. |
TS, TR |
Supply lines and explicitly set training_enabled: true. Training does not change normal stock or sale totals. |
PS |
Supply lines for a proforma. No normal stock deduction. |
Receipt status and printing
GET/api/portal/receipts/{cisReceiptId} returns the stored request, fiscal response and delivery status inside data. ACCEPTED records RRA acceptance; VSDC_ISSUED records issuance by a remote VSDC and requires downstream reconciliation there. Other statuses are not proof of acceptance. List receipts using GET/api/portal/receipts?offset=0 (up to 50 per page).
Prepare the original print using POST/api/portal/cis/receipts/{cisReceiptId}/print, with a separately persisted X-Idempotency-Key and no body. The data response contains the immutable id, profile, request, receipt, source_receipt_number, version and print_kind. Retry with the same key to recover that snapshot. A different key cannot authorize another original print; issue a signed copy when appropriate.
Use the portal's existing receipt rendering or implement and qualify your own rendering against the CIS specification. Returning a print snapshot does not prove paper delivery. Receipt layout, QR scanning and printer recovery remain qualification requirements.
Sandbox acceptance exercise
For each branch, exercise a service sale, stocked-goods sale, insufficient stock, unchanged retry, changed-input conflict, full refund, duplicate refund, copy and all nonofficial modes. Confirm one fiscal record per successful order and verify delivery status independently of issuance. Test lost responses without replacing the request identity. Verify isolation using a second company and a separate environment.
Catalogue, stock and receiving
All paths below start with /api/portal. API keys remain pinned to their environment even if a request supplies X-EBM-Environment. Approval and production enablement apply to these operations. Keys cannot change company settings, tax configuration, other keys or recovery decisions.
| Method and path | Required write scopes | Request and outcome |
|---|---|---|
GET/items |
— | Catalogue records (id, body, updated_at), up to 2000 per page. Follow next_after using ?after=... until null. |
POST/items |
items:write |
VSDC ItemSaveReq fields. The authenticated TIN replaces the submitted TIN. Returns data.id. An empty item code generates a new code; preserve the returned code for updates. |
POST/cis/items/register/{id} |
items:write, receipts:write |
No body. Registers the saved item with its configured branch VSDC; inspect resultCd. |
GET/cis/stock?branch=00 |
— | Quantity, reserved and available per catalogue item. |
POST/cis/stock |
stock:write, receipts:write |
Branch, item UUID, signed nonzero quantity and reason. Requires persistent request key. |
GET/cis/delivery?branch=00 |
— | Stock delivery states; local movement does not prove downstream delivery. |
POST/cis/references/{kind} |
stock:write, receipts:write |
{ "branch":"00", "since":"20260901000000" }; kind is imports, purchases, customers or notices. |
GET/cis/inbound?branch=00 |
— | Inbox records with source body, state and prior decision. |
POST/cis/inbound/{id} |
stock:write, receipts:write |
Approval/rejection, line mappings and reason; requires persistent request key. |
Catalogue pages are ordered by item code. URL-encode the returned next_after when passing it as after; do not construct cursors from invoice or catalogue UUIDs. Existing clients still receive a data array, but must follow the cursor to retrieve more than 2000 records. Python client.iter_items() traverses the pages, and the portal loads them automatically. Each page remains scoped to the API key's company and environment. Concurrent catalogue edits do not form a frozen snapshot; for a stable export, pause edits during traversal.
GET/api/portal/items includes registered_at on each catalogue entry. A timestamp records successful registration of the current body with the configured fiscal backend for body.bhfId; null means registration is required. This does not register the item in every branch. Every catalogue edit clears its registration status. Re-register before invoicing. If an edit occurs while registration is in flight, HTTP 409 explains that the submitted version was accepted but the current catalogue version remains unregistered. Reload and review the current item before deliberately registering it; do not treat this conflict as proof that the upstream submission failed. Catalogue creation with an automatically generated code does not support idempotency-key replay: after a lost response, reconcile with the list before creating again. Explicit item-code saves update that company's item in that environment.
A stock adjustment example is { "branch":"00", "item_id":"YOUR-CATALOGUE-UUID", "quantity":10, "reason":"Opening stock count" }. A negative quantity removes goods. Retain the original body and request key for retry. The result's data.operation identifies the local operation; inspect stock delivery independently.
For inbound approval, map each source line to a catalogue UUID in the original order. Different source lines may map to the same item. Imports require the corresponding import_unit_costs; purchase approval needs an invoice number. Optional conversions give exact positive numerator/denominator ratios for quantity and packages, plus an audit reason. Inexact results beyond two decimal places are rejected. The OpenAPI schema documents these fields.
For rejection, send { "branch":"00", "approve":false, "items":[], "reason":"Goods not received" }. A supplier return requires the original approved purchase invoice and preserves the full original values. A completed identical decision returns its saved result; changed or uncertain decisions require reconciliation.
Python receiving example (persist the decision and key before sending):
resolution = client.resolve_inbound(inbound_id, original_decision, persistent_key)
# Decision completed; check the separately queued stock-delivery messages.
delivery = client.request("GET", "/api/portal/cis/delivery?branch=00")["data"]
On EbmError, retain error.response, the original decision and its key. Codes 894, 896 and 899 are treated as uncertain by the service; inspect the inbound state and obtain operator reconciliation before another decision. A business refusal also requires inspecting the saved state rather than submitting a new decision blindly.
Reference fetches store a cursor and do not promise replay after a lost response. Inspect the inbox before advancing your cursor; the next since must be later than the last successful value for that branch and reference kind.
Cash and reports
Both endpoints require receipts:write:
- POST
/api/portal/cis/cashtakesbranch,kind(depositorwithdrawal), positiveamountandreason. Persist anX-Idempotency-Key. Identical retries recoverdata.id; changed content conflicts. This records cash movement, not a bank or Mobile Money transfer. - POST
/api/portal/cis/reportstakesbranch,kind,fromandto. Dates areYYYY-MM-DD, ordered, with an inclusive end no later than today in Kigali. Kinds areX,Z,PLU,SALES,STOCK,PURCHASES,IMPORTSandITEMS.
X/Z begin after the last closed Z period; other reports use the requested start. A Z report refuses unresolved receipts, inbound decisions or stock delivery. A successful Z closes the local CIS period. It does not establish RRA report submission. The response explicitly records that distinction. Persist an X-Idempotency-Key before generating a report. An unchanged retry returns the original snapshot, even after Z closes the period. Changed input under that key returns HTTP 409. The header remains optional for older clients, but requests without it cannot recover a lost response by replay. A new request to close an already closed period returns a conflict.
Report output is a data snapshot. groups_cents and cash_balance_cents store monetary values as integer cents; divide by 100 for display. Tax arrays are ordered A, B, C, D. count counts receipts and items counts item lines; these are not money. Mode groups retain positive refund amounts: subtract NR from NS when calculating net normal sales. PLU quantity_hundredths and amount_cents already represent net normal sales/refunds. PLU tax_rate is the percentage from the original receipt; lines with different item codes, unit prices, tax groups or rates remain separate. It is null when unavailable and absent in older saved snapshots; do not substitute current tax settings. remaining_quantity_hundredths can be null for an item with no tracked balance. from and to_exclusive are Kigali timestamps in YYYYMMDDhhmmss; the first X/Z period can begin at 00000000000000 (first recorded operation). Retain the report version and profile. Qualify your printed report layout against the CIS requirements rather than assuming the JSON itself is a qualified printed report.
New report snapshots also contain plu_categories: net normal-sale/refund totals grouped by the original receipt's itemClsCd (item classification) and qtyUnitCd (quantity unit). Each entry has item_classification, quantity_unit, quantity_hundredths and amount_cents. Classifications and units may be null for incomplete historical records; the portal labels these explicitly. Different units are never added into a single quantity total. Category amounts sum to the PLU line amounts, and both exclude copies, training and proformas. A classification or quantity-unit change also separates item lines. Older snapshots omit plu_categories; show it as unavailable rather than recalculating from today's catalogue.
Retrieve saved reports through GET/api/portal/cis/reports?branch=00&offset=0 (50 newest-first records per page) and GET/api/portal/cis/reports/{id}. Retrieval returns the original snapshot, including its profile and totals, without recomputing against current data. The portal provides the same history and can reopen a saved report for printing.
Response contracts
The published OpenAPI includes response schemas for managed issuance, receipt list/detail, print snapshots, catalogue, stock balances/adjustments/delivery, item registration, inbound/reference operations, cash, report generation and report history. Fields not described by a schema can still be present; clients should tolerate added fields. Raw VSDC operations also have response contracts: reflected typed endpoints describe nested fields; upstream passthrough routes explicitly retain flexible data. Nested source payloads and backend-dependent fields remain extensible objects.
A successful managed invoice requires resultCd: "000", cisReceiptId and the fiscal receipt fields in data. Training/proforma signatures can be empty. Fiscal counters are JSON integers and may exceed JavaScript's exact integer range: use a parser that preserves 64-bit integers. Receipt-history invoice and counter fields are strings.
Inbound decisions return a nested fiscal result in data.response. Inspect data.response.resultCd and fetch the inbound record's state; HTTP 200 does not by itself establish approval or rejection completion. Use Python resolve_inbound(inbound_id, original_body, persistent_key) to check the nested result automatically. It returns the resolution only for result code 000; otherwise it raises EbmError while retaining the full original envelope in error.response. An incomplete nested outcome also raises without retrying. The generic request() helper checks top-level fiscal results only. Unknown outcomes require reconciliation with the original input.
For JSON application errors, inspect success: false and message. Invalid JSON, framework extraction errors and gateway failures may return plain text; do not assume every error is JSON or replace a fiscal request key after a parsing failure.
Paging stock delivery history
GET/api/portal/cis/delivery?branch=00 returns up to 500 messages newest first in data, plus next_before_id (a string or null). Pass that cursor unchanged as before_id for older records until it is null. Keep the branch and optional state filter unchanged while paging. Supported states are PENDING, SUBMITTING, UNKNOWN, FAILED, and SENT. For example, ?branch=00&state=UNKNOWN finds uncertain messages even when newer successful messages fill the first unfiltered page.
Statuses can change during pagination. Refresh from the first page to see those changes; this listing is not a frozen export. Reading or filtering messages never retries them. Uncertain outcomes still require verified owner/operator reconciliation.
Registered-item reference data and the customer catalogue
POST/api/portal/fiscal/items/selectItems retrieves upstream item updates. With the local Rust VSDC backend, these populate its company/environment-scoped product cache; they do not automatically overwrite the editable managed CIS catalogue returned by /api/portal/items. An empty incremental item list does not delete existing cached products. A supplied useYn updates the cached usage flag; omission preserves an existing flag.
For managed invoicing, create or review the item through the catalogue API and register its current body for the intended branch. The portal provides a manual Review in catalogue action for selected registered reference items. API clients can likewise review the source, save the intended body through /api/portal/items, then register it. Automatic bulk synchronization between upstream reference items and the customer catalogue is not yet provided. Use the conditional-save headers below to protect reviewed replacements from concurrent catalogue saves. Do not interpret a successful reference fetch as completion of that workflow.
Conditional catalogue saves
OpenAPI 1.7.0 exposes an opaque UUID version on each catalogue entry and in each save result. For an explicit itemCd, send If-Match: "VERSION-UUID" to replace only the version you reviewed, or If-None-Match: * to create only when that code is absent. Use one condition at a time. These checks are scoped to the authenticated company/environment and run under the catalogue write lock. A failed condition returns HTTP 412 with no catalogue change. Malformed or combined conditions return HTTP 400.
Every successful save assigns a new version and clears registration, including saves with identical bodies. Registration itself does not change the body version. If a successful save response is lost, repeating its old condition may return 412: reload and compare the current item before deciding what to do. Do not automatically retry by dropping the condition. Saves without these optional headers retain the existing replacement behavior. Generated-code creation cannot use these conditions; provide an explicit code.
The shipped Python client supports both conditions directly:
# item is a complete catalogue body with an explicit itemCd.
saved = client.save_item(item, create_only=True)
# Later, load the version that you actually review before editing.
entry = client.get_item(saved["id"])
edited = {**entry["body"], "itemNm": "Reviewed item name"}
updated = client.save_item(edited, version=entry["version"])
save_item returns the saved id, version and message. It requires exactly one of version or create_only=True and never retries or drops a precondition. A conflict raises EbmError with status == 412; reload and review the current record. A malformed success response also raises an error because the save may already have committed. Lower-level request accepts the keyword arguments if_match (one quoted UUID) or if_none_match="*".
Reading one catalogue item
GET/api/portal/items/{id} returns the current catalogue entry in data, including body, version, updated_at and nullable registered_at. Read access works with a read-only API key. The item must belong to the authenticated company and key environment; missing or out-of-scope IDs return HTTP 404. The Python helper is client.get_item(item_id). After a conditional-save conflict, read the item again and review its new version before another save.
Questions about onboarding, keys or branches? Contact your EBM Hub onboarding contact.