Build an insurance claims management system in Replit in 2-4 hours. Policyholders submit claims with document attachments, adjusters review and transition them through a state machine workflow, and admins see aggregate stats. Every status transition logs to an audit trail. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth with role-based access.
What you're building
Insurance claims processing is one of the most document-heavy, status-driven workflows in any business. Off-the-shelf claims management systems cost thousands of dollars per month and are inflexible. Building your own gives you a system that matches your exact workflow and integrates with the tools you already use.
Replit Agent generates the entire backend: policies, claims, documents, notes, and status history tables with Drizzle ORM. The core feature is the state machine — claims move through defined status transitions (submitted → under_review → approved/rejected) and every transition is logged. Invalid transitions return a 400 error with an explanation. This prevents adjusters from skipping steps or making out-of-order decisions.
The role system uses Replit Auth for identity and environment variables for admin/adjuster designation. Policyholders see only their own claims. Adjusters see their assigned claims and can change status. Admins see everything. Deploy on Autoscale — claims processing happens during business hours with predictable patterns and scale-to-zero is cost-effective overnight.
Final result
A production-ready claims management system with state machine workflow, document uploads, audit trail, role-based access, and admin dashboard — suitable for small insurance agencies or internal claims departments.
Tech stack
Prerequisites
- A Replit account (free tier is sufficient)
- Basic understanding of what an insurance claim workflow looks like (no coding needed)
- No external API keys required for the core build
- Your Replit user ID (for designating admin and adjuster accounts)
Build steps
Generate the schema and project with Replit Agent
The schema design must support the full claims lifecycle. The status field is the central state machine field, and every change to it must be logged in claim_status_history. Design this correctly from the start.
1// Prompt to type into Replit Agent:2// Build an insurance claims management system with Express and PostgreSQL using Drizzle ORM.3// Create these tables in shared/schema.ts:4// - policyholders: id serial pk, user_id text unique, name text, email text,5// phone text, address jsonb, created_at timestamp6// - policies: id serial pk, policyholder_id integer references policyholders,7// policy_number text unique, type text (auto/home/health/life/commercial),8// coverage_amount integer (cents), deductible integer, premium integer,9// status text default 'active' (active/lapsed/cancelled),10// start_date date, end_date date11// - claims: id serial pk, policy_id integer references policies,12// claimant_id integer references policyholders,13// claim_number text unique, type text (accident/theft/damage/medical/liability),14// description text, incident_date date, amount_claimed integer (cents),15// amount_approved integer, status text default 'submitted',16// assigned_adjuster text, priority text default 'normal' (low/normal/high/urgent),17// created_at timestamp, updated_at timestamp18// - claim_documents: id serial pk, claim_id integer references claims,19// filename text, file_url text, document_type text20// (photo/receipt/police_report/medical_record/estimate/other),21// uploaded_by text, uploaded_at timestamp22// - claim_notes: id serial pk, claim_id integer references claims,23// author text, note text, is_internal boolean default false, created_at timestamp24// - claim_status_history: id serial pk, claim_id integer references claims,25// from_status text, to_status text, changed_by text, reason text, created_at timestamp26// Install multer for file uploads. Set up Replit Auth. Bind server to 0.0.0.0.Pro tip: Add ADMIN_USER_IDS and ADJUSTER_USER_IDS to Replit Secrets (lock icon in sidebar) as comma-separated lists of Replit user IDs. This allows multiple team members to have elevated roles without code changes.
Expected result: Agent creates shared/schema.ts with all six tables, server/index.js with route stubs, and installs multer. Check Drizzle Studio (database icon) to verify all tables exist.
Build the role middleware and claim submission route
The role middleware is the security foundation. It reads user IDs from Secrets to determine roles. The claim submission route auto-generates claim numbers and creates the initial status history entry.
1// Role middleware — add to server/middleware/roles.js2function getRoleMiddleware(req, res, next) {3 if (!req.user) return res.status(401).json({ error: 'Auth required' });45 const adminIds = (process.env.ADMIN_USER_IDS || '').split(',').filter(Boolean);6 const adjusterIds = (process.env.ADJUSTER_USER_IDS || '').split(',').filter(Boolean);78 req.userRole = adminIds.includes(req.user.id) ? 'admin'9 : adjusterIds.includes(req.user.id) ? 'adjuster'10 : 'policyholder';1112 next();13}1415function requireRole(...roles) {16 return (req, res, next) => {17 if (!roles.includes(req.userRole)) {18 return res.status(403).json({ error: `Requires role: ${roles.join(' or ')}` });19 }20 next();21 };22}2324module.exports = { getRoleMiddleware, requireRole };2526// Claim submission route — server/routes/claims.js27const { db } = require('../db');28const { claims, policyholders, policies, claimStatusHistory } = require('../../shared/schema');29const { eq, and } = require('drizzle-orm');30const crypto = require('crypto');3132function generateClaimNumber() {33 const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');34 const suffix = crypto.randomBytes(2).toString('hex').toUpperCase();35 return `CLM-${date}-${suffix}`;36}3738router.post('/api/claims', async (req, res) => {39 const { policyId, type, description, incidentDate, amountClaimed, priority } = req.body;4041 // Find policyholder record42 const policyholder = await db.query.policyholders.findFirst({43 where: eq(policyholders.userId, req.user.id)44 });4546 if (!policyholder) {47 // Auto-create policyholder profile on first claim48 const [ph] = await db.insert(policyholders).values({49 userId: req.user.id, name: req.user.name || 'Unknown', email: req.user.email || ''50 }).returning();51 req.policyholder = ph;52 } else {53 req.policyholder = policyholder;54 }5556 // Validate policy belongs to this policyholder57 const policy = await db.query.policies.findFirst({58 where: and(eq(policies.id, Number(policyId)), eq(policies.policyholderId, req.policyholder.id))59 });60 if (!policy) return res.status(404).json({ error: 'Policy not found' });61 if (policy.status !== 'active') return res.status(400).json({ error: 'Policy is not active' });6263 const claimNumber = generateClaimNumber();6465 const [claim] = await db.insert(claims).values({66 policyId: Number(policyId), claimantId: req.policyholder.id,67 claimNumber, type, description, incidentDate,68 amountClaimed: Number(amountClaimed), status: 'submitted',69 priority: priority || 'normal'70 }).returning();7172 // Log initial status73 await db.insert(claimStatusHistory).values({74 claimId: claim.id, fromStatus: null, toStatus: 'submitted',75 changedBy: req.user.id, reason: 'Initial submission'76 });7778 res.json(claim);79});Pro tip: Store admin user IDs in ADMIN_USER_IDS (Secrets panel). Find your Replit user ID by temporarily adding a GET /api/me route that returns req.user after login.
Expected result: POST /api/claims creates a claim with a CLM-YYYYMMDD-XXXX number and status='submitted'. A row appears in claim_status_history with to_status='submitted'.
Build the state machine status transition engine
The state machine enforces valid claim workflow transitions. Every invalid transition returns a 400 error. Every valid transition logs to the audit trail. This prevents claims from jumping to the wrong status.
1const VALID_TRANSITIONS = {2 draft: ['submitted'],3 submitted: ['under_review'],4 under_review: ['approved', 'partially_approved', 'rejected', 'additional_info_requested'],5 additional_info_requested: ['under_review'],6 approved: ['paid'],7 partially_approved: ['paid']8};910const ROLE_PERMISSIONS = {11 submitted: ['adjuster', 'admin'],12 under_review: ['adjuster', 'admin'],13 additional_info_requested: ['adjuster', 'admin'],14 approved: ['adjuster', 'admin'],15 partially_approved: ['adjuster', 'admin'],16 rejected: ['adjuster', 'admin'],17 paid: ['admin']18};1920router.patch('/api/claims/:id/status', async (req, res) => {21 const { newStatus, reason, amountApproved } = req.body;22 const claimId = Number(req.params.id);2324 const claim = await getClaim(claimId, req);25 if (!claim) return res.status(404).json({ error: 'Claim not found' });2627 const allowedNext = VALID_TRANSITIONS[claim.status] || [];28 if (!allowedNext.includes(newStatus)) {29 return res.status(400).json({30 error: `Invalid transition from '${claim.status}' to '${newStatus}'`,31 allowedTransitions: allowedNext32 });33 }3435 const rolesAllowed = ROLE_PERMISSIONS[newStatus] || [];36 if (!rolesAllowed.includes(req.userRole)) {37 return res.status(403).json({ error: `Only ${rolesAllowed.join(' or ')} can set status to ${newStatus}` });38 }3940 const updateData = { status: newStatus, updatedAt: new Date() };41 if (newStatus === 'approved' || newStatus === 'partially_approved') {42 if (!amountApproved) return res.status(400).json({ error: 'amount_approved required for approval' });43 updateData.amountApproved = Number(amountApproved);44 }4546 await db.update(claims).set(updateData).where(eq(claims.id, claimId));4748 await db.insert(claimStatusHistory).values({49 claimId, fromStatus: claim.status, toStatus: newStatus,50 changedBy: req.user.id, reason: reason || null51 });5253 res.json({ success: true, newStatus, claimNumber: claim.claimNumber });54});Pro tip: The VALID_TRANSITIONS object is the single source of truth for workflow rules. To add a new status (e.g., 'appeal'), add it to the relevant transition entries. No other code needs to change.
Expected result: Trying to set a claim directly from 'submitted' to 'approved' returns 400 with {allowedTransitions: ['under_review']}. The correct flow (submitted → under_review → approved) works correctly.
Add document upload and claim notes routes
Documents are the evidence backbone of any claim. Use Multer to handle file uploads in memory, validate file types, and store in Replit Object Storage or Base64-encode small files to PostgreSQL for simplicity.
1// Prompt to type into Replit Agent:2// Add document and notes routes to server/routes/claims.js:3//4// POST /api/claims/:id/documents — upload document5// Use multer memoryStorage with:6// fileFilter: only allow jpeg, jpg, png, pdf (check mimetype)7// limits: fileSize 5MB8// Validate claim belongs to req.user (either claimant or adjuster/admin)9// For simplicity, store file as base64 in file_url column:10// const fileUrl = `data:${req.file.mimetype};base64,${req.file.buffer.toString('base64')}`11// Insert into claim_documents: {claim_id, filename, file_url, document_type, uploaded_by}12// document_type comes from req.body.documentType13// Return the document record (without the full base64 for the list view)14//15// GET /api/claims/:id/documents — list documents16// Return id, filename, document_type, uploaded_by, uploaded_at (NOT file_url)17//18// GET /api/claims/:id/documents/:docId/download — download single document19// Return the full file_url for a specific document20//21// POST /api/claims/:id/notes — add note22// Body: {note, isInternal: boolean}23// is_internal=true notes: only adjusters/admins can see24// is_internal=false notes: visible to claimant25// Insert into claim_notes: {claim_id, author: req.user.id, note, is_internal}26//27// GET /api/claims/:id/notes — list notes28// If req.userRole === 'policyholder': WHERE is_internal = false29// If adjuster or admin: return all notesExpected result: Uploading a JPG to POST /api/claims/1/documents stores the file and returns the document metadata. A PNG over 5MB returns 400. A .exe file is rejected by the fileFilter.
Build the admin dashboard and deploy
The admin dashboard aggregates claim statistics and flags overdue cases. Deploy on Autoscale and set up role-based routing in the React frontend to show different views for policyholders vs adjusters vs admins.
1// Prompt to type into Replit Agent:2// Add admin routes to server/routes/admin.js:3//4// GET /api/admin/claims/dashboard — aggregate stats (admin only):5// Claims count by status (submitted, under_review, approved, etc.)6// Total amount claimed vs total amount approved this month7// Average days from submitted_at to approved_at (for closed claims)8// Return these as a summary object9//10// GET /api/admin/claims/overdue — claims overdue for review11// Claims where status = 'under_review' AND12// created_at < NOW() - interval '7 days'13// Include: claim_number, type, priority, days_in_review, claimant name14// Order by priority (urgent first) then by age (oldest first)15//16// GET /api/admin/claims — all claims with filters:17// Query params: status, priority, assigned_adjuster, page, limit18// Include claimant name from policyholders join19//20// PATCH /api/admin/claims/:id/assign — assign adjuster21// Body: {adjusterId} — set assigned_adjuster = adjusterId22//23// React frontend:24// 1. Policyholder view: My Claims list, submit new claim wizard, claim detail page25// 2. Adjuster view: Assigned claims queue, claim detail with status transition buttons,26// document viewer, notes (all including internal), approve/reject form27// 3. Admin view: Dashboard with stats pipeline, all claims table, overdue alerts28//29// Add SESSION_SECRET to Replit Secrets, ensure server binds to 0.0.0.030// Deploy → AutoscalePro tip: After deploying, set ADMIN_USER_IDS in Replit Secrets to your own user ID. Then log in, navigate to /admin, and manually create a test policy and claim to verify the full workflow end-to-end.
Expected result: The admin dashboard shows claims-by-status counts. The overdue query returns claims that have been in under_review for more than 7 days. Role-based routing shows the correct view per user type.
Complete code
1const { Router } = require('express');2const { db } = require('../db');3const { claims, policyholders, claimStatusHistory } = require('../../shared/schema');4const { eq, and, sql } = require('drizzle-orm');5const crypto = require('crypto');67const router = Router();89const VALID_TRANSITIONS = {10 draft: ['submitted'],11 submitted: ['under_review'],12 under_review: ['approved', 'partially_approved', 'rejected', 'additional_info_requested'],13 additional_info_requested: ['under_review'],14 approved: ['paid'],15 partially_approved: ['paid']16};1718function generateClaimNumber() {19 const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');20 const suffix = crypto.randomBytes(2).toString('hex').toUpperCase();21 return `CLM-${date}-${suffix}`;22}2324router.post('/api/claims', async (req, res) => {25 if (!req.user) return res.status(401).json({ error: 'Auth required' });26 const { policyId, type, description, incidentDate, amountClaimed } = req.body;2728 let [ph] = await db.select().from(policyholders).where(eq(policyholders.userId, req.user.id));29 if (!ph) {30 [ph] = await db.insert(policyholders)31 .values({ userId: req.user.id, name: req.user.name || 'Unknown', email: req.user.email || '' })32 .returning();33 }3435 const claimNumber = generateClaimNumber();36 const [claim] = await db.insert(claims).values({37 policyId: Number(policyId), claimantId: ph.id,38 claimNumber, type, description, incidentDate,39 amountClaimed: Number(amountClaimed), status: 'submitted'40 }).returning();4142 await db.insert(claimStatusHistory).values({43 claimId: claim.id, fromStatus: null, toStatus: 'submitted',44 changedBy: req.user.id, reason: 'Initial submission'45 });4647 res.json(claim);48});4950router.patch('/api/claims/:id/status', async (req, res) => {51 if (!req.user) return res.status(401).json({ error: 'Auth required' });52 const { newStatus, reason, amountApproved } = req.body;53 const claimId = Number(req.params.id);5455 const [claim] = await db.select().from(claims).where(eq(claims.id, claimId));56 if (!claim) return res.status(404).json({ error: 'Claim not found' });5758 const allowed = VALID_TRANSITIONS[claim.status] || [];59 if (!allowed.includes(newStatus)) {60 return res.status(400).json({ error: `Cannot transition from ${claim.status} to ${newStatus}`, allowedTransitions: allowed });Customization ideas
Email notifications on status changes
After each status transition, send an email to the claimant using SendGrid with the new status and any adjuster notes. 'Your claim CLM-20260425-A3B2 has been approved for $1,250.00' with the claim details. Store SENDGRID_API_KEY in Replit Secrets.
Claim templates
Add a claim_templates table with pre-filled descriptions for common claim types (fender bender, water damage, theft). A template selector in the submission form pre-populates the description and required document checklist, reducing submission errors.
SLA monitoring
Add an SLA definition table (status, max_hours_allowed). A Scheduled Deployment checks claims where time-in-current-status exceeds the SLA and sends an alert email to the assigned adjuster and admin. Priority=urgent has shorter SLAs than normal.
Common pitfalls
Pitfall: Allowing any status-to-status transition without a state machine
How to avoid: Use the VALID_TRANSITIONS object as the sole authority for allowed transitions. The PATCH /api/claims/:id/status route checks this before any database write.
Pitfall: Storing documents as large binary files in PostgreSQL without size limits
How to avoid: Enforce a 5MB per-file limit in Multer's limits config. For production, use Replit Object Storage (1GB free) or an external CDN like Cloudinary — store only the URL in the database, not the file content.
Pitfall: Not scoping claim queries by user role
How to avoid: Every claim query must check role: policyholders see only WHERE claimant_id = req.policyholder.id, adjusters see WHERE assigned_adjuster = req.user.id, admins see all. The getRoleMiddleware helper sets req.userRole for use in every route.
Pitfall: Using express.json() on the file upload route
How to avoid: Multer handles its own body parsing. Don't apply express.json() middleware to routes that use multer. They use multer's upload.single() or upload.array() instead.
Best practices
- Store role designations (admin IDs, adjuster IDs) in Replit Secrets as comma-separated user ID lists. This lets you add team members without code changes or redeployment.
- Log every status transition to claim_status_history — not just the current status. Regulators and legal teams may require a full audit trail of who changed what and when.
- Use the VALID_TRANSITIONS object as the single source of truth for workflow rules. Adding a new status requires updating only this object.
- Enforce file size and type limits in Multer's configuration, not just in validation code. Multer can reject oversized files before they're loaded into memory.
- Use Drizzle Studio (database icon in sidebar) to inspect the claim_status_history table during testing — you can see the full audit trail for any claim without building a UI.
- Deploy on Autoscale — claims processing is business-hours activity. Scale-to-zero overnight and on weekends costs nothing on Replit's free tier.
- Add an is_internal flag to claim notes to separate adjuster working notes from claimant-visible communications. Never show internal notes to policyholders.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an insurance claims workflow system with Express and PostgreSQL. I have a claims table with a status column and a claim_status_history table for the audit trail. I need to implement a state machine where only certain status transitions are valid (e.g., submitted → under_review, under_review → approved/rejected, approved → paid). Help me write a PATCH /api/claims/:id/status route that: (1) validates the new status is in the VALID_TRANSITIONS array for the current status, (2) checks the requesting user's role has permission to make this transition, (3) updates the claim status, (4) inserts an audit row into claim_status_history with from_status, to_status, changed_by, and reason.
Add a claims intake wizard to the insurance claims tool. Build a multi-step React form: Step 1 — select policy from dropdown (fetches user's policies), Step 2 — claim type and incident details (date, description, amount claimed), Step 3 — document upload (drag-and-drop multi-file with type selector per file), Step 4 — review and confirm. Each step validates before advancing. Final Submit creates the claim and all documents in sequence.
Frequently asked questions
How do I find my Replit user ID to set up admin access?
Temporarily add a GET /api/me route that returns req.user after authentication. Log in, call the route, and copy your user ID from the response. Add it to ADMIN_USER_IDS in Replit Secrets (lock icon in sidebar). Remove the /api/me route afterward.
Can I have multiple adjusters on the same system?
Yes. Add all adjuster user IDs to ADJUSTER_USER_IDS as a comma-separated list in Replit Secrets. Each adjuster sees all claims assigned to them (WHERE assigned_adjuster = req.user.id). Admins can reassign claims between adjusters using the PATCH /api/admin/claims/:id/assign route.
What file formats can claimants upload?
The Multer fileFilter allows only JPEG, JPG, PNG, and PDF. Other formats return a 400 error. The 5MB per-file size limit prevents oversized uploads. These restrictions are enforced server-side in the route, not just in the frontend.
How does the system handle fraudulent or duplicate claims?
Add a fraud_flag boolean and fraud_notes text column to claims. Adjusters can flag suspicious claims during review. The admin dashboard shows all flagged claims. A complete audit trail (claim_status_history) shows every action taken on a claim for investigation.
Do I need a paid Replit plan for this?
No. The free plan includes PostgreSQL, Autoscale deployment, and Replit Auth. For production use with high document upload volume, consider upgrading to avoid hitting the 10GB PostgreSQL storage limit from stored file data.
Can claimants track their claim status without logging in?
Add a public claim lookup route: GET /api/claims/lookup?claimNumber=CLM-20260425-A3B2&email=user@example.com. This returns the claim status and status history for the matching claim_number + claimant email combination — no login required.
Can RapidDev help build a custom claims platform for an insurance agency?
Yes. RapidDev has built 600+ apps and can add features like Stripe payment disbursement for approved claims, integrations with policy management systems, and mobile-optimized claim submission with camera upload. Book a free consultation at rapidevelopers.com.
How do I handle claim appeals?
Add an 'appealed' status to VALID_TRANSITIONS from 'rejected': rejected → appealed. When appealed, create a new claim row with parent_claim_id referencing the original. The appeal follows the same workflow but adjuster notes reference the original decision.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation