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.

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.
submitted and returns the claim number immediately.claim_events table inside the same transaction, and the database user for the API has no UPDATE or DELETE grant on that table.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.
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.

Run in your terminal to get started locally.
-- 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.
UPDATE on history, and the audit trail stays trustworthy.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.
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.
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.
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.