Nov 11, 202610 min read

KYC Onboarding Backend: Document Uploads, Verification and Webhooks

Design the backend behind fintech customer onboarding: collect identity documents through signed uploads, start one verification check per application, accept signed provider webhooks and keep an auditable status trail, built by hand on AWS or in Go with Encore.

Illustration for KYC Onboarding Backend: Document Uploads, Verification and Webhooks

Every fintech product that moves money starts with knowing who the customer is. From the app, onboarding is a photo of an ID, a selfie and a wait for approval, while the backend has to store sensitive documents without exposing them, start exactly one check with an outside verification provider, survive that provider being slow or down, and record a decision that arrives later through a webhook. A duplicate check costs money per call, and a lost webhook leaves a customer stuck at "pending" until someone notices.

This guide designs that backend: an application record, signed document uploads, a queued verification step with idempotency keys and a signed webhook for the result, first by hand on AWS and then in Go with Encore. The verification provider is generic, since the flow is the same across vendors. Pave Bank built a licensed bank on Encore while keeping control of its own GCP account, and Echo ran its fintech Go backend on Encore in its own AWS account.

The onboarding backend at a glance

JobHand-built on AWSWith Encore in your AWS account
Application statusRDS for PostgreSQLEncore database on RDS
Identity documentsPrivate S3 bucket and presigned URLsEncore bucket with signed URLs
Start one check per applicationSQS queue and a workerPub/Sub topic and subscription (SNS and SQS)
Provider API key and webhook secretSecrets Manager plus IAM and SDK codeEncore secrets, stored in Secrets Manager
Provider decisionAPI Gateway or ALB route to a handlerRaw endpoint with signature check
Local developmentA dev account or LocalStackencore run

How do you build it by hand on AWS?

  1. Application record: a Postgres table holds one application per customer with a status (collecting, submitted, approved, rejected, needs_review), the provider's check ID and the decision reason. Status changes are conditional updates, so the table is also the lock that stops double submission.
  2. Documents: a private S3 bucket with public access blocked and encryption on. The API returns presigned PUT URLs for each document, valid for minutes, and checks with HeadObject that all of them arrived before accepting a submission.
  3. Verification: submission sends one SQS message, and a worker turns the stored documents into short-lived download links for the provider and creates the check with the application ID as idempotency key. A dead-letter queue catches applications the provider keeps rejecting.
  4. Decision: the provider calls a webhook route whose handler verifies the HMAC signature over the raw body and updates the application only if it is still submitted.
  5. Secrets and access: the provider API key and webhook secret live in Secrets Manager. The API, the worker and the webhook handler each need an IAM role that can reach the bucket, queue and secrets they use, and nothing else.

That is a database, a bucket, a queue with a dead-letter queue, two secrets, a public route and three sets of IAM permissions, repeated for staging.

How do you build it with Encore?

Encore

Encore turns infrastructure declared in Go or TypeScript code into resources in your own cloud account. The applications database, the document bucket, the verification topic and the provider secrets are declared in the onboarding service, and deploying it creates RDS, S3, SNS and SQS and Secrets Manager entries in your own AWS account, or the GCP equivalents, as listed in the AWS infrastructure docs.

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 bank --example=hello-world$ cd bank && mkdir -p onboarding/migrations$ encore secret set --type dev,local VerifyAPIKey$ encore secret set --type dev,local VerifyWebhookSecret
-- onboarding/migrations/1_create_applications.up.sql CREATE TABLE applications ( id BIGSERIAL PRIMARY KEY, customer_id TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'collecting', -- collecting, submitted, approved, rejected, needs_review submitted_event_at TIMESTAMPTZ, provider_check_id TEXT UNIQUE, reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), decided_at TIMESTAMPTZ ); CREATE TABLE application_events ( application_id BIGINT PRIMARY KEY REFERENCES applications (id), customer_id TEXT NOT NULL, published_at TIMESTAMPTZ );

The first file opens an application, hands out upload URLs and accepts the submission:

// onboarding/onboarding.go package onboarding import ( "context" "fmt" "time" "encore.dev/beta/errs" "encore.dev/cron" "encore.dev/pubsub" "encore.dev/storage/objects" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("onboarding", sqldb.DatabaseConfig{Migrations: "./migrations"}) // Private bucket for identity documents. Clients upload straight to it. var Documents = objects.NewBucket("kyc-documents", objects.BucketConfig{}) type Submitted struct { ApplicationID int64 CustomerID string } var ApplicationSubmitted = pubsub.NewTopic[*Submitted]("kyc-submitted", pubsub.TopicConfig{ DeliveryGuarantee: pubsub.AtLeastOnce, }) type StartResponse struct { ApplicationID int64 UploadURLs map[string]string // document kind -> signed PUT URL } // Start opens an application and returns short-lived upload URLs for the documents. // //encore:api private method=POST path=/onboarding/customers/:customerID/start func Start(ctx context.Context, customerID string) (*StartResponse, error) { var id int64 err := db.QueryRow(ctx, ` INSERT INTO applications (customer_id) VALUES ($1) ON CONFLICT (customer_id) DO UPDATE SET customer_id = EXCLUDED.customer_id RETURNING id `, customerID).Scan(&id) if err != nil { return nil, err } urls := map[string]string{} for _, kind := range []string{"id-front", "id-back", "selfie"} { key := fmt.Sprintf("%d/%s", id, kind) u, err := Documents.SignedUploadURL(ctx, key, objects.WithTTL(15*time.Minute)) if err != nil { return nil, err } urls[kind] = u.URL } return &StartResponse{ApplicationID: id, UploadURLs: urls}, nil } const maxDocumentBytes = 10 << 20 // 10 MiB func allowedDocumentType(ct string) bool { return ct == "image/jpeg" || ct == "image/png" || ct == "application/pdf" } func acceptDocument(ctx context.Context, key, kind string) error { attrs, err := Documents.Attrs(ctx, key) if err != nil { return &errs.Error{Code: errs.FailedPrecondition, Message: kind + " not uploaded"} } if attrs.Size > maxDocumentBytes || !allowedDocumentType(attrs.ContentType) { _ = Documents.Remove(ctx, key) return &errs.Error{Code: errs.InvalidArgument, Message: kind + " must be a JPEG, PNG or PDF up to 10 MiB"} } return nil } // Submit checks that every document arrived and is a valid identity file, then // hands off to the verification worker. // //encore:api private method=POST path=/onboarding/applications/:id/submit func Submit(ctx context.Context, id int64) error { for _, kind := range []string{"id-front", "id-back", "selfie"} { if err := acceptDocument(ctx, fmt.Sprintf("%d/%s", id, kind), kind); err != nil { return err } } tx, err := db.Begin(ctx) if err != nil { return err } defer tx.Rollback() var customerID string err = tx.QueryRow(ctx, ` UPDATE applications SET status = 'submitted' WHERE id = $1 AND status IN ('collecting', 'submitted') RETURNING customer_id `, id).Scan(&customerID) if err == sqldb.ErrNoRows { return nil } else if err != nil { return err } if _, err = tx.Exec(ctx, ` INSERT INTO application_events (application_id, customer_id) VALUES ($1, $2) ON CONFLICT (application_id) DO NOTHING `, id, customerID); err != nil { return err } if err := tx.Commit(); err != nil { return err } return publishApplicationEvent(ctx, id) } var _ = cron.NewJob("recover-kyc-events", cron.JobConfig{ Title: "Publish unpublished KYC submission events", Every: 1 * cron.Hour, Endpoint: RecoverSubmittedEvents, }) // RecoverSubmittedEvents is called by the Encore platform on a schedule, // and from Submit, so a crash after the outbox write is retried even if // the client never comes back. Cron jobs do not run locally or in preview // environments; call this endpoint from the dashboard to test it. // //encore:api private func RecoverSubmittedEvents(ctx context.Context) error { return publishPendingApplicationEvents(ctx) } func publishApplicationEvent(ctx context.Context, id int64) error { var customerID string err := db.QueryRow(ctx, ` SELECT customer_id FROM application_events WHERE application_id = $1 AND published_at IS NULL `, id).Scan(&customerID) if err == sqldb.ErrNoRows { return nil } else if err != nil { return err } if _, err := ApplicationSubmitted.Publish(ctx, &Submitted{ApplicationID: id, CustomerID: customerID}); err != nil { return err } if _, err := db.Exec(ctx, ` UPDATE application_events SET published_at = now() WHERE application_id = $1 AND published_at IS NULL `, id); err != nil { return err } _, err = db.Exec(ctx, ` UPDATE applications SET submitted_event_at = now() WHERE id = $1 AND submitted_event_at IS NULL `, id) return err } func publishPendingApplicationEvents(ctx context.Context) error { rows, err := db.Query(ctx, ` SELECT application_id FROM application_events WHERE published_at IS NULL `) if err != nil { return err } defer rows.Close() for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return err } if err := publishApplicationEvent(ctx, id); err != nil { return err } } return rows.Err() }

A subscription on ApplicationSubmitted starts the provider check. It builds short-lived signed download URLs for the three documents, calls the provider with Idempotency-Key: kyc-<application id>, and stores the returned check ID. A redelivered message therefore cannot open a second check. The webhook is a raw endpoint so the HMAC can be checked against the exact bytes the provider sent:

// onboarding/verify.go package onboarding import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "encore.dev/pubsub" ) var secrets struct { VerifyAPIKey string VerifyWebhookSecret string } // startCheck (omitted here) builds signed download URLs, calls the provider // with Idempotency-Key kyc-<application id>, and stores the returned check ID. var _ = pubsub.NewSubscription(ApplicationSubmitted, "start-check", pubsub.SubscriptionConfig[*Submitted]{ Handler: startCheck, AckDeadline: 2 * time.Minute, RetryPolicy: &pubsub.RetryPolicy{MinBackoff: 30 * time.Second, MaxRetries: 10}, }) //encore:api public raw method=POST path=/webhooks/verification func VerificationResult(w http.ResponseWriter, req *http.Request) { body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20)) if err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } mac := hmac.New(sha256.New, []byte(secrets.VerifyWebhookSecret)) mac.Write(body) if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(req.Header.Get("X-Signature"))) { http.Error(w, "bad signature", http.StatusUnauthorized) return } var ev struct { CheckID string `json:"check_id"` Outcome string `json:"outcome"` // approved, rejected, needs_review Reason string `json:"reason"` } if err := json.Unmarshal(body, &ev); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } // Only the first decision counts; duplicates and late retries change nothing. if _, err = db.Exec(req.Context(), ` UPDATE applications SET status = $1, reason = $2, decided_at = now() WHERE provider_check_id = $3 AND status = 'submitted' `, ev.Outcome, ev.Reason, ev.CheckID); err != nil { http.Error(w, "retry later", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) }

Submit rejects documents that are missing, larger than 10 MiB or not a JPEG, PNG or PDF. It writes an application_events outbox row in the same transaction as the move to submitted, so a concurrent retry cannot insert a second event. Submit publishes only that application's unpublished outbox row. An hourly recovery job publishes any rows still unpublished after a crash, and startCheck stays idempotent through the provider's Idempotency-Key. A client that retries after the event is recorded finds no unpublished outbox row and gets no second event. Cron jobs do not run locally or in preview environments, and in deployed environments the recovery job is Encore platform Managed. A provider outage only delays startCheck, which retries with backoff and lands in the subscription's dead-letter queue after the last attempt, per the Pub/Sub docs. The webhook ignores decisions for applications that already have one.

The documents never pass through the API, and every link to them is signed and expires within half an hour, as described in the object storage docs. Provider keys are set per environment with encore secret set, and the values stay out of the repository, where a coding agent working in the code sees the secret names but not their values.

encore run starts Postgres locally, stores documents on disk and runs the topic, so the flow can be exercised against the provider's sandbox from the local development dashboard. Encore provisions an IAM role for the service, or a service account on GCP.

Best fit: Fintech teams onboarding customers in their own AWS or GCP account who want document storage, the verification queue and provider secrets declared in one Go service.

Things to decide early

  • Retention. FATF Recommendation 11 asks financial institutions to keep customer identification records for at least five years after the relationship ends, and many jurisdictions write a similar period into local law. That obligation stays with your team: decide where the rule lives, and add a lifecycle rule or a scheduled cleanup once the period has passed.
  • Manual review. needs_review needs a back-office screen and an audit trail of who decided what. Store reviewer decisions as rows, not as status overwrites.
  • Re-verification. Expired documents and changed details start a new application. Keep one row per attempt if you need history, and relax the unique constraint on customer_id accordingly.

Frequently asked questions

How should a fintech app upload identity documents for KYC?

Have the backend create the application, then return short-lived signed upload URLs for each document so the app uploads straight to a private bucket. The API never streams the images, nothing is publicly readable, and the backend checks that every document exists before the application can be submitted.

How do I avoid starting two KYC checks for the same customer?

Make submission a conditional status change in the database, publish one event only when that update succeeds, and send the application ID as the idempotency key when the worker calls the verification provider. A retried request or a redelivered message then returns the existing check instead of opening a new one.

How should I handle KYC provider webhooks?

Read the raw request body, verify the provider's HMAC signature over those exact bytes with a shared secret, and apply the decision with an update that only matches applications still waiting for a result. Return a 5xx on database errors so the provider retries, and a 2xx for duplicates so it stops.

How do I build a KYC onboarding backend in Go with Encore?

Declare the applications database with sqldb.NewDatabase, the document bucket with objects.NewBucket, a topic and subscription for verification with pubsub.NewTopic and pubsub.NewSubscription, the provider keys in a secrets struct and the webhook as a raw endpoint. Encore provisions the database, bucket, queue and secret storage in your own AWS or GCP account.

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