Nov 10, 202610 min read

Insurance Claims Processing Backend on AWS

Build the backend for first notice of loss, claim documents, automated triage, adjuster SLAs and an audit trail of every status change, with the hand-built AWS path and the same design in TypeScript with Encore.

Illustration for Insurance Claims Processing Backend on AWS

Policyholders judge a claims system by how fast they can report a loss, and operations teams judge it by how long a claim sits before an adjuster acts on it, and both depend on the backend, where first notice of loss (FNOL) has to be accepted even when the triage rules are slow, photos and repair estimates have to reach storage without passing through the API servers, a claim cannot jump from submitted to paid, and every change needs a record of who made it and why for the complaint or dispute that arrives a year later.

This guide designs a claims processing backend for those jobs on AWS: FNOL intake, signed document uploads, asynchronous triage, an append-only history and SLA escalation on a schedule, first by hand and then in TypeScript with Encore. It covers the application side, while regulatory obligations for claims handling differ by country and line of business and stay with your team, for review by your compliance function.

The claims backend at a glance

JobHand-built on AWSWith Encore in your AWS account
FNOL intakeAPI Gateway or ALB plus Lambda or ECSEncore endpoint
Claim documentsPrivate S3 bucket, presigned URLs from the SDKBucket with signedUploadUrl
TriageSQS queue and a workerTopic and Subscription (SNS and SQS)
Claims data and historyRDS for PostgreSQLEncore database on RDS
SLA escalationEventBridge Scheduler and a taskCronJob
Least-privilege accessIAM policies written per function or taskIAM role per service, generated
Local developmentLocalStack or a dev accountencore run

How do you build it by hand on AWS?

  1. Intake: An ECS service or Lambda behind API Gateway accepts the FNOL payload: policy number, loss date, loss type and a description. It validates the policy exists and the loss date falls inside the coverage period, inserts the claim with status submitted and returns the claim number immediately.
  2. Documents: A private S3 bucket with Block Public Access on holds photos, police reports and estimates. The API issues presigned upload URLs so files go straight from the claimant's phone to S3, and stores only the object key on the claim.
  3. Triage: The intake handler sends a message to SQS, and a worker scores the claim with your rules (loss amount, coverage type, prior claims, time since policy start) and routes it to fast-track or to an adjuster queue. Keeping this off the request path means a slow rule never blocks a policyholder from reporting a loss.
  4. History: Every status change writes a row to an append-only claim_events table inside the same transaction, and the database user for the API has no UPDATE or DELETE grant on that table.
  5. Escalation: An EventBridge Scheduler schedule runs a task every hour that finds claims waiting past their service level and reassigns or flags them.

Each piece needs its IAM role scoped to one bucket, one queue and one database, plus the VPC, security groups, dead-letter queue and alarms, and the same set again for staging.

How do you build it with Encore?

Encore

Encore combines an open source infrastructure SDK with a platform that provisions and deploys into your own AWS or GCP account. The claims database, the document bucket, the triage topic and the escalation schedule are declared in the TypeScript service that uses them, and deploying it creates RDS, a private S3 bucket, SNS and SQS with a dead-letter queue and an IAM role for the service that can reach only those resources, as the AWS infrastructure docs list.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create claims-app --example=ts/hello-world$ cd claims-app && mkdir -p claims/migrations
-- claims/migrations/1_create_claims.up.sql CREATE TABLE claims ( id BIGSERIAL PRIMARY KEY, claimant_uid TEXT NOT NULL, policy_number TEXT NOT NULL, loss_date DATE NOT NULL, loss_type TEXT NOT NULL, description TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'submitted', due_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE claim_events ( id BIGSERIAL PRIMARY KEY, claim_id BIGINT NOT NULL REFERENCES claims(id), from_status TEXT, to_status TEXT NOT NULL, actor TEXT NOT NULL, reason TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE claim_documents ( claim_id BIGINT NOT NULL REFERENCES claims(id), object_key TEXT NOT NULL PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );

The intake endpoint requires a bearer token, stores the claim against that claimant, writes the first history row in one transaction, publishes it for triage and returns. A second authenticated endpoint issues a signed upload URL only after checking that the claim belongs to the caller, and a confirm step accepts the file only when size and content type are within limits:

// auth/auth.ts import { APIError, Gateway } from "encore.dev/api"; import { authHandler, Header } from "encore.dev/auth"; import { secret } from "encore.dev/config"; import { createHmac, timingSafeEqual } from "node:crypto"; const authTokenHMAC = secret("AuthTokenHMAC"); interface AuthParams { authorization: Header<"Authorization">; } export interface AuthData { userID: string; } export const handler = authHandler<AuthParams, AuthData>(async (params) => { const token = params.authorization?.replace("Bearer ", "") ?? ""; // Tokens are "<id>.<unix-expiry>.<hex hmac>". In production, verify a JWT // against your IdP JWKS (signature, issuer, audience, expiry) and return the subject. if (!authTokenHMAC()) throw APIError.unauthenticated("invalid token"); const parts = token.split("."); if (parts.length !== 3) throw APIError.unauthenticated("invalid token"); const [userID, expStr, sig] = parts; const exp = Number(expStr); if (!userID || userID.includes("/") || userID.includes(" ") || !Number.isFinite(exp) || exp * 1000 <= Date.now()) { throw APIError.unauthenticated("invalid token"); } const expected = createHmac("sha256", authTokenHMAC()).update(`${userID}.${expStr}`).digest("hex"); const a = Buffer.from(sig); const b = Buffer.from(expected); if (a.length !== b.length || !timingSafeEqual(a, b)) { throw APIError.unauthenticated("invalid token"); } return { userID }; }); export const gateway = new Gateway({ authHandler: handler });
// claims/intake.ts import { api, APIError } from "encore.dev/api"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import { Bucket } from "encore.dev/storage/objects"; import { Topic } from "encore.dev/pubsub"; import { getAuthData } from "~encore/auth"; import { randomUUID } from "node:crypto"; export const db = new SQLDatabase("claims", { migrations: "./migrations" }); // Private S3 bucket on AWS, files go straight from the claimant's device. export const documents = new Bucket("claim-documents", { versioned: true }); const maxDocumentBytes = 10 * 1024 * 1024; const allowedDocumentTypes = new Set(["application/pdf", "image/jpeg", "image/png"]); export interface ClaimSubmitted { claimId: number; } export const claimSubmitted = new Topic<ClaimSubmitted>("claim-submitted", { deliveryGuarantee: "at-least-once", }); interface FnolRequest { policyNumber: string; lossDate: string; lossType: "auto" | "property" | "travel"; description: string; } async function requireClaim(claimId: number, userID: string) { const claim = await db.queryRow<{ id: number }>` SELECT id FROM claims WHERE id = ${claimId} AND claimant_uid = ${userID} `; if (!claim) throw APIError.notFound("claim not found"); return claim; } export const submitClaim = api( { expose: true, auth: true, method: "POST", path: "/claims" }, async (req: FnolRequest): Promise<{ claimId: number }> => { const { userID } = getAuthData()!; await using tx = await db.begin(); const row = await tx.queryRow<{ id: number }>` INSERT INTO claims (claimant_uid, policy_number, loss_date, loss_type, description, due_at) VALUES (${userID}, ${req.policyNumber}, ${req.lossDate}, ${req.lossType}, ${req.description}, now() + interval '2 days') RETURNING id `; await tx.exec` INSERT INTO claim_events (claim_id, from_status, to_status, actor, reason) VALUES (${row!.id}, NULL, 'submitted', 'claimant', 'FNOL received') `; await tx.commit(); await claimSubmitted.publish({ claimId: row!.id }); return { claimId: row!.id }; }, ); export const documentUploadUrl = api( { expose: true, auth: true, method: "POST", path: "/claims/:claimId/documents" }, async ({ claimId }: { claimId: number }): Promise<{ uploadUrl: string; key: string }> => { const { userID } = getAuthData()!; await requireClaim(claimId, userID); const key = `claims/${claimId}/${randomUUID()}`; const { url } = await documents.signedUploadUrl(key, { ttl: 900 }); return { uploadUrl: url, key }; }, ); export const confirmDocument = api( { expose: true, auth: true, method: "POST", path: "/claims/:claimId/documents/confirm" }, async ({ claimId, key }: { claimId: number; key: string }): Promise<{ key: string }> => { const { userID } = getAuthData()!; await requireClaim(claimId, userID); const prefix = `claims/${claimId}/`; if (!key.startsWith(prefix) || key.includes("..")) { throw APIError.notFound("document not found"); } const attrs = await documents.attrs(key); if (attrs.size > maxDocumentBytes || !allowedDocumentTypes.has(attrs.contentType ?? "")) { await documents.remove(key); throw APIError.invalidArgument("PDF, JPEG or PNG up to 10 MiB"); } await db.exec` INSERT INTO claim_documents (claim_id, object_key) VALUES (${claimId}, ${key}) ON CONFLICT (object_key) DO NOTHING `; return { key }; }, );

Triage runs from a subscription, and every status change goes through one function that checks the allowed transitions and writes the history row. An hourly cron job flags claims past their due time:

// claims/workflow.ts import { api, APIError } from "encore.dev/api"; import { Subscription } from "encore.dev/pubsub"; import { CronJob } from "encore.dev/cron"; import { db, claimSubmitted } from "./intake"; const allowed: Record<string, string[]> = { submitted: ["fast_track", "assigned"], fast_track: ["approved", "assigned"], assigned: ["approved", "denied", "escalated"], escalated: ["approved", "denied"], }; async function transition(claimId: number, to: string, actor: string, reason: string) { await using tx = await db.begin(); const claim = await tx.queryRow<{ status: string }>` SELECT status FROM claims WHERE id = ${claimId} FOR UPDATE `; if (!claim) throw APIError.notFound("claim not found"); if (claim.status === to) return; // redelivered message, already applied if (!allowed[claim.status]?.includes(to)) { throw APIError.failedPrecondition(`cannot move from ${claim.status} to ${to}`); } await tx.exec`UPDATE claims SET status = ${to} WHERE id = ${claimId}`; await tx.exec` INSERT INTO claim_events (claim_id, from_status, to_status, actor, reason) VALUES (${claimId}, ${claim.status}, ${to}, ${actor}, ${reason}) `; await tx.commit(); } const _ = new Subscription(claimSubmitted, "triage", { handler: async ({ claimId }) => { const claim = await db.queryRow<{ loss_type: string; description: string }>` SELECT loss_type, description FROM claims WHERE id = ${claimId} `; // Replace with your rules: coverage, amount, prior claims, policy age. const fastTrack = claim?.loss_type === "travel"; await transition(claimId, fastTrack ? "fast_track" : "assigned", "triage", "rules v1"); }, maxConcurrency: 5, retryPolicy: { minBackoff: "10s", maxRetries: 8 }, }); export const escalateOverdue = api({}, async (): Promise<{ escalated: number }> => { const overdue = db.query<{ id: number }>` SELECT id FROM claims WHERE status = 'assigned' AND due_at < now() `; let escalated = 0; for await (const { id } of overdue) { await transition(id, "escalated", "sla-job", "past due"); escalated++; } return { escalated }; }); const __ = new CronJob("escalate-overdue-claims", { title: "Escalate claims past their due time", every: "1h", endpoint: escalateOverdue, });

escalateOverdue has no expose flag, so it is reachable from the cron job and other services but not from the internet. The claim row and its history are written in one transaction, so the current status and the event log cannot disagree, and the FOR UPDATE lock stops an adjuster's decision and the SLA job from racing on the same claim. A redelivered triage message finds the status already applied and returns. Adjusters get documents through documents.signedDownloadUrl(key, { ttl: 600 }) behind an authenticated endpoint, which the object storage docs cover along with the bucket options.

encore run starts Postgres, the topic and a local object store, so FNOL, uploads and triage run end to end on a laptop, and the local development dashboard shows each request and the triage message in one trace. Cron jobs do not run locally or in preview environments, so escalateOverdue is tested by calling it from the dashboard. A coding agent asked to add a pending_documents status edits the transition map and writes a test for the new path in the same diff, and the review shows exactly which transitions changed.

Best fit: Insurers, MGAs and insurtech teams building claims intake and workflow on their own AWS account who want documents, triage, history and SLA jobs in one TypeScript codebase.

Things to decide early

  • Who can change status. Route every change through one function and give the API's database role no direct UPDATE on history, and the audit trail stays trustworthy.
  • Document retention. Claims files often outlive the claim by years. Set S3 lifecycle rules for the retention your legal team requires, and keep versioning on so an overwrite does not destroy evidence.
  • Personal data. Claims carry health details, addresses and bank accounts. Keep them out of logs and message payloads, and pass the claim ID through the topic as the sample does.

Frequently asked questions

What does a claims processing backend need?

An intake endpoint for first notice of loss, private document storage with signed uploads, a status model enforced in one place, an append-only history of every status change, asynchronous triage so intake stays fast, and a scheduled job that escalates claims waiting past their service level.

How do I store claim photos and documents securely on AWS?

Keep them in a private S3 bucket, let the claimant's app upload directly with a short-lived presigned URL, store only the object key on the claim, and generate short-lived download URLs for adjusters. Bucket policies block public access, and encryption at rest is on by default for new S3 objects.

How do I build a claims API in TypeScript with Encore?

Declare the claims database with SQLDatabase, a private Bucket for documents with signedUploadUrl and signedDownloadUrl, a Topic that a triage Subscription consumes after each new claim, and a CronJob that escalates overdue claims. Encore provisions RDS, S3, SNS and SQS for these in your own AWS account.

How do I keep an audit trail of claim status changes?

Write every transition to an append-only claim_events table in the same transaction as the status update, with the actor, the old and new status and a reason. The claim row holds the current state and the events table holds the history, so the two cannot disagree.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders