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.

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.
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.HeadObject that all of them arrived before accepting a submission.submitted.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.
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.

Run in your terminal to get started locally.
-- 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.
needs_review needs a back-office screen and an audit trail of who decided what. Store reviewer decisions as rows, not as status overwrites.customer_id accordingly.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.
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.
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.
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.