Skip to main content

sha-claim

Python SDK for Social Health Authority (SHA, Kenya) claims through the Digital Health Agency's AfyaConnect HIE eClaims API. Async-first, typed, framework-free — httpx and pydantic, nothing else.

pip install sha-claim

Status: 0.1.18, pre-production. All 49 published endpoints implemented. Eligibility, benefits, consent, the claim lifecycle, discharge and dispensing are verified live against DHA UAT. Two things are not ours to fix: prescribe() is blocked by an unpublished patient_instruction vocabulary (§9.8), and the pre-auth nested arrays are unverified (§14). Both are tracked in docs/api/WORKFLOWS.md. See PLAN.md.

This file is the whole guide — every method, its parameters, the HTTP call it makes, the object it returns, end-to-end recipes, and the gotchas that cost us days. Written for a developer who has never touched SHA, the DHA API or this SDK: read §1–§4 once, then come back to the rest as reference.

Contents

  1. What you are talking to
  2. Install and configure
  3. The client
  4. Errors
  5. The types you pass around
  6. sha.eligibility — who is covered, for what · sha.registries
  7. sha.consent — patient consent (OTP)
  8. sha.claims — opening and resuming a claim
  9. ClaimSession — everything you do to one claim
  10. sha.emergency — emergency cases
  11. sha.files — standalone uploads
  12. End-to-end recipes
  13. Testing code that uses the SDK
  14. Gotchas
  15. Appendix — every result object, field by field

Beyond this file: ARCHITECTURE (how the SDK is built and why), WORKFLOWS (what UAT actually does, as opposed to what it documents), api/ (reference generated from the official portal), NACARE_INTEGRATION, CERTIFICATION, RELEASING, CHANGELOG.


1. What you are talking to

SHA (Social Health Authority) is Kenya's public health insurer. Facilities get paid by submitting claims to SHA through the Digital Health Agency's AfyaConnect HIE eClaims API. This SDK is a typed Python wrapper around that API.

Four ideas explain almost everything:

Idea What it means for you
The claim lives on the server. You don't build a claim locally and post it. You open one on the server, then add things to it call by call, then submit it.
consent_token is the handle. Opening a claim returns a token. Every later call about that claim needs it. The SDK hides this inside a ClaimSession object. Treat the token like a password: it lets anyone bill against that patient's visit.
Consent is a two-step OTP dance. authorize sends the patient an SMS code; open_visit consumes it. Without the code, no claim.
Interventions are the unit of everything. An intervention code (e.g. SHA-12-001 Consultation) is what you authorise, bill against, attach documents to, and pre-authorise.

Two environments exist: UAT (https://ilm-dev.dha.go.ke/uat-middleware, test data) and production (URL supplied by DHA at onboarding). Your credentials usually decide which facility you are — the facility code is embedded in the access token. A credential that covers several facilities sends the code per request instead; see Facility scoping in §3.


2. Install and configure

pip install sha-claim

Requires Python 3.11+. Runtime dependencies: httpx, pydantic — nothing else.

Configuration is read from environment variables:

Variable Required Default Meaning
SHA_CLIENT_ID yes — OAuth2 client id from DHA onboarding
SHA_CLIENT_SECRET yes — OAuth2 client secret
SHA_ENVIRONMENT no uat uat or production
SHA_BASE_URL production only UAT URL Override the base URL (no trailing /api/v1)
SHA_CONNECT_TIMEOUT no 5 seconds
SHA_READ_TIMEOUT no 30 seconds
SHA_UPLOAD_TIMEOUT no 120 seconds, for multipart uploads
SHA_FACILITY_ID no — static X-Facility-Id for single-facility deployments (see Facility scoping in §3)
SHA_FACILITY_ID_TYPE no fr-code X-Facility-Id-Type

Missing or invalid settings raise ConfigurationError when you create the client, not later.

If you prefer code over env vars:

from sha_claim import SHASettings, Environment, Timeouts

settings = SHASettings(
    client_id="…",
    client_secret="…",
    base_url="https://ilm-dev.dha.go.ke/uat-middleware",
    environment=Environment.UAT,
    timeouts=Timeouts(connect=5, read=30, upload=120),
)

3. The client

Everything starts from AsyncSHAClient. It is async: every call is awaited and you run it inside an event loop (asyncio.run(...), or your FastAPI/Django-async handler).

import asyncio
from sha_claim import AsyncSHAClient, IdentificationType


async def main() -> None:
    async with AsyncSHAClient.from_env() as sha:  # reads SHA_* env vars
        e = await sha.eligibility.check("12345678", IdentificationType.NATIONAL_ID)
        print(e.full_name, e.member_found)


asyncio.run(main())

async with … as sha: opens the HTTP connection pool and closes it at the end. Reuse one client for many calls; don't create one per request. Tokens are fetched on first use, cached, and refreshed automatically (single-flight: 100 concurrent callers trigger one refresh).

The client exposes seven resources:

Attribute What it's for Section
sha.auth Which facility am I? Is the credential working? below
sha.eligibility Is this person covered? For which benefits/interventions? Balances. §6
sha.registries Turn an identity document into a CR number; can this member receive an OTP? §6.4
sha.consent Send the OTP; inspect/reject an authorization §7
sha.claims Open a claim (→ ClaimSession) or resume one from a saved token §8
sha.emergency Open an emergency case (→ ClaimSession); list treatment protocols §10
sha.files Upload a file / get a download URL §11

sha.auth is plumbing you rarely touch — the SDK authenticates on its own — but three calls are useful:

Method Returns
identity() Identity — facility, facility_id_type, tenant_id, tenant_name, issuer, client_id, issued_at, expires_at, seconds_remaining (prop). Read off the cached token; never returns the token itself.
check() True if a token can be obtained, else raises. A readiness probe.
token() BearerToken — access_token, expires_in, token_type. For code that must call the HIE directly (legacy paths, bots). Prefer the SDK's own methods: they never need it.
me = await sha.auth.identity()
print(me.facility, me.tenant_name)  # "which facility am I", without parsing a JWT yourself

Constructor options, all optional (used mainly in tests):

AsyncSHAClient(settings, transport=…, tokens=…, clock=…, retry=RetryPolicy(max_attempts=3), http=httpx.AsyncClient())

Observability. Pass on_event= a callable and the SDK calls it once per HTTP attempt with an SDKEvent — operation, status, duration_ms, attempt, trace_id, redacted consent_token, error. The SDK stores nothing; your callback is where audit logging, metrics and alerting live. A hook that raises is logged and ignored — it can never break a claim call.

def audit(e: SDKEvent) -> None:
    db.insert(
        "sha_events",
        operation=e.operation,
        status=e.status,
        trace_id=e.trace_id,
        duration_ms=e.duration_ms,
        token=e.consent_token,
        at=e.occurred_at,
    )
    if not e.ok:
        metrics.increment("sha.errors", tags={"op": e.operation, "status": e.status})


async with AsyncSHAClient.from_env(on_event=audit) as sha:
    ...

Facility scoping. DHA scopes claims/preauth/patient calls to one facility (docs). A facility-specific credential needs nothing: its token carries facility_id. A multi-facility integration sends X-Facility-Id + X-Facility-Id-Type on every request; the SDK does that for you:

with facility_scope("FID-47-115307-8"):  # everything inside is scoped; safe under concurrency
    await sha.eligibility.check(...)
# or, in a per-request web dependency: activate_facility(code) … clear_facility()
# or, one facility for the whole process: SHA_FACILITY_ID=FID-… / SHASettings(facility=...)

Headers override the token claim. The SDK never sends one header without the other.

Retries. Reads (GET, and preview) are retried up to 3 times with jittered backoff on network errors and 408/429/502/503/504. Writes are never retried automatically. A 401 triggers one token refresh and one replay.


4. Errors

Every exception the SDK raises is a subclass of sha_claim.SHAClaimError. Catch that if you don't care which; catch specific ones when you do.

Exception When Useful attributes
ConfigurationError bad/missing settings —
RequestValidationError the SDK refused to send because the request is certainly wrong (empty list, negative price, bad ICD format…) .violations → list of Violation(field, message)
BadRequestError server said 400 — usually a business rule ("intervention X not supported for OUTPATIENT") .status, .error, .trace_id
AuthenticationError token could not be obtained, or 401 even after a refresh .trace_id
PermissionDeniedError 403 .trace_id
NotFoundError 404 .trace_id
ServerError any other 4xx .status, .trace_id
RateLimitedError 429 .retry_after (seconds, may be None)
TransportError network failure / timeout / 5xx after retries .trace_id
SubmissionOutcomeUnknownError submit was sent but we never got an answer — see §9 lifecycle .trace_id
UnexpectedResponseError the server answered with a shape we don't understand —

trace_id is the server's own correlation id. Quote it when you raise a ticket with DHA.

from sha_claim import BadRequestError, RequestValidationError, SHAClaimError

try:
    auth = await sha.consent.authorize(patient, ServiceType.OUTPATIENT, ["SHA-12-001"])
except RequestValidationError as e:  # nothing was sent
    for v in e.violations:
        print(v.field, v.message)
except BadRequestError as e:  # server refused
    log.warning("SHA rejected: %s (trace %s)", e, e.trace_id)
except SHAClaimError:  # anything else from the SDK
    raise

Server messages are unwrapped for you: the API sometimes nests JSON inside strings ({"error":"{\"error\":\"Kindly note…\"}"}); str(exc) gives you the human sentence.


5. The types you pass around

The SDK uses small typed values instead of bare strings so mistakes fail early and loudly. Almost every method also accepts a plain str and converts it for you — the types below are what you get back, and what you use when you want the validation up front.

Identifiers

Type What it is Notes
PatientId the beneficiary's CR number (e.g. CR7678914660684-5) comes from eligibility.check(...).patient_id; this is patient_id everywhere in the API
ConsentToken the claim handle returned by open_visit repr/str are redacted (CR76…G6); use .value when you must persist it
ClaimGuid, LineGuid, AttachmentId, FileId, InvoiceNumber, FacilityCode server ids / your invoice number non-empty, whitespace-trimmed

All raise ValueError if empty.

Codes

Type Format Example
InterventionCode SHA intervention, upper-cased for you InterventionCode("sha-12-001") → SHA-12-001
Icd11Code ICD-11 / ICD-10 stem, upper-cased, validated "1A00", "BA00.1", "J18.9" ok; "malaria" → ValueError
SchemeCode, ProtocolCode as given by SHA "UHC", "EP-1"

Money

from sha_claim import Money

Money.kes("1500")  # KES 1,500.00
Money.kes("1500.505")  # → 1500.51 (half-up to cents)
Money.kes(1500) * 2  # KES 3,000.00
Money(1500.0)  # TypeError — floats are refused on purpose; pass str/int/Decimal

Otp("123456") (digits only, redacted in repr), BiometricGuid("…"), MatchId("…"). open_visit accepts any of the three as proof.

Practitioners

from sha_claim import PractitionerRef, RegulationBody, IdentificationType

PractitionerRef.registered("A1234", RegulationBody.KMPDC)  # by registration number (usual)
PractitionerRef("12345678", IdentificationType.NATIONAL_ID, RegulationBody.NCK)  # by national ID

RegulationBody: KMPDC (doctors), COC (clinical officers), NCK (nurses).

Attachments

from sha_claim import Attachment, DocumentType

Attachment.from_path("discharge.pdf", DocumentType.DISCHARGE_SUMMARY)  # reads the file, guesses content type
Attachment("inv.pdf", pdf_bytes, DocumentType.INVOICE, "application/pdf")

Empty content or > 10 MB → ValueError. DocumentType has 31 values (INVOICE, LAB_RESULTS, DISCHARGE_SUMMARY, PREAUTH_FORM, PRESCRIPTION, CT_SCAN, … see appendix).

Enums you will pass

Enum Values
ServiceType CAPITATION, OUTPATIENT, INPATIENT, EMERGENCY
IdentificationType NATIONAL_ID ("National ID"), ALIEN_ID, REFUGEE_ID, REGISTRATION_NUMBER (practitioners only)
CancelReason WRONG_PATIENT, NO_SERVICE_GIVEN, WRONG_BENEFIT, EXPIRED_VISIT, EXHAUSTED_BENEFIT, TIME_BARRED, OTHER_REASONS
DischargeReason RECOVERED, REFERRED, DECEASED, ABSCONDED, OTHER
NextOfKinIdType NATIONAL_ID, CLIENT_REGISTRY_ID, BIRTH_NOTIFICATION, BIRTH_CERTIFICATE, ALIEN_ID, REFUGEE_ID, MANDATE_NUMBER, TEMPORARY_ID
BroughtBy RELATIVE, UNKNOWN, SAMARITAN, PARAMEDICS
ModeOfArrival AMBULANCE, WALK_IN, OTHER
DoctorConsentRequestType PREAUTH_DOCTOR_APPROVAL, EMERGENCY_CLAIM_DOCTOR_APPROVAL, PRESCRIPTION

Enums you will read (lenient)

PaymentMechanism, AuthorizationStatus, EligibilityStatus, CoverageStatus come from the server. Their vocabularies aren't fully documented, so an unknown value does not crash: you get a member whose .is_known is False and whose value is the raw string. Compare with ==.

extra

Every result object has an extra dict holding server fields the SDK hasn't modelled yet. It's a read-only escape hatch — log it, don't build logic on it.


6. sha.eligibility

All reads. Safe to call any time; retried automatically on transient failures.

check(identification_number, identification_type) → Eligibility

Start here for every patient. Turns an ID document into the CR number you need everywhere else, and tells you whether they're covered.

Param Type Notes
identification_number str e.g. national ID number
identification_type IdentificationType NATIONAL_ID, ALIEN_ID, REFUGEE_ID (not REGISTRATION_NUMBER — that's for doctors)

Sends GET /patients/eligibility?identification_number=…&identification_type=National ID.

Returns Eligibility:

Field Type Meaning
patient_id PatientId | None the CR number — keep it
full_name str
status / status_description EligibilityStatus | None / str "10" = member found; UAT returns "00" "Member Not found!" for unknown IDs
schemes tuple[Scheme, …] e.g. UHC; each has coverage.status, policy_period
date_of_birth, gender, age, is_alive may be empty on UAT
whitelisted_for_otp, facility_biometrics_enforced bool
member_found (property) bool status is "member found" and a CR number came back
is_covered_on(day) bool member found and at least one scheme is active on that date
active_schemes_on(day) tuple[Scheme, …]
e = await sha.eligibility.check("00000000", IdentificationType.NATIONAL_ID)  # UAT synthetic member
if not e.is_covered_on(date.today()):
    raise Exception(f"not covered: {e.status_description}")
patient = e.patient_id

benefits(patient) → tuple[BenefitPackage, …]

Top-level packages the patient can use. GET /patients/benefits?patient_id=…. Each BenefitPackage: code (SHA-12), name (Outpatient Services).

sub_benefits(patient) → tuple[SubBenefit, …]

GET /patients/sub-benefits?patient_id=…. Each SubBenefit: code (SHA-12-SC-01), name, parent_code (SHA-12), access_point (OP, IP, OP and IP).

interventions(patient, sub_benefit_code) → tuple[InterventionCoverage, …]

The billable things under a sub-benefit, with tariffs and rules. GET /patients/benefits/interventions?patient_id=…&sub_benefit_code=….

Field Meaning
code InterventionCode — what you authorise and bill
name Consultation, Cesarean Section, …
payment_mechanism CAPITATION, CASE BASED, FEE FOR SERVICE (lenient)
needs_preauth must file a pre-auth (§9) before billing
needs_doctor_authorization needs a doctor's sign-off
access_point OP / IP
overall_tariff Money | None — 0 for capitation
applicable_schemes e.g. ("UHC",)
service_type_for_authorization (property) use this as service_type when authorising — see gotcha in §14
coverage = await sha.eligibility.interventions(patient, "SHA-12-SC-01")
consultation = next(c for c in coverage if c.name == "Consultation")
consultation.service_type_for_authorization  # ServiceType.CAPITATION on UAT

utilization(patient, intervention) → UtilizationBalance

Remaining limits for one intervention. GET /patients/benefits/utilization. Fields: individual_max, individual_utilised, household_max, household_utilised, available_amount, eligible, funds (per-fund limits), individual_remaining (property). On UAT this endpoint returned an upstream 400 in Sept 2026; expect BadRequestError.

pomsf_balances(patient, policy_year, *, principal_member_number=None) → dict

Public Officers Medical Scheme Fund (civil servants). Returned raw as a dict — the payload is large and NaCare has no POMSF members yet.

bed_occupancy(facility) → BedOccupancy

GET /facilities/{code}/beds/occupancy. Fields: facility_name, level, total_beds, total_inpatient_visits, normal_beds, icu_beds, hdu_beds, dialysis_beds, baby_cots, occupancy_rate (property, None if no beds). Needs auth on UAT despite the docs.

6.4 sha.registries — who is this person?

sha.eligibility answers what is this member entitled to; the Client Registry answers who are they and can we reach them. Worth calling before consent: SHA sends the visit OTP only to a confirmed, active phone contact, and a member with none cannot consent by OTP at all — so this turns a failed authorize into a question you asked first.

Method Sends Returns
find_patient(identification_number, identification_type=NATIONAL_ID) GET /patients PatientRecord | None — None, not an exception, when the registry holds nobody with that document
contacts(patient) GET /patients/contacts tuple[PatientContact, …] — empty means the OTP path is closed for this member
can_consent_by_otp(patient) as above bool — any confirmed, active phone on record

PatientRecord: patient_id (PatientId | None), first_name, middle_name, last_name, full_name (prop), gender, date_of_birth, identification_type, identification_number, phone, citizenship, county, sub_county, ward, other_identifications, dependant_ids.

PatientContact: contact_id, value, contact_type, is_confirmed, is_active, is_main, next_of_kin_full_name, can_receive_otp (prop).

record = await sha.registries.find_patient("12345678", IdentificationType.NATIONAL_ID)
if record is None:
    return "not in the Client Registry"  # not an error — just nobody with that document
if not await sha.registries.can_consent_by_otp(record.patient_id):
    return "no confirmed phone on record — this member cannot consent by OTP"

UAT answers a document it does not hold with 400 "zero results found in client registry" rather than a 404. find_patient folds that into None, so a registry outage still raises and is never mistaken for an unknown patient.


7. sha.consent

Which consent path? Two exist and they don't mix:

  • OTP (usual): send_otp → patient reads the code → claims.open_visit(..., Otp(code)). Do not call authorize first.
  • Biometrics (device): authorize → the patient verifies on the biometric device → open_visit(..., BiometricGuid(auth.guid)).

A PENDING authorization left behind by the biometric path blocks the OTP path. Use list + reject to clear it.

send_otp(patient, interventions) → str

POST /claims/otp {patient_id, intervention_codes}. Sends the visit OTP to the beneficiary's registered phone; returns the server message. (On UAT the message contains the OTP itself — sandbox only.)

list(patient) → tuple[Authorization, …]

GET /claims/authorizations?beneficiary_code=…. Every authorization the beneficiary has at your facility, newest first, with status ∈ PENDING | AUTHORIZED | SUBMITTED_CLAIM | CLOSED. Undocumented but live.

authorize(patient, service_type, interventions, otp=None) → Authorization

Biometric path. Creates a pending authorization to be verified on a biometric device. If the patient already has an open visit, UAT returns that AUTHORIZED record instead.

Param Type Notes
patient PatientId | str CR number
service_type ServiceType use InterventionCoverage.service_type_for_authorization
interventions list of InterventionCode | str at least one; duplicates removed
otp Otp | None leave None — the server generates and sends it

Sends POST /claims/authorize with {"patient_id", "service_type", "interventions": [...]} (+ "otp" if given).

Returns Authorization: guid, token, auth_code, status (PENDING), label (UNAUTHORIZED until verified), is_open, benefit_type, beneficiary, beneficiary_name, provider_fid, interventions (each with needs_preauth), expiry, is_pending (prop), needs_preauth (prop, any intervention), as_biometric_proof() → BiometricGuid(guid).

Raises RequestValidationError if interventions is empty; BadRequestError e.g. "intervention Consultation(SHA-12-001) is not supported for service type OUTPATIENT".

get(token, guid, patient=None) → Authorization | None

Re-read an authorization. GET /claims/authorizations?token=…&guid=…[&beneficiary_code=…]. None if the server has nothing.

reject(token) → None

Close a pending authorization you won't use. POST /claims/authorizations/{token}/reject.


8. sha.claims

open_visit(patient, service_type, interventions, proof) → ClaimSession

Step 2. Verifies the OTP and opens the server-side virtual claim.

Param Type Notes
patient PatientId | str
service_type ServiceType same as you used in authorize
interventions list of codes
proof Otp | BiometricGuid | MatchId the code the patient read out, or a verified biometric GUID

Sends POST /claims/visit with {"patient_id", "service_type", "intervention_codes": [...]} plus exactly one of "otp" / "auth_guid" / "match_id".

Returns a ClaimSession (§9). session.consent_token is the handle; session.claim is the VirtualClaim snapshot the server returned.

Typical failures: BadRequestError "OTP was not found for contact: +2547…" (no pending authorization / wrong code), "Kindly ensure the biometrics visit has been successfully verified".

resume(consent_token) → ClaimSession

Re-attach to a claim you opened earlier (e.g. token persisted in your DB). No network call. session.claim is None until you preview().

session = sha.claims.resume(saved_token)
claim = await session.preview()

9. ClaimSession

One object = one server-side claim. Every method sends the session's consent_token for you. Methods that return a VirtualClaim also refresh session.claim.

9.1 Interventions

Method Sends Returns
add_intervention(code) POST /claims/interventions {consent_token, intervention_code} ClaimIntervention — needs_preauth, preauth_exists, preauth_outstanding (prop), applicable_document_types, required_preauth_document_types, tariff
retire_intervention(code) POST /claims/interventions/retire None
restore_intervention(code) POST /claims/interventions/restore None
switch_intervention(existing, new, *, retain_bill_items=True, bill_from=None, bill_to=None) POST /claims/interventions/switch None

The bed rebate (PER DIEM). Critical care is not billed by the item. ICU, HDU, NICU and the burns unit (SHA-03-*) come back with payment_mechanism == PaymentMechanism.PER_DIEM: SHA pays a fixed amount for every day of the stay and accrues it itself. None of this is in the portal spec — the fields exist with empty descriptions — so read it off the intervention:

Field / property Type What it is
is_per_diem (prop) bool this intervention is paid by the day, not the item
accrued_per_diem_days int days of stay SHA has accrued so far
accrued_per_diem Money | None what those days have earned, as SHA computed it
keph_level_tariff Money | None the daily rate for your facility's KEPH level (SHA spells it kephLevelTarrif)
bill_from / bill_to datetime | None the window being accrued over
per_diem_allowance (prop) Money | None what SHA will pay for the stay so far

per_diem_allowance prefers SHA's own accrual, falls back to keph_level_tariff × accrued_per_diem_days, and returns None — never zero — when it knows neither. That matters: on UAT every per-diem intervention returns overall_tariff 0.00 because no rate is published for the facility's KEPH level, and a zero here would read as "SHA pays nothing" and push a covered stay onto the patient. Treat None as unknown, and say so on screen.

bed = next((i for i in claim.interventions if i.is_per_diem), None)
if bed:
    allowance = bed.per_diem_allowance  # Money | None
    if allowance is None:
        ui.warn(
            f"{bed.name}: {bed.accrued_per_diem_days} day(s) accrued, rate not published — confirm with SHA"
        )
    else:
        extras = claim.total_amount - allowance  # anything over the per diem is the patient's / an exclusion

9.2 Diagnoses

Method Sends Returns
add_diagnosis(icd, intervention) POST /claims/diagnoses {consent_token, icd_code, intervention_code} ClaimDiagnosis — code, name, intervention_code, record_id, is_flagged, recorded_on
remove_diagnosis(icd, intervention) PATCH /claims/diagnoses None

icd is validated locally (Icd11Code), so a typo fails before any network call.

9.3 Billing lines

add_line(intervention, unit_price, quantity=1, *, scheme_code=None, charge_date=None, diagnoses=(), service_name="", service_identifier="", practitioner=None, attachments=()) → ClaimLine

Param Type Notes
intervention code which intervention this line bills
unit_price Money required, Money not a number
quantity int | Decimal > 0
scheme_code SchemeCode | str | None e.g. "UHC"
charge_date date | None
diagnoses ICD codes linked to this line
service_name str label for the line (DHA "Add New Line")
service_identifier str your own charge/order reference, for reconciliation
practitioner PractitionerRef | None attending doctor — one per claim
attachments LineAttachment(document_title, Attachment) … files sent with the line ("Add Combined Billing Details")

Sends POST /claims/lines as multipart/form-data: consent_token, intervention_code, unit_price ("1500.00"), quantity, [scheme_code], [charge_date], [diagnoses = JSON array], [service_name], [service_identifier], [practitioner_identification_type/number, practitioner_regulation_body], [attachments = JSON array of {document_title, document_type, file_field_name}] plus one binary part per attachment named attachment_0, attachment_1, … (upload timeout applies when files are present).

Returns ClaimLine: guid (needed to remove/edit), intervention_code, item_code, item_name, quantity (Decimal), unit_price, total_amount, net_amount, copay, scheme_code, charge_date, is_active, doctor_name.

Plus, on lines read back from preview(), how SHA splits the line — computed by SHA, never sent by you:

Field Type What it is
rebate_amount Money | None what SHA rebates on this line (the wire still calls it nhifRebateAmount)
sponsor_net Money | None the sponsor's share
patient_net Money | None what is left to the patient
benefit_exceeded bool SHA flagged this line as taking the member past a UHC limit

All four are None/False on the line add_line returns and populated once SHA has priced the claim, so read them from preview(), not from the add call.

RequestValidationError if quantity ≤ 0 or price negative.

Method Sends Returns
remove_line(line_guid) PATCH /claims/lines {consent_token, line_guid} None
edit_line(line_guid, *, quantity=None, unit_price=None, scheme_code=None) PATCH /claims/lines/edit {line_id, …changed fields} — for after payer review ClaimLine
resubmit_lines() POST /claims/lines/resubmit {consent_token} — after edit_line LineResubmission — line, status, message, resubmitted_at

9.4 Attachments

Method Sends Returns
attach(attachment, intervention) POST /claims/attachments multipart: consent_token, document_type, intervention_code, file_blob (uses the upload timeout) ClaimAttachment — attachment_id, title, attachment_type, intervention_code
remove_attachment(attachment_id, intervention) PATCH /claims/attachments None
await session.attach(Attachment.from_path("invoice.pdf", DocumentType.INVOICE), "SHA-12-001")

9.5 Lifecycle

preview() → VirtualClaim

POST /claims/preview {consent_token}. The claim as the server sees it. Safe to call any time (it's retried like a read). Use it to check totals before submitting, and to resolve an ambiguous submit.

VirtualClaim.submission_blockers() → tuple[Blocker, …]

Pure check over the snapshot preview() returned — nothing is stored or fetched. Each Blocker has code, message, intervention. Codes: NO_BILLING_LINES, ZERO_TOTAL, NEGATIVE_TOTAL, PREAUTH_OUTSTANDING, NO_DIAGNOSIS, MISSING_DOCUMENTS, NO_ACTIVE_INTERVENTIONS. It is conservative (it only interprets what the server said), so an empty tuple means no known blocker, not a guarantee. Use it to disable the Submit button and tell the biller why.

claim = await session.preview()
if blockers := claim.submission_blockers():
    for b in blockers:
        print(b)  # PREAUTH_OUTSTANDING [SHA-08-006]: intervention needs a pre-authorisation…
else:
    await session.submit("INV-1")

add_doctor(practitioner) → str

POST /claims/doctors. Attaches the attending practitioner; the registration is checked against the Health Worker Registry. Required before submit (UAT: "there is no doctor attached to the claim").

submit(invoice_number=None, *, discharge_reason=None, otp=None, reason_for_unknown_patient=None) → VirtualClaim

POST /claims/submit {consent_token, invoice_number, discharge_reason, otp, [reason_for_unknown_patient]}. Final. No changes after this.

What UAT actually requires (none of it documented): a discharge_reason, the OTP from send_discharge_otp() — yes, for outpatient/CAPITATION too — and a doctor on the claim. The server-assigned invoice_number is on the preview (preview.invoice_number). So the real sequence is:

await session.add_doctor(PractitionerRef.registered("A1234", RegulationBody.KMPDC))
preview = await session.preview()
await session.send_discharge_otp(patient)  # OTP to the patient / next of kin
claim = await session.submit(preview.invoice_number, discharge_reason=DischargeReason.RECOVERED, otp=code)

The SDK sends submit exactly once. If the network drops or the server times out after the request left, you get SubmissionOutcomeUnknownError — the SDK does not guess. Do this:

try:
    claim = await session.submit("INV-2026-000123")
except SubmissionOutcomeUnknownError as e:
    log.warning("submit outcome unknown, trace %s", e.trace_id)
    claim = await session.preview()  # the server knows; ask it
    if claim.workflow_state not in SUBMITTED_STATES:  # your own mapping once you've seen real values
        claim = await session.submit("INV-2026-000123")

close(reason, text) → VirtualClaim

POST /claims/close {consent_token, cancel_reason_type, cancel_reason_text}. Abandon a claim that will never be submitted. reason is a CancelReason; text must be non-empty.

payer_status(provider_claim_no) → tuple[PayerClaimRecord, …]

GET /claims/preview/payer?guid=…&provider_claim_no=… — how the payer's system sees the submitted claim. Needs the claim GUID; if the session has no snapshot yet it calls preview() first. Each PayerClaimRecord: guid, provider_claim_no, tracking_number, workflow_state, workflow_display_name, status (prop), claim_type, is_inpatient, proposed_value, proposed_value_less_copays, total_copay, member_name, scheme_name, created.

9.6 Inpatient: discharge and next of kin

Every claim needs a discharge OTP and reason before submit. Inpatient claims additionally call discharge; outpatient / CAPITATION pass discharge_reason + otp straight to submit (UAT rejects /claims/discharge for them).

Method Sends Returns
add_next_of_kin(*, full_name, id_number, id_type, contact_value) POST /patients/next-of-kin/contacts — register who receives OTPs when the patient can't NextOfKinContact — guid, is_verified, is_confirmed, is_main_contact
send_discharge_otp(patient) POST /claims/otp/discharge {consent_token, patient_id} str message
discharge(*, reason, invoice_number, otp, discharged_at=None) POST /claims/discharge — INPATIENT only (UAT rejects it for other service types; outpatient/CAPITATION pass the discharge fields to submit instead). discharged_at defaults to now, must be tz-aware VirtualClaim

id_type is a NextOfKinIdType; reason a DischargeReason; otp may be Otp or str.

9.7 Pre-authorisation

Needed when an intervention has needs_preauth (check InterventionCoverage or ClaimIntervention.preauth_outstanding).

request_preauth(intervention, *, service_start, service_end, items, diagnoses, doctors, notification_email, attachments=()) → Preauthorization

Param Type Notes
intervention code
service_start, service_end datetime (tz-aware) end ≥ start
items list[PreauthItem] ≥ 1; PreauthItem(code, description, quantity, unit_price: Money)
diagnoses ICD codes ≥ 1
doctors list[PractitionerRef]
notification_email str valid email; DHA sends updates here
attachments list[Attachment] e.g. PREAUTH_FORM

Sends POST /preauths as multipart; items/diagnoses/doctors/attachments are JSON strings in form fields and attachment bytes are extra file parts. ⚠ The portal does not publish the inner schema of those four arrays; the SDK's encoding follows the API's own vocabulary and is unverified on UAT (see docs/api/WORKFLOWS.md Q3). If DHA rejects it, the fix is in one function.

Returns Preauthorization: guid, token, status, doctor_review_status, needs_doctor_approval, doctor_approved, doctors_required, is_request_phase, is_response_phase, total_estimated, interim_approved, final_approved, service_start/end, countdown, awaiting_doctor (prop), decided (prop).

Method Sends Returns
preauths() GET /preauths?consent_token=… tuple[Preauthorization, …]
remove_preauth_diagnosis(icd, intervention) DELETE /preauths/diagnoses/{icd} Preauthorization
remove_preauth_doctor(intervention, registration_number) DELETE /preauths/doctors — only before submission None
cancel_preauth(intervention) POST /preauths/cancel Preauthorization
request_doctor_consent(intervention, practitioner, *, request_type=PREAUTH_DOCTOR_APPROVAL, service_type=None, emergency_claim_id=None) POST /claims/doctor-consent str message

9.8 ePrescriptions

prescribe(intervention, items, *, prescriber=None) → Prescription

items is a list of MedicationOrder:

Field Type Notes
generic_concept_code str the medication's code
dose_quantity int | Decimal > 0
dose_unit str TABLET, ML, …
frequency int times per period_unit, > 0
period_unit str DAY, …
duration int > 0
duration_unit str DAY, WEEK, …
start_date date
end_date date | None ≥ start
patient_instruction, additional_instruction str optional
needs_refill, refill_count bool, int refill_count > 0 when needs_refill

Sends POST /prescriptions JSON {consent_token, intervention_code, items: [...], [identification_number, identification_type, regulation_body]}. Field names match the portal's published example exactly.

Returns Prescription: guid, code, status, doctor_review_status, intervention_code, dosage (tuple of Dosage: medication, dose_quantity, dose_unit, frequency, period_unit, duration, route, start_date, end_date, price, status).

Method Sends Returns
prescription() GET /prescriptions?consent_token=… Prescription | None
dispense(intervention, products, dispensers) POST /prescriptions/dispense (singular — the portal documents /dispenses, which 404s) {consent_token, intervention_code, actual_products: [{actual_product_code, total_quantity, medication_price}], doctors: [{identification_number, identification_type}]} Dispense — record_id, status, dosages
remove_prescription_doctor(intervention, registration_number) DELETE /prescriptions/doctors None

products is a list of DispensedProduct(product_code, quantity, price: Money); dispensers a list of PractitionerRef. Both must be non-empty.

⚠ prescribe() cannot succeed on UAT today. patient_instruction is validated against an enumeration DHA has never published: 36 candidates have been rejected, including every member of its own PRESCRIPTION-CONDITION-KENYA value set, all 40 administrative routes, free text and integer choices. The live portal page carries enums for 14 other fields and none for this one. dose_unit was solved the same way and accepts 12 lowercase values (tablet, ml, …) that appear in no published registry. GET /prescriptions answers 403 for this client. dispense() works — but only once a prescription exists. The full elimination table is in docs/api/WORKFLOWS.md §6.1; it is DHA's to answer, don't re-guess it.

One related trap: generic_concept_code is not validated — UAT accepts a PH… product code where a GE… generic belongs. A wrong code fails silently rather than loudly, so resolve the generic yourself (DHA's terminology service links them by a has-generic mapping).

9.9 Emergency (on a session opened via sha.emergency.open_case)

Method Sends Returns
add_protocol(protocol, intervention, unit_price, quantity=1, *, diagnoses=()) POST /claims/emergency/protocols multipart (diagnoses comma-separated here) ClaimLine
add_emergency_doctor(doctor) POST /claims/doctors {consent_token, identification_number, identification_type, regulation_body} str message
remove_emergency_doctor() DELETE /claims/doctors {consent_token} None
open_emt_claim(EmtClaim(...)) POST /claims/emt multipart — the ambulance provider's claim VirtualClaim

EmtClaim(protocol_code, case_number, practitioner_registration_number, provider_registration_number, beneficiary, otp, diagnoses, interventions, attachments=()).


10. sha.emergency

open_case(attending, reference_number, brought_by, mode_of_arrival, interventions, *, patient=None, otp=None, notes="") → ClaimSession

Opens an emergency claim without prior OTP consent. patient=None for an unidentified casualty.

Param Type
attending PractitionerRef (identification + regulation body)
reference_number your ER reference
brought_by BroughtBy
mode_of_arrival ModeOfArrival
interventions codes, ≥ 1
patient PatientId | str | None
otp Otp | None (if the patient can consent)

Sends POST /claims/emergency. Returns a ClaimSession — use add_protocol, add_emergency_doctor, then the normal preview / submit.

protocols(intervention, *, active=True) → tuple[EmergencyProtocol, …]

GET /claims/emergency/protocols?intervention_code=…&active=true. Each EmergencyProtocol: code (ProtocolCode), name, protocol_type, classification, status, tariff (Money | None).


11. sha.files

Claim documents normally go through session.attach(...). These are for standalone storage.

Method Sends Returns
upload(filename, content: bytes, content_type="application/octet-stream") POST /uploads multipart file StoredFile — file_id, path (the documented response is {}; fields appear if the server sends them)
download_link(file_id) GET /uploads/{file_id} DownloadLink — url, message

12. End-to-end recipes

set_coverage(principal, policy_number) → None

POST /authorizations/covers — POMSF schemes only (the DHA process pages; not on the eclaims portal). Chooses which member's policy pays for this visit: principal is the CR number of the member whose cover pays (not necessarily the patient), policy_number is Scheme.policy_number from eligibility — never composed locally. Call before adding lines or pre-auths. RequestValidationError on a blank policy number.

12.1 Outpatient consultation (the common case)

import asyncio
from datetime import date
from sha_claim import (
    AsyncSHAClient,
    Attachment,
    DocumentType,
    IdentificationType,
    Money,
    Otp,
    SubmissionOutcomeUnknownError,
)


async def outpatient(id_number: str, read_otp_from_patient) -> str:
    async with AsyncSHAClient.from_env() as sha:
        # 1. Who is this, and are they covered?
        e = await sha.eligibility.check(id_number, IdentificationType.NATIONAL_ID)
        if not e.is_covered_on(date.today()):
            raise RuntimeError(f"Not covered: {e.status_description}")
        patient = e.patient_id

        # 2. What can we bill? Pick the intervention and let it tell us the service type.
        coverage = await sha.eligibility.interventions(patient, "SHA-12-SC-01")
        consultation = next(c for c in coverage if c.name == "Consultation")
        service_type = consultation.service_type_for_authorization

        # 3. Consent: this sends the SMS.
        await sha.consent.authorize(patient, service_type, [consultation.code])
        otp = Otp(read_otp_from_patient())  # your UI asks the patient for the code

        # 4. Open the claim.
        session = await sha.claims.open_visit(patient, service_type, [consultation.code], otp)
        token_to_persist = session.consent_token.value  # save this if the visit spans requests

        # 5. Build it.
        await session.add_diagnosis("1A00", consultation.code)
        await session.add_line(consultation.code, Money.kes("1500"), quantity=1, diagnoses=["1A00"])
        await session.attach(Attachment.from_path("invoice.pdf", DocumentType.INVOICE), consultation.code)

        # 6. Check, then submit once.
        preview = await session.preview()
        print("server total:", preview.total_amount)
        try:
            claim = await session.submit("INV-2026-000123")
        except SubmissionOutcomeUnknownError:
            claim = await session.preview()
        return claim.consent_token.value


asyncio.run(outpatient("00000000", lambda: input("OTP: ")))

12.2 Inpatient with discharge

session = await sha.claims.open_visit(patient, ServiceType.INPATIENT, ["SHA-08-005"], otp)
await session.add_diagnosis("JB0Z", "SHA-08-005")
await session.add_line("SHA-08-005", Money.kes("10000"))
# patient can't consent to discharge herself → register next of kin first
await session.add_next_of_kin(
    full_name="Jane Doe", id_number="12345678", id_type=NextOfKinIdType.NATIONAL_ID, contact_value="+2547…"
)
await session.send_discharge_otp(patient)
await session.discharge(
    reason=DischargeReason.RECOVERED,
    invoice_number="INV-1",
    otp=read_otp(),  # discharged_at defaults to now
)
await session.submit("INV-1")

12.2a Critical care, where SHA pays by the day

session = await sha.claims.open_visit(patient, ServiceType.INPATIENT, ["SHA-03-001"], otp)  # ICU CARE
await session.add_diagnosis("JB0Z", "SHA-03-001")
await session.add_line("SHA-03-001", Money.kes("25000"))  # your own bed charge, as usual

claim = await session.preview()
bed = next(i for i in claim.interventions if i.is_per_diem)
allowance = bed.per_diem_allowance  # Money | None — what SHA covers for the stay so far

# anything the bill carries above the allowance is the patient's, or an exclusion on your invoice
if allowance is not None and claim.total_amount and claim.total_amount > allowance:
    excess = claim.total_amount - allowance

allowance is None means SHA published neither an accrual nor a KEPH-level rate — unknown, not nothing. §9.1.

12.3 Something that needs pre-authorisation

session = await sha.claims.open_visit(patient, ServiceType.INPATIENT, ["SHA-08-006"], otp)  # Cesarean
pa = await session.request_preauth(
    "SHA-08-006",
    service_start=start,
    service_end=end,
    items=[PreauthItem("CS", "Cesarean section", 1, Money.kes("30000"))],
    diagnoses=["JB0Z"],
    doctors=[PractitionerRef.registered("A1234", RegulationBody.KMPDC)],
    notification_email="claims@yourfacility.example",
    attachments=[Attachment.from_path("preauth.pdf", DocumentType.PREAUTH_FORM)],
)
if pa.awaiting_doctor:
    await session.request_doctor_consent(
        "SHA-08-006", PractitionerRef.registered("A1234", RegulationBody.KMPDC)
    )
# poll later:
for p in await session.preauths():
    print(p.status, p.final_approved)

12.4 Emergency

doctor = PractitionerRef.registered("A1234", RegulationBody.KMPDC)
protocols = await sha.emergency.protocols("SHA-19-001")
session = await sha.emergency.open_case(
    doctor,
    "ER-0917",
    BroughtBy.PARAMEDICS,
    ModeOfArrival.AMBULANCE,
    ["SHA-19-001"],
    patient=None,
    notes="RTA, unconscious on arrival",  # notes are mandatory on SHA's side
)  # unidentified casualty — interventions must be emergency-fund ones (`InterventionCoverage.is_emergency`)
await session.add_emergency_doctor(doctor)
await session.add_protocol(protocols[0].code, "SHA-19-001", protocols[0].tariff, diagnoses=["NF0A"])
await session.submit(reason_for_unknown_patient="unconscious, no ID")

12.5 Resume a claim tomorrow

session = sha.claims.resume(token_from_your_db)
claim = await session.preview()
status = await session.payer_status(claim.invoice_number.value)

13. Testing code that uses the SDK

You have three levels, cheapest first.

Fake the SDK's port. ClaimSession and the resources depend on Protocols; hand them fakes. See tests/unit/test_session_and_submit.py for complete fakes you can copy.

Fake the HTTP layer. Give the real client a mocked httpx server with respx:

import httpx, respx
from sha_claim import AsyncSHAClient, SHASettings

settings = SHASettings(client_id="x", client_secret="y", base_url="https://uat.example/uat-middleware")


@respx.mock
async def test_my_code():
    respx.post(f"{settings.api_root}/tenants/token").mock(
        return_value=httpx.Response(200, json={"access_token": "T", "expires_in": 3600})
    )
    respx.get(f"{settings.api_root}/patients/eligibility").mock(return_value=httpx.Response(200, json={...}))
    async with AsyncSHAClient(settings) as sha:
        ...

Realistic payloads for every endpoint are in docs/api/spec/examples.json (the portal's samples) and tests/fixtures/ (recorded from UAT, sanitised).

Hit UAT. Set RUN_LIVE=1 and real credentials in .env; see tests/live/.


14. Gotchas

  1. CAPITATION interventions must be authorised as ServiceType.CAPITATION, not OUTPATIENT — the server rejects the latter even though the docs don't list CAPITATION for that endpoint. Use InterventionCoverage.service_type_for_authorization and you never think about it.
  2. authorize without an OTP is what sends the SMS. Don't ask the patient for a code before you've called it. open_visit with an OTP but no pending authorization fails with "OTP was not found".
  3. submit is attempted once. On SubmissionOutcomeUnknownError, preview() — never blindly retry.
  4. Persist consent_token.value, not the object's str() — str() is redacted on purpose.
  5. Money refuses floats. Money.kes("1500.50") or Money.kes(1500).
  6. Diagnosis codes are validated locally. "malaria" raises before any request.
  7. Server status strings (workflow_state, claim_auth_status, preauth status) are raw str — DHA hasn't documented the vocabulary. Log what you see; the SDK will promote them to enums as values are confirmed.
  8. Lenient enums: PaymentMechanism("SOMETHING NEW") doesn't raise; check .is_known if you care.
  9. Every result has .extra with unmodelled server fields. Read-only.
  10. Reads retry, writes don't. If you wrap a write in your own retry, you own the duplicate.
  11. The facility comes from your credentials, or from the scope you set. No method takes a facility argument: either the token carries it, or you wrap the call in facility_scope(...) / set SHA_FACILITY_ID. The two headers always travel together, never one alone.
  12. per_diem_allowance is None, not zero, when SHA hasn't published a rate. Don't coerce it — a zero on an ICU stay bills the patient for the bed. §9.1.
  13. ePrescriptions are blocked on UAT by patient_instruction. 36 candidate values rejected, including every member of DHA's own published value set; no enumeration exists in any published source. dispense works once a prescription exists. WORKFLOWS.md §6.1 has the elimination table — don't re-guess it.
  14. Pre-auth nested arrays are unverified on UAT (Q3). Everything else in this guide is either live-verified or asserted against the portal's published examples.

15. Appendix — result objects

Generated from the code. All are frozen dataclasses: read fields, don't mutate. extra holds unmodelled server fields.

15.1 Objects the SDK returns

Eligibility

Eligibility(patient_id: 'PatientId | None', full_name: 'str', status: 'EligibilityStatus | None', status_description: 'str', schemes: 'tuple[Scheme, ...]', date_of_birth: 'date | None' = None, gender: 'str' = '', age: 'int | None' = None, is_alive: 'bool | None' = None, whitelisted_for_otp: 'bool' = False, facility_biometrics_enforced: 'bool' = False, extra: 'Mapping[str, Any]' = )

Field Type Required
patient_id `PatientId None`
full_name str yes
status `EligibilityStatus None`
status_description str yes
schemes tuple[Scheme, ...] yes
date_of_birth `date None`
gender str no
age `int None`
is_alive `bool None`
whitelisted_for_otp bool no
facility_biometrics_enforced bool no
extra dict no

Properties: member_found

Scheme

Scheme(name: 'str', scheme_id: 'int | None', member_type: 'str', policy_number: 'str', policy_period: 'DateRange', coverage: 'Coverage')

Field Type Required
name str yes
scheme_id `int None`
member_type str yes
policy_number str yes
policy_period DateRange yes
coverage Coverage yes

Coverage

Coverage(status: 'CoverageStatus | None', message: 'str', reason: 'str', period: 'DateRange')

Field Type Required
status `CoverageStatus None`
message str yes
reason str yes
period DateRange yes

DateRange

DateRange(start: 'date | None', end: 'date | None')

Field Type Required
start `date None`
end `date None`

BenefitPackage

BenefitPackage(code: 'str', name: 'str')

Field Type Required
code str yes
name str yes

SubBenefit

SubBenefit(code: 'str', name: 'str', parent_code: 'str', access_point: 'str')

Field Type Required
code str yes
name str yes
parent_code str yes
access_point str yes

InterventionCoverage

InterventionCoverage(code: 'InterventionCode', name: 'str', payment_mechanism: 'PaymentMechanism | None', needs_preauth: 'bool', needs_doctor_authorization: 'bool', access_point: 'str', overall_tariff: 'Money | None', applicable_schemes: 'tuple[str, ...]', fund: 'str' = '', sub_benefit_code: 'str' = '', extra: 'Mapping[str, Any]' = )

Field Type Required
code InterventionCode yes
name str yes
payment_mechanism `PaymentMechanism None`
needs_preauth bool yes
needs_doctor_authorization bool yes
access_point str yes
overall_tariff `Money None`
applicable_schemes tuple[str, ...] yes
fund str no
sub_benefit_code str no
extra dict no

Properties: service_type_for_authorization

UtilizationBalance

GET /patients/benefits/utilization — how much of a benefit the member (and household) has left.

Field Type Required
intervention_code `InterventionCode None`
patient_id str yes
limit_scope str yes
individual_max `Money None`
individual_utilised `Money None`
household_max `Money None`
household_utilised `Money None`
available_amount `Money None`
eligible `bool None`
next_availability str no
funds tuple[FundLimit, ...] no
extra dict no

Properties: individual_remaining

FundLimit

FundLimit(fund_type: 'str', max_amount: 'Money | None', utilised_amount: 'Money | None', available_amount: 'Money | None')

Field Type Required
fund_type str yes
max_amount `Money None`
utilised_amount `Money None`
available_amount `Money None`

BedOccupancy

BedOccupancy(facility_name: 'str', level: 'str', total_beds: 'int', total_inpatient_visits: 'int', normal_beds: 'int' = 0, icu_beds: 'int' = 0, hdu_beds: 'int' = 0, dialysis_beds: 'int' = 0, baby_cots: 'int' = 0, extra: 'Mapping[str, Any]' = )

Field Type Required
facility_name str yes
level str yes
total_beds int yes
total_inpatient_visits int yes
normal_beds int no
icu_beds int no
hdu_beds int no
dialysis_beds int no
baby_cots int no
extra dict no

Properties: occupancy_rate

Authorization

Snapshot of POST /claims/authorize / GET /claims/authorizations.

Field Type Required
guid str yes
token str yes
auth_code str yes
status `AuthorizationStatus None`
label str yes
is_open bool yes
benefit_type str yes
beneficiary `PatientId None`
beneficiary_name str yes
provider_fid str yes
interventions tuple[AuthorizedIntervention, ...] yes
expiry `datetime None`
overall_preauth_finalised bool no
record_id `int None`
extra dict no

Properties: is_pending, needs_preauth

AuthorizedIntervention

AuthorizedIntervention(code: 'InterventionCode', name: 'str', needs_preauth: 'bool', payment_mechanism: 'PaymentMechanism | None', sub_benefit_code: 'str' = '')

Field Type Required
code InterventionCode yes
name str yes
needs_preauth bool yes
payment_mechanism `PaymentMechanism None`
sub_benefit_code str no

VirtualClaim

VirtualClaim(consent_token: 'ConsentToken', guid: 'ClaimGuid | None', claim_id: 'int | None', workflow_state: 'str', claim_auth_status: 'str', service_type: 'ServiceType | None', patient_name: 'str', member_number: 'str', payer_name: 'str', scheme_name: 'str', currency: 'str', total_amount: 'Money | None', net_amount: 'Money | None', total_copay: 'Money | None' = None, total_discount: 'Money | None' = None, invoice_number: 'InvoiceNumber | None' = None, visit_number: 'str' = '', visit_start: 'datetime | None' = None, visit_end: 'datetime | None' = None, interventions: 'tuple[ClaimIntervention, ...]' = (), diagnoses: 'tuple[ClaimDiagnosis, ...]' = (), attachments: 'tuple[ClaimAttachment, ...]' = (), invoices: 'tuple[Invoice, ...]' = (), is_negative: 'bool' = False, is_zero: 'bool' = False, extra: 'Mapping[str, Any]' = )

Field Type Required
consent_token ConsentToken yes
guid `ClaimGuid None`
claim_id `int None`
workflow_state str yes
claim_auth_status str yes
service_type `ServiceType None`
patient_name str yes
member_number str yes
payer_name str yes
scheme_name str yes
currency str yes
total_amount `Money None`
net_amount `Money None`
total_copay `Money None`
total_discount `Money None`
invoice_number `InvoiceNumber None`
visit_number str no
visit_start `datetime None`
visit_end `datetime None`
interventions tuple[ClaimIntervention, ...] no
diagnoses tuple[ClaimDiagnosis, ...] no
attachments tuple[ClaimAttachment, ...] no
invoices tuple[Invoice, ...] no
is_negative bool no
is_zero bool no
extra dict no

Properties: preauth_outstanding, lines

ClaimIntervention

ClaimIntervention(code: 'InterventionCode', name: 'str', payment_mechanism: 'PaymentMechanism | None', needs_preauth: 'bool', preauth_exists: 'bool', workflow_state: 'str', sub_benefit_code: 'str' = '', fund: 'str' = '', overall_tariff: 'Money | None' = None, applicable_document_types: 'tuple[str, ...]' = (), required_preauth_document_types: 'tuple[str, ...]' = (), bill_from: 'datetime | None' = None, bill_to: 'datetime | None' = None, accrued_per_diem_days: 'int' = 0, accrued_per_diem: 'Money | None' = None, keph_level_tariff: 'Money | None' = None, extra: 'Mapping[str, Any]' = )

Field Type Required
code InterventionCode yes
name str yes
payment_mechanism `PaymentMechanism None`
needs_preauth bool yes
preauth_exists bool yes
workflow_state str yes
sub_benefit_code str no
fund str no
overall_tariff `Money None`
applicable_document_types tuple[str, ...] no
required_preauth_document_types tuple[str, ...] no
bill_from `datetime None`
bill_to `datetime None`
accrued_per_diem_days int no
accrued_per_diem `Money None`
keph_level_tariff `Money None`
extra dict no

Properties: preauth_outstanding, is_per_diem, per_diem_allowance

ClaimDiagnosis

ClaimDiagnosis(code: 'Icd11Code | None', name: 'str', intervention_code: 'InterventionCode | None', record_id: 'int | None' = None, is_flagged: 'bool' = False, recorded_on: 'datetime | None' = None, extra: 'Mapping[str, Any]' = )

Field Type Required
code `Icd11Code None`
name str yes
intervention_code `InterventionCode None`
record_id `int None`
is_flagged bool no
recorded_on `datetime None`
extra dict no

ClaimLine

ClaimLine(guid: 'LineGuid | None', intervention_code: 'InterventionCode | None', item_code: 'str', item_name: 'str', quantity: 'Decimal', unit_price: 'Money | None', total_amount: 'Money | None', net_amount: 'Money | None', copay: 'Money | None' = None, scheme_code: 'str' = '', charge_date: 'date | None' = None, is_active: 'bool' = True, doctor_name: 'str' = '', rebate_amount: 'Money | None' = None, sponsor_net: 'Money | None' = None, patient_net: 'Money | None' = None, benefit_exceeded: 'bool' = False, extra: 'Mapping[str, Any]' = )

Field Type Required
guid `LineGuid None`
intervention_code `InterventionCode None`
item_code str yes
item_name str yes
quantity Decimal yes
unit_price `Money None`
total_amount `Money None`
net_amount `Money None`
copay `Money None`
scheme_code str no
charge_date `date None`
is_active bool no
doctor_name str no
rebate_amount `Money None`
sponsor_net `Money None`
patient_net `Money None`
benefit_exceeded bool no
extra dict no

Invoice

An invoice inside a virtual claim (lines are grouped by invoice on the server side).

Field Type Required
invoice_id str yes
invoice_number `InvoiceNumber None`
invoice_type str yes
workflow_state str yes
dispatch_status str yes
total_amount `Money None`
net_amount `Money None`
copay `Money None`
discount `Money None`
lines tuple[ClaimLine, ...] no
invoice_date `date None`
extra dict no

ClaimAttachment

ClaimAttachment(attachment_id: 'AttachmentId | None', title: 'str', attachment_type: 'str', intervention_code: 'InterventionCode | None', description: 'str' = '', extra: 'Mapping[str, Any]' = )

Field Type Required
attachment_id `AttachmentId None`
title str yes
attachment_type str yes
intervention_code `InterventionCode None`
description str no
extra dict no

PayerClaimRecord

A row from GET /claims/preview/payer — the claim as the payer's system sees it (camelCase on the wire).

Field Type Required
guid str yes
provider_claim_no str yes
tracking_number str yes
workflow_state str yes
workflow_display_name str yes
claim_type str yes
is_inpatient bool yes
proposed_value `Money None`
proposed_value_less_copays `Money None`
total_copay `Money None`
member_name str no
member_number str no
scheme_name str no
created `datetime None`
extra dict no

Properties: status

NextOfKinContact

NextOfKinContact(guid: 'str', full_name: 'str', id_number: 'str', contact_value: 'str', contact_type: 'str', is_verified: 'bool', is_confirmed: 'bool', is_main_contact: 'bool', owner_type: 'str' = '', extra: 'Mapping[str, Any]' = )

Field Type Required
guid str yes
full_name str yes
id_number str yes
contact_value str yes
contact_type str yes
is_verified bool yes
is_confirmed bool yes
is_main_contact bool yes
owner_type str no
extra dict no

LineResubmission

LineResubmission(line: 'LineGuid | None', status: 'str', message: 'str', resubmitted_at: 'datetime | None' = None)

Field Type Required
line `LineGuid None`
status str yes
message str yes
resubmitted_at `datetime None`

Preauthorization

Snapshot of a pre-authorisation record (camelCase on the wire).

Field Type Required
guid str yes
token str yes
intervention_code `InterventionCode None`
status str yes
doctor_review_status str yes
needs_doctor_approval bool yes
doctor_approved bool yes
doctors_required int yes
is_request_phase bool yes
is_response_phase bool yes
total_estimated `Money None`
interim_approved `Money None`
final_approved `Money None`
service_start `datetime None`
service_end `datetime None`
provider_notification_email str no
member_name str no
description str no
countdown `int None`
record_id `int None`
extra dict no

Properties: awaiting_doctor, decided

Prescription

Prescription(guid: 'str', code: 'str', status: 'str', doctor_review_status: 'str', intervention_code: 'InterventionCode | None', dosage: 'tuple[Dosage, ...]', record_id: 'int | None' = None, extra: 'Mapping[str, Any]' = )

Field Type Required
guid str yes
code str yes
status str yes
doctor_review_status str yes
intervention_code `InterventionCode None`
dosage tuple[Dosage, ...] yes
record_id `int None`
extra dict no

Dosage

A dosage line as the server stores it.

Field Type Required
medication str yes
medication_identifier str yes
dose_quantity `Decimal None`
dose_unit str yes
frequency `int None`
period_unit str yes
duration str yes
duration_unit str yes
route str no
start_date `date None`
end_date `date None`
price `Money None`
status str no
extra dict no

Dispense

Dispense(record_id: 'int | None', status: 'str', dosages: 'tuple[Dosage, ...]', extra: 'Mapping[str, Any]' = )

Field Type Required
record_id `int None`
status str yes
dosages tuple[Dosage, ...] yes
extra dict no

EmergencyProtocol

EmergencyProtocol(code: 'ProtocolCode', name: 'str', protocol_type: 'str', classification: 'str', status: 'str', tariff: 'Money | None', guid: 'str' = '', extra: 'Mapping[str, Any]' = )

Field Type Required
code ProtocolCode yes
name str yes
protocol_type str yes
classification str yes
status str yes
tariff `Money None`
guid str no
extra dict no

StoredFile

Result of POST /uploads. The documented response is {}; the id/path surface when the server sends them.

Field Type Required
file_id `FileId None`
path str no
extra dict no

DownloadLink(url: 'str', message: 'str' = '', extra: 'Mapping[str, Any]' = )

Field Type Required
url str yes
message str no
extra dict no

15.2 Objects you construct (commands)

Required = yes means you must pass it. Each validates in its constructor and raises ValueError; when passed through a ClaimSession method the same problem surfaces as RequestValidationError.

NewClaimLine

Command for POST /claims/lines.

Field Type Required
intervention_code InterventionCode yes
unit_price Money yes
quantity `Decimal int`
scheme_code `SchemeCode None`
charge_date `date None`
diagnoses tuple[Icd11Code, ...] no

Properties: total

LineEdit

Command for PATCH /claims/lines/edit (after payer review).

Field Type Required
line LineGuid yes
quantity `int None`
unit_price `Money None`
scheme_code `SchemeCode None`

Discharge

Command for POST /claims/discharge (inpatient). The OTP comes from send_discharge_otp.

Field Type Required
discharge_date date yes
reason DischargeReason yes
invoice_number InvoiceNumber yes
otp Otp yes

NextOfKin

Command for POST /patients/next-of-kin/contacts — who consents when the beneficiary cannot.

Field Type Required
full_name str yes
id_number str yes
id_type NextOfKinIdType yes
contact_value str yes

PreauthRequest

Command for POST /preauths. Validates what the server would certainly reject.

Field Type Required
intervention_code InterventionCode yes
service_start datetime yes
service_end datetime yes
items tuple[PreauthItem, ...] yes
diagnoses tuple[Icd11Code, ...] yes
doctors tuple[PractitionerRef, ...] yes
provider_notification_email str yes
attachments tuple[Attachment, ...] no

Properties: estimated_total

PreauthItem

A billed item on the pre-auth request.

Field Type Required
code str yes
description str yes
quantity `Decimal int`
unit_price Money yes

Properties: total

DoctorConsentRequest

Command for POST /claims/doctor-consent.

Field Type Required
intervention_code InterventionCode yes
request_type DoctorConsentRequestType yes
practitioner PractitionerRef yes
service_type `ServiceType None`
emergency_claim_id `str None`

MedicationOrder

One prescribed medication (an items[] entry of POST /prescriptions).

Field Type Required
generic_concept_code str yes
dose_quantity `Decimal int`
dose_unit str yes
frequency int yes
period_unit str yes
duration int yes
duration_unit str yes
start_date date yes
end_date `date None`
patient_instruction str no
additional_instruction str no
needs_refill bool no
refill_count int no

PrescriptionRequest

PrescriptionRequest(intervention_code: 'InterventionCode', items: 'tuple[MedicationOrder, ...]', prescriber: 'PractitionerRef | None' = None)

Field Type Required
intervention_code InterventionCode yes
items tuple[MedicationOrder, ...] yes
prescriber `PractitionerRef None`

DispensedProduct

An actual_products[] entry of POST /prescriptions/dispenses.

Field Type Required
product_code str yes
quantity `Decimal int`
price Money yes

DispenseRequest

DispenseRequest(intervention_code: 'InterventionCode', products: 'tuple[DispensedProduct, ...]', dispensers: 'tuple[PractitionerRef, ...]')

Field Type Required
intervention_code InterventionCode yes
products tuple[DispensedProduct, ...] yes
dispensers tuple[PractitionerRef, ...] yes

EmergencyCase

Command for POST /claims/emergency. beneficiary is None for an unidentified patient.

Field Type Required
attending PractitionerRef yes
reference_number str yes
brought_by BroughtBy yes
mode_of_arrival ModeOfArrival yes
interventions tuple[InterventionCode, ...] yes
beneficiary `PatientId None`
otp `Otp None`
notes str no

ProtocolLine

Command for POST /claims/emergency/protocols — bills a treatment protocol on an emergency claim.

Field Type Required
protocol_code ProtocolCode yes
intervention_code InterventionCode yes
unit_price Money yes
quantity int no
diagnoses tuple[Icd11Code, ...] no

EmtClaim

Command for POST /claims/emt — the ambulance / EMT provider's claim for an emergency case.

Field Type Required
protocol_code ProtocolCode yes
case_number str yes
practitioner_registration_number str yes
provider_registration_number str yes
beneficiary PatientId yes
otp Otp yes
diagnoses tuple[Icd11Code, ...] yes
interventions tuple[InterventionCode, ...] yes
attachments tuple[Attachment, ...] no

Attachment

Attachment(filename: 'str', content: 'bytes', document_type: 'DocumentType', content_type: 'str' = 'application/octet-stream')

Field Type Required
filename str yes
content bytes yes
document_type DocumentType yes
content_type str no

Properties: size

PractitionerRef

Identifies a doctor by registration number (preferred) or a national identity document.

Field Type Required
identification_number str yes
identification_type IdentificationType yes
regulation_body RegulationBody yes

15.3 Enum values

  • AuthorizationStatus (lenient — unknown values allowed): PENDING = PENDING
  • BroughtBy (strict): RELATIVE = RELATIVE, UNKNOWN = UNKNOWN, SAMARITAN = SAMARITAN, PARAMEDICS = PARAMEDICS
  • CancelReason (strict): WRONG_PATIENT = WRONG_PATIENT, NO_SERVICE_GIVEN = NO_SERVICE_GIVEN, WRONG_BENEFIT = WRONG_BENEFIT, EXPIRED_VISIT = EXPIRED_VISIT, EXHAUSTED_BENEFIT = EXHAUSTED_BENEFIT, TIME_BARRED = TIME_BARRED, OTHER_REASONS = OTHER_REASONS
  • CoverageStatus (lenient — unknown values allowed): COVERED = 1
  • DischargeReason (strict): RECOVERED = RECOVERED, REFERRED = REFERRED, DECEASED = DECEASED, ABSCONDED = ABSCONDED, OTHER = OTHER
  • DoctorConsentRequestType (strict): PREAUTH_DOCTOR_APPROVAL = PREAUTH_DOCTOR_APPROVAL_REQUEST, EMERGENCY_CLAIM_DOCTOR_APPROVAL = EMERGENCY_CLAIM_DOCTOR_APPROVAL_REQUEST, PRESCRIPTION = PRESCRIPTION_REQUEST
  • EligibilityStatus (lenient — unknown values allowed): MEMBER_FOUND = 10
  • IdentificationType (strict): NATIONAL_ID = National ID, ALIEN_ID = Alien ID, REFUGEE_ID = Refugee ID, REGISTRATION_NUMBER = registration_number
  • ModeOfArrival (strict): AMBULANCE = AMBULANCE, WALK_IN = WALK-IN, OTHER = OTHER
  • NextOfKinIdType (strict): NATIONAL_ID = National ID, CLIENT_REGISTRY_ID = ClientRegistry ID, BIRTH_NOTIFICATION = Birth Notification, BIRTH_CERTIFICATE = Birth Certificate, ALIEN_ID = Alien ID, REFUGEE_ID = Refugee ID, MANDATE_NUMBER = Mandate Number, TEMPORARY_ID = Temporary ID
  • PaymentMechanism (lenient — unknown values allowed): CAPITATION = CAPITATION, CASE_BASED = CASE BASED, FEE_FOR_SERVICE = FEE FOR SERVICE, PER_DIEM = PER DIEM
  • ServiceType (strict): CAPITATION = CAPITATION, OUTPATIENT = OUTPATIENT, INPATIENT = INPATIENT, EMERGENCY = EMERGENCY
  • DocumentType (strict): BIO_DETAILS = BIO_DETAILS, BIRTH_NOTIFICATION = BIRTH_NOTIFICATION, CARE_PLAN = CARE_PLAN, CASE_NOTE = CASE_NOTE, CASE_SUMMARY = CASE_SUMMARY, CERTIFIED_BURIAL_PERMIT = CERTIFIED_BURIAL_PERMIT, CERTIFIED_COPY_OF_DECEASED_ID = CERTIFIED_COPY_OF_DECEASED_ID, CLAIM_FORM = CLAIM_FORM, COVER_LETTER_FROM_EMPLOYER = COVER_LETTER_FROM_EMPLOYER, CRITICAL_CARE_UNIT_CASE = CRITICAL_CARE_UNIT_CASE, CT_SCAN = CT_SCAN, DEATH_NOTICE = DEATH_NOTICE, DIALYSIS_CHART = DIALYSIS_CHART, DISCHARGE_SUMMARY = DISCHARGE_SUMMARY, ENTRY_EXIT_VISA_STAMP = ENTRY_EXIT_VISA_STAMP, FINAL_BILL = FINAL_BILL, IMAGING_ORDER = IMAGING_ORDER, IMAGING_REPORT = IMAGING_REPORT, INVOICE = INVOICE, LAB_ORDER = LAB_ORDER, LAB_RESULTS = LAB_RESULTS, MAGNETIC_RESONANCE_IMAGING = MAGNETIC_RESONANCE_IMAGING, MEDICAL_REPORT = MEDICAL_REPORT, OTHER = OTHER, POST_SERVICE_IMAGING_REPORT = POST_SERVICE_IMAGING_REPORT, PRE_SERVICE_IMAGING_REPORT = PRE_SERVICE_IMAGING_REPORT, PREAUTH_FORM = PREAUTH_FORM, PRESCRIPTION = PRESCRIPTION, REQUEST_FORM_BY_RELEVANT_CONSULTANT = REQUEST_FORM_BY_RELEVANT_CONSULTANT, RHESUS_FACTOR = RHESUS_FACTOR, THEATRE_NOTES = THEATRE_NOTES
  • RegulationBody (strict): KMPDC = KMPDC, COC = COC, NCK = NCK

Design in one paragraph

The server owns the claim: you open a virtual claim, receive a consent_token, mutate the claim call by call, then submit. The SDK mirrors that faithfully — it holds snapshots, not a local state machine — and only rejects locally what the server will certainly reject. Reads are retried with backoff; submit is attempted exactly once. It stores nothing: no database, no polling, no background tasks — your on_event hook is where audit logging and metrics live. Layers follow Clean Architecture and are enforced by import-linter. Details: docs/ARCHITECTURE.md.

Treat the consent_token like a password — it lets anyone bill against that patient's visit. Persist token.value, never str(token), which is redacted on purpose.

Develop

make install      # venv + editable install with dev extras
make check        # ruff, mypy --strict, import-linter, pytest (≥ 90 % coverage)
make live         # RUN_LIVE=1: smoke tests against DHA UAT (needs .env)

License

Apache-2.0

Release files for sha-claim 0.1.18

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sha-claim 0.1.18
File Size Uploaded
sha_claim-0.1.18.tar.gz 188.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sha-claim 0.1.18
File Interpreter ABI Platform
sha_claim-0.1.18-py3-none-any.whl Python 3 none any Details

Total release size: 299.2 kB

Release files / sha_claim-0.1.18.tar.gz

Download URL sha_claim-0.1.18.tar.gz
Size 188.6 kB
Tags Source
SHA-256 checksum
How to use checksums
d8be24754f061b13c7daf05f6c091d2e0e948e3f0f21870d3adf3ced328e69fc
BLAKE2b-256 checksum
How to use checksums
dbae9376d0e32ab657c9a01ef85bd835e0ac093ac70f033dcf35801797c922aa
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release files / sha_claim-0.1.18-py3-none-any.whl

Download URL sha_claim-0.1.18-py3-none-any.whl
Size 110.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
86fb626de9187bcb308f1c226c52fb5610e6abe482658031def33a73f1190529
BLAKE2b-256 checksum
How to use checksums
7e08d51d3feb1cb8632e1799697f87a361aa9d7b3414a8027c79a2df5d10ee9b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.18 This release

2 release files

0.1.17

2 release files

0.1.16

2 release files

0.1.15

2 release files

0.1.14

2 release files

0.1.13

2 release files

0.1.12

2 release files

0.1.11

2 release files

0.1.10

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page