Summary
At Bima Garage, an insurtech startup in Mumbai, I helped build the platform that connected agents inside hospitals with the back office handling insurance claims. This is what we built, the four features that made it work, and what I would do differently now.
The problem: paying for a hospital stay
Insurance penetration in India is low, and a large share of insured people have low credit scores. When an emergency hits, families take expensive loans just to get someone admitted. People with good insurance are not safe either: claims get delayed, negotiated down, or rejected at exactly the wrong moment.
Bima Garage took that headache over for a one-time processing fee or a yearly subscription. Two journeys started at the hospital desk. An insured patient was onboarded by our agent, and the back office handled the claim with the insurer. A patient with no insurance, or a denied claim, could have the bill financed by Bima Garage, acting as an NBFC, and repay it in EMIs.
- Hospital intake establishes the patient or representative and creates a case.
- An insured journey collects policy evidence and coordinates the claim lifecycle.
- A separate financing journey performs authorised application, assessment, decision, and agreement steps.
- Both journeys use shared document, task, communication, deadline, and audit services.
- Claim decisions remain owned by the insurer and authorised operations.
- Financing decisions remain owned by the authorised lender and reviewers.
- The case timeline records resolution, settlement, repayment, closure, and any correction path.
Two apps, one case
Hospital agents used HobNob, a React Native app. The back office used Desk, a React web portal. Both talked to a Python and Django backend on PostgreSQL, running in Docker on Kubernetes.
Onboarding needed only a few fields and one identifier, a mobile number or a national ID, and that identifier followed the case through its whole life.
If I were starting again, the case is the thing I would design first. Its statuses should be real states with allowed transitions, not labels any screen can overwrite. And I would give every case an internal ID and treat the phone number as an attribute, because phone numbers change hands.
Scoring a loan while the family waits
For an uninsured patient, time is everything. Once they were onboarded, the platform fetched their CIBIL credit score from third-party vendors, combined it with the estimated medical cost, and our scoring algorithm produced a recommendation for our banking partners: whether to approve the loan, and at what rate.
Families are anxious while this happens, so updates had to be instant. Every time a row changed status, a Django signal fired, handed the job to a Celery worker, and the worker used AWS SNS to send WhatsApp and email.
It worked, and it is the part I would build differently. Django’s own documentation on signals warns that they make code hard to follow. Worse, a message sent from a side door can go out for a change that never committed, or be lost for one that did. Celery’s task guide recommends writing tasks to be idempotent, because a message can be redelivered.
The fix is called a transactional outbox. Write the status change and a row saying “this needs sending” in the same transaction. A separate dispatcher publishes those rows, and consumers ignore an event they have already handled.
- The application writes the case change and an outbox row in one transaction.
- The database commits both or neither.
- A dispatcher reads unpublished outbox rows.
- It publishes each one with its event ID.
- It marks the row as published.
- The broker delivers the event, possibly more than once.
- The consumer skips event IDs it has already handled.
def transition_case(case_id, command, actor):
with database.transaction():
case = cases.lock(case_id)
transition = policy.validate(case, command, actor)
case.apply(transition)
cases.save(case)
outbox.append(
event_id=stable_event_id(case.id, case.version),
event_type="case.status_changed",
aggregate_id=case.id,
payload={
"from": transition.previous_state,
"to": transition.next_state,
"reason_code": transition.reason_code,
},
)
return case.snapshot()
def publish_outbox(record):
broker.publish(
topic=record.event_type,
key=record.aggregate_id,
payload=record.payload,
idempotency_key=record.event_id,
)
outbox.mark_published(record.event_id)Collecting repayments with eNACH
Financing a hospital bill is half the job. Collecting the EMIs reliably is the other half. We looked at standard gateways like Paytm and Razorpay, but we needed something that ran with no human involved, month after month.
We chose eNACH. The customer registers a one-time mandate, and the system can pull the EMI from their bank account on a set date. We could also pause or cancel the mandate from code when a repayment plan changed.
- Create a mandate request under the applicable participant and provider rules.
- Track registration separately from activation and rejection.
- An active mandate schedules distinct debit attempts with stable identities.
- A presented debit waits for authoritative settlement or failure status.
- Successful debits reconcile against the repayment ledger.
- Failed debits follow a policy-controlled retry, communication, or review path.
- The mandate can pause, change, cancel, expire, or complete without rewriting prior attempts.
- Scheduled reconciliation finds missing or inconsistent provider outcomes.
The lesson here is that a mandate is not an on/off switch. Registration, activation, each debit, a failed debit, a retry, a pause, and a cancellation all happen at different times and are reported by different parties. “The API accepted it” does not mean the money arrived. You only know that by reconciling against the bank’s reports. NPCI’s NACH overview describes the rails. The limits and obligations come from the current rules and your provider.
Cutting turnaround time by reading the insurers’ email
For a long time our biggest bottleneck was claim tracking. Back-office staff refreshed insurer portals and inboxes all day to find out whether a claim had moved.
Guided by a senior solution architect, we built an email polling engine. Scheduled jobs polled our dedicated claims inboxes, and scripts read the automated emails insurers sent. They pulled out the claim ID and the status, such as Query Raised, Approved, or Rejected, and updated PostgreSQL. Hours of manual data entry became seconds, and each update set off the notifications to the hospital desk.
- A webhook, API call, or partner email arrives.
- Store the raw message and its digest.
- If the message was seen before, ignore the replay.
- Parse allowlisted fields into a candidate event.
- Check the sender, the case, and the transition.
- If all are valid, apply the case transition.
- Otherwise quarantine it for an operator.
It worked, but email is somebody else’s API with no contract, and templates change without warning. If I built it again I would keep the raw message, ignore one already seen, and send anything unfamiliar or mismatched to a person instead of guessing. The diagram above is that version.
Forty pages down to one
This feature landed on my desk at very short notice. An insurance policy is 20 to 40 pages of dense legal language. We wanted to turn it into a one-page summary called KYP, Know Your Policy.
To pull text out of large PDFs we tried Tesseract, AWS Textract, and Google Cloud Vision. Azure Form Recognizer, now called Azure Document Intelligence, was the best fit for speed and accuracy on unstructured Indian financial documents.
The real problem was load. A 40-page PDF is heavy, and five uploads at once could tie up our Django threads and take the API down. So the file went straight to S3, a message went onto a RabbitMQ queue, and the user saw a “Processing” screen. Workers took messages one at a time, sent the PDF to Azure, parsed the JSON that came back, built the summary, and told the user it was ready. We delivered it in under five minutes without ever loading the main servers.
The version I would ship today adds one step. Extraction returns a confidence for each field, and a person should check the uncertain ones before a summary reaches a patient. This is that version.
- The authorised user uploads a policy through a scoped object-storage path.
- Media validation confirms identity, type, checksum, and security policy.
- A durable queue sends the document to a versioned extraction worker.
- OCR and layout or field extraction preserve page-level source coordinates.
- Deterministic validation checks types, cross-field rules, missing values, and contradictions.
- Low-confidence or conflicting fields become explicit review tasks.
- A summary draft uses only validated fields and cited source passages.
- An authorised reviewer corrects and approves the one-page KYP output.
- The system records versions, evidence, reviewer, and retention state.
What I took from it
Startups teach you that the idea is the cheap part. What matters is the execution: how you design the database, how you handle the queues, and how you deal with the edge cases.
- Design the case and its states before any screen.
- Tie every notification to a change that actually committed.
- Money needs reconciliation, not optimism.
- Put a queue in front of anything slow.
References
- Building a Claims & Financing Engine from the Ground Up
Abhishek Choudhury. Original project article, published February 2025.
- NACH: High Volume Repetitive Payment Solution
National Payments Corporation of India. An overview of NACH transactions and mandate management.
- Django signals
Django Software Foundation. Signal behavior and the maintainability warning for implicit coupling.
- Celery tasks
Celery Project. Task delivery, idempotency, timeouts, and worker behavior.
- Azure Document Intelligence documentation
Microsoft. Current product terminology, capabilities, versions, and lifecycle.
- Transparency note for Document Intelligence
Microsoft. Responsible-use considerations for the document-processing service.
