# How to Build an Insurance Claims Tool with Replit

- Tool: How to Build with Replit
- Difficulty: Advanced
- Compatibility: Replit Free
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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)

## Step-by-step guide

### 1. 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.

```
// Prompt to type into Replit Agent:
// Build an insurance claims management system with Express and PostgreSQL using Drizzle ORM.
// Create these tables in shared/schema.ts:
// - policyholders: id serial pk, user_id text unique, name text, email text,
//   phone text, address jsonb, created_at timestamp
// - policies: id serial pk, policyholder_id integer references policyholders,
//   policy_number text unique, type text (auto/home/health/life/commercial),
//   coverage_amount integer (cents), deductible integer, premium integer,
//   status text default 'active' (active/lapsed/cancelled),
//   start_date date, end_date date
// - claims: id serial pk, policy_id integer references policies,
//   claimant_id integer references policyholders,
//   claim_number text unique, type text (accident/theft/damage/medical/liability),
//   description text, incident_date date, amount_claimed integer (cents),
//   amount_approved integer, status text default 'submitted',
//   assigned_adjuster text, priority text default 'normal' (low/normal/high/urgent),
//   created_at timestamp, updated_at timestamp
// - claim_documents: id serial pk, claim_id integer references claims,
//   filename text, file_url text, document_type text
//   (photo/receipt/police_report/medical_record/estimate/other),
//   uploaded_by text, uploaded_at timestamp
// - claim_notes: id serial pk, claim_id integer references claims,
//   author text, note text, is_internal boolean default false, created_at timestamp
// - claim_status_history: id serial pk, claim_id integer references claims,
//   from_status text, to_status text, changed_by text, reason text, created_at timestamp
// 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.

### 2. 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.

```
// Role middleware — add to server/middleware/roles.js
function getRoleMiddleware(req, res, next) {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });

  const adminIds = (process.env.ADMIN_USER_IDS || '').split(',').filter(Boolean);
  const adjusterIds = (process.env.ADJUSTER_USER_IDS || '').split(',').filter(Boolean);

  req.userRole = adminIds.includes(req.user.id) ? 'admin'
    : adjusterIds.includes(req.user.id) ? 'adjuster'
    : 'policyholder';

  next();
}

function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.userRole)) {
      return res.status(403).json({ error: `Requires role: ${roles.join(' or ')}` });
    }
    next();
  };
}

module.exports = { getRoleMiddleware, requireRole };

// Claim submission route — server/routes/claims.js
const { db } = require('../db');
const { claims, policyholders, policies, claimStatusHistory } = require('../../shared/schema');
const { eq, and } = require('drizzle-orm');
const crypto = require('crypto');

function generateClaimNumber() {
  const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
  const suffix = crypto.randomBytes(2).toString('hex').toUpperCase();
  return `CLM-${date}-${suffix}`;
}

router.post('/api/claims', async (req, res) => {
  const { policyId, type, description, incidentDate, amountClaimed, priority } = req.body;

  // Find policyholder record
  const policyholder = await db.query.policyholders.findFirst({
    where: eq(policyholders.userId, req.user.id)
  });

  if (!policyholder) {
    // Auto-create policyholder profile on first claim
    const [ph] = await db.insert(policyholders).values({
      userId: req.user.id, name: req.user.name || 'Unknown', email: req.user.email || ''
    }).returning();
    req.policyholder = ph;
  } else {
    req.policyholder = policyholder;
  }

  // Validate policy belongs to this policyholder
  const policy = await db.query.policies.findFirst({
    where: and(eq(policies.id, Number(policyId)), eq(policies.policyholderId, req.policyholder.id))
  });
  if (!policy) return res.status(404).json({ error: 'Policy not found' });
  if (policy.status !== 'active') return res.status(400).json({ error: 'Policy is not active' });

  const claimNumber = generateClaimNumber();

  const [claim] = await db.insert(claims).values({
    policyId: Number(policyId), claimantId: req.policyholder.id,
    claimNumber, type, description, incidentDate,
    amountClaimed: Number(amountClaimed), status: 'submitted',
    priority: priority || 'normal'
  }).returning();

  // Log initial status
  await db.insert(claimStatusHistory).values({
    claimId: claim.id, fromStatus: null, toStatus: 'submitted',
    changedBy: req.user.id, reason: 'Initial submission'
  });

  res.json(claim);
});
```

> 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'.

### 3. 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.

```
const VALID_TRANSITIONS = {
  draft: ['submitted'],
  submitted: ['under_review'],
  under_review: ['approved', 'partially_approved', 'rejected', 'additional_info_requested'],
  additional_info_requested: ['under_review'],
  approved: ['paid'],
  partially_approved: ['paid']
};

const ROLE_PERMISSIONS = {
  submitted: ['adjuster', 'admin'],
  under_review: ['adjuster', 'admin'],
  additional_info_requested: ['adjuster', 'admin'],
  approved: ['adjuster', 'admin'],
  partially_approved: ['adjuster', 'admin'],
  rejected: ['adjuster', 'admin'],
  paid: ['admin']
};

router.patch('/api/claims/:id/status', async (req, res) => {
  const { newStatus, reason, amountApproved } = req.body;
  const claimId = Number(req.params.id);

  const claim = await getClaim(claimId, req);
  if (!claim) return res.status(404).json({ error: 'Claim not found' });

  const allowedNext = VALID_TRANSITIONS[claim.status] || [];
  if (!allowedNext.includes(newStatus)) {
    return res.status(400).json({
      error: `Invalid transition from '${claim.status}' to '${newStatus}'`,
      allowedTransitions: allowedNext
    });
  }

  const rolesAllowed = ROLE_PERMISSIONS[newStatus] || [];
  if (!rolesAllowed.includes(req.userRole)) {
    return res.status(403).json({ error: `Only ${rolesAllowed.join(' or ')} can set status to ${newStatus}` });
  }

  const updateData = { status: newStatus, updatedAt: new Date() };
  if (newStatus === 'approved' || newStatus === 'partially_approved') {
    if (!amountApproved) return res.status(400).json({ error: 'amount_approved required for approval' });
    updateData.amountApproved = Number(amountApproved);
  }

  await db.update(claims).set(updateData).where(eq(claims.id, claimId));

  await db.insert(claimStatusHistory).values({
    claimId, fromStatus: claim.status, toStatus: newStatus,
    changedBy: req.user.id, reason: reason || null
  });

  res.json({ success: true, newStatus, claimNumber: claim.claimNumber });
});
```

> 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.

### 4. 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.

```
// Prompt to type into Replit Agent:
// Add document and notes routes to server/routes/claims.js:
//
// POST /api/claims/:id/documents — upload document
//   Use multer memoryStorage with:
//     fileFilter: only allow jpeg, jpg, png, pdf (check mimetype)
//     limits: fileSize 5MB
//   Validate claim belongs to req.user (either claimant or adjuster/admin)
//   For simplicity, store file as base64 in file_url column:
//     const fileUrl = `data:${req.file.mimetype};base64,${req.file.buffer.toString('base64')}`
//   Insert into claim_documents: {claim_id, filename, file_url, document_type, uploaded_by}
//   document_type comes from req.body.documentType
//   Return the document record (without the full base64 for the list view)
//
// GET /api/claims/:id/documents — list documents
//   Return id, filename, document_type, uploaded_by, uploaded_at (NOT file_url)
//
// GET /api/claims/:id/documents/:docId/download — download single document
//   Return the full file_url for a specific document
//
// POST /api/claims/:id/notes — add note
//   Body: {note, isInternal: boolean}
//   is_internal=true notes: only adjusters/admins can see
//   is_internal=false notes: visible to claimant
//   Insert into claim_notes: {claim_id, author: req.user.id, note, is_internal}
//
// GET /api/claims/:id/notes — list notes
//   If req.userRole === 'policyholder': WHERE is_internal = false
//   If adjuster or admin: return all notes
```

**Expected 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.

### 5. 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.

```
// Prompt to type into Replit Agent:
// Add admin routes to server/routes/admin.js:
//
// GET /api/admin/claims/dashboard — aggregate stats (admin only):
//   Claims count by status (submitted, under_review, approved, etc.)
//   Total amount claimed vs total amount approved this month
//   Average days from submitted_at to approved_at (for closed claims)
//   Return these as a summary object
//
// GET /api/admin/claims/overdue — claims overdue for review
//   Claims where status = 'under_review' AND
//   created_at < NOW() - interval '7 days'
//   Include: claim_number, type, priority, days_in_review, claimant name
//   Order by priority (urgent first) then by age (oldest first)
//
// GET /api/admin/claims — all claims with filters:
//   Query params: status, priority, assigned_adjuster, page, limit
//   Include claimant name from policyholders join
//
// PATCH /api/admin/claims/:id/assign — assign adjuster
//   Body: {adjusterId} — set assigned_adjuster = adjusterId
//
// React frontend:
// 1. Policyholder view: My Claims list, submit new claim wizard, claim detail page
// 2. Adjuster view: Assigned claims queue, claim detail with status transition buttons,
//    document viewer, notes (all including internal), approve/reject form
// 3. Admin view: Dashboard with stats pipeline, all claims table, overdue alerts
//
// Add SESSION_SECRET to Replit Secrets, ensure server binds to 0.0.0.0
// Deploy → Autoscale
```

> Pro 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 example

File: `server/routes/claims.js`

```javascript
const { Router } = require('express');
const { db } = require('../db');
const { claims, policyholders, claimStatusHistory } = require('../../shared/schema');
const { eq, and, sql } = require('drizzle-orm');
const crypto = require('crypto');

const router = Router();

const VALID_TRANSITIONS = {
  draft: ['submitted'],
  submitted: ['under_review'],
  under_review: ['approved', 'partially_approved', 'rejected', 'additional_info_requested'],
  additional_info_requested: ['under_review'],
  approved: ['paid'],
  partially_approved: ['paid']
};

function generateClaimNumber() {
  const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
  const suffix = crypto.randomBytes(2).toString('hex').toUpperCase();
  return `CLM-${date}-${suffix}`;
}

router.post('/api/claims', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });
  const { policyId, type, description, incidentDate, amountClaimed } = req.body;

  let [ph] = await db.select().from(policyholders).where(eq(policyholders.userId, req.user.id));
  if (!ph) {
    [ph] = await db.insert(policyholders)
      .values({ userId: req.user.id, name: req.user.name || 'Unknown', email: req.user.email || '' })
      .returning();
  }

  const claimNumber = generateClaimNumber();
  const [claim] = await db.insert(claims).values({
    policyId: Number(policyId), claimantId: ph.id,
    claimNumber, type, description, incidentDate,
    amountClaimed: Number(amountClaimed), status: 'submitted'
  }).returning();

  await db.insert(claimStatusHistory).values({
    claimId: claim.id, fromStatus: null, toStatus: 'submitted',
    changedBy: req.user.id, reason: 'Initial submission'
  });

  res.json(claim);
});

router.patch('/api/claims/:id/status', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Auth required' });
  const { newStatus, reason, amountApproved } = req.body;
  const claimId = Number(req.params.id);

  const [claim] = await db.select().from(claims).where(eq(claims.id, claimId));
  if (!claim) return res.status(404).json({ error: 'Claim not found' });

  const allowed = VALID_TRANSITIONS[claim.status] || [];
  if (!allowed.includes(newStatus)) {
    return res.status(400).json({ error: `Cannot transition from ${claim.status} to ${newStatus}`, allowedTransitions: allowed });
```

## Common mistakes

- **Allowing any status-to-status transition without a state machine** — Without enforced transitions, adjusters can accidentally (or intentionally) jump from 'submitted' to 'paid', skipping review entirely. This creates compliance and audit problems. Fix: 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.
- **Storing documents as large binary files in PostgreSQL without size limits** — Replit's free PostgreSQL has a 10GB total limit. A few hundred multi-megabyte PDFs can consume it quickly, breaking the entire database. Fix: 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.
- **Not scoping claim queries by user role** — A policyholder should never see another policyholder's claims, even if they guess or iterate claim IDs. Fix: 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.
- **Using express.json() on the file upload route** — Multer parses multipart/form-data requests. If express.json() runs first on the file upload route, it may try to parse the multipart body and fail. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/insurance-claims-tool
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/insurance-claims-tool
