paybondpaybond
Sign in

TrueLayer settlement rail — implementation design

Engineer-ready design for Paybond’s first international A2A escrow rail (TrueLayer Payments API v3) — control plane, Harbor driver, webhooks, admin, Kit, security, and phased delivery.

Date: 2026-07-14
Status: Implementation design (not yet coded)
Decision: Deferred — TrueLayer/Wise deferred pending demand; Paystack chosen as next corridor. See Paystack settlement rail — implementation design.

Not legal advice — commercial counsel must review the TrueLayer partner agreement, KYB, and AML / money-transmission exposure before live merchant accounts.

1. Executive decision

Why TrueLayer

TrueLayer is the strongest Harbor fit among the researched international rails:

  • Fund → evidence → refund/release maps cleanly onto pay-in to a TrueLayer merchant account (operational escrow float), then Refund API (evidence failed / cancelled) or Payouts (evidence passed → payee).
  • Webhook-first lifecycle (payment_settled, payment_failed, payout_executed, refund_executed) reuses the gateway paybond_webhook_job outbox pattern already proven for Stripe / Adyen / Shopify.
  • Licensing stays with TrueLayer as PISP; Paybond remains the orchestrator (tenant destinations, binding, Harbor state machine).

V1 product slice (ship this first)

ConcernV1 choiceRationale
Provider surfacePayments API v3 onlySingle API for pay-in, refund, payout
GeographyUK first (GBP / Faster Payments)Simplest SCAN beneficiary model; expand EU/SEPA in phase 2
Escrow holdPay-in beneficiary = tenant’s TrueLayer merchant_account_idFunds settle into merchant account; Harbor policy holds until evidence
Funding signalPrefer payment_settled; optionally accept configured payment_creditableSettled = strongest fund gate; creditable is risk-configurable later
Terminal refundPOST /v3/payments/{payment_id}/refundsTied to original pay-in; cannot exceed paid amount
Terminal releasePOST /v3/payouts open-loop to snapshotted payee SCAN (v1)Escrow release to payee bank details captured at intent create
Auth UXTrueLayer Hosted Payment Page (HPP) / payment link redirectAvoid BYO bank-consent UI in v1
Mandates / VRPOut of scopeRecurring later
Own PISP licenseNoTrueLayer shown in bank consent UI

Explicitly deferred (do not build in v1)

  • Wise FX release leg (separate rail after TrueLayer is stable)
  • EU SEPA Instant / multi-currency merchant accounts (phase 2)
  • Closed-loop payout as the only release path (keep as optional phase-2 optimization when payee ≡ original payer)
  • Variable recurring payments / mandates
  • Paybond as licensed PISP / own branding in consent UI
  • Direct open-banking connections outside TrueLayer aggregation

2. Architecture

Reuse the settlement provider integration three-layer split. TrueLayer mirrors Adyen more than Connect: BYO tenant credentials in a destination vault, gateway webhook edge verify and enqueue, Harbor re-verify / execute money movement with tenant-scoped vault material.

sequenceDiagram
  autonumber
  actor Admin as Tenant admin
  participant GW as Gateway
  participant PG as Postgres
  participant HB as Harbor
  participant TL as TrueLayer API
  actor Buyer as Buyer bank UX
  actor App as Kit / application

  Admin->>GW: POST /v1/admin/settlement/truelayer/destination
  GW->>PG: Encrypt client_secret and signing key; store merchant_account_id

  App->>GW: POST /harbor/intents (rail=truelayer_merchant_account)
  GW->>HB: Forward create (mTLS)
  HB->>PG: Snapshot destination and payee beneficiary
  HB->>TL: POST /v3/payments (beneficiary=merchant_account)
  HB-->>App: authorization_url / HPP resource

  Buyer->>TL: Authorize payment in bank UI
  TL->>GW: Webhook payment_settled (Tl-Signature)
  GW->>GW: Verify signature, bind client_id→tenant, enqueue truelayer_settlement
  GW->>HB: POST /internal/v1/truelayer/events
  HB-->>HB: Fund gate → funded

  App->>GW: evidence and settlement/confirm
  alt evidence passed
    HB->>TL: POST /v3/payouts (open-loop SCAN)
    TL-->>GW: payout_executed / payout_settled webhooks
    HB-->>HB: released
  else evidence failed
    HB->>TL: POST /v3/payments/{id}/refunds
    TL-->>GW: refund_* webhooks
    HB-->>HB: refunded
  end

Layer ownership

LayerOwns for TrueLayerDoes not own
GatewayDestination CRUD and secret-box vault, webhook ingress and Tl-Signature verify, truelayer_settlement jobs, config rail enablement, secret purgeCreating payments / refunds / payouts
HarborPayment create and HPP URL, fund ingest, drive_terminal_settlement refund/payout drivers, recovery reconcile, intent snapshotsLong-lived dispute case rows
Postgres (Gateway)paybond_tenant_truelayer_destination, webhook outbox, settlement config versionsIntent escrow rows (Harbor sled)

Rail identifier

SurfaceValue
Settlement rail stringtruelayer_merchant_account
Rust enumSettlementRail::TruelayerMerchantAccount
Go constsettlementconfig.RailTruelayerMerchantAccount
Webhook job kindtruelayer_settlement

Naming follows adyen_*truelayer_* (destination table, packages, CLI namespace).


3. TrueLayer product contract (v1)

Official references:

Environments

SandboxLive
API basehttps://api.truelayer-sandbox.comhttps://api.truelayer.com
Auth tokenhttps://auth.truelayer-sandbox.com/connect/tokenhttps://auth.truelayer.com/connect/token
Grantclient_credentials scope paymentssame
Request signingPrivate key → Tl-Signature on outbound API callssame
Webhook verifyValidate inbound Tl-Signature with TrueLayer JWKS / signing keyssame

Note: TrueLayer webhook signatures use asymmetric keys (verify via JWKS) and TL's webhook signature scheme uses RS512 — ensure verifier accepts RS512. Webhook deliveries include Tl-Signature and a timestamp header (X-TL-Webhook-Timestamp) which must be validated for max-age to mitigate replay attacks.

Engineer access & credentials (what engineers need today)

  • To start engineering and run sandbox E2E you need:
    • TrueLayer Sandbox App credentials: client_id and client_secret (sandbox environment).
    • Sandbox merchant_account_id (sandbox merchant account under the sandbox app) for directing pay-ins.
    • Sandbox request-signing private key / PEM and optional kid to exercise Tl-Signature flows in integration tests.
    • A configured webhook URL in the TrueLayer Console pointing to Paybond's sandbox webhook endpoint (e.g. https://api.paybond.ai/webhooks/sandbox/truelayer?t=<token>).
    • Note: These are sandbox artifacts — do not reuse live credentials in development. Live credentials and merchant_account provisioning require KYB and contractual onboarding and are a production ship blocker.

Auth model (backend-only)

  1. Tenant destination stores client_id (plaintext, binding key) and encrypted client_secret and encrypted request-signing private key (or key kid and PEM).
  2. Harbor (and only Harbor for money movement) obtains short-lived bearer tokens via client credentials.
  3. Every mutating Payments API call includes Authorization: Bearer …, Idempotency-Key, and Tl-Signature.
  4. Agents / Kit never receive client_secret or signing keys.

Binding metadata (mandatory)

Every pay-in must encode Harbor correlation so webhooks can bind without trusting unauthenticated client fields alone:

FieldValue
TrueLayer metadata (or user custom fields per TL schema)paybond_intent_id, paybond_tenant_id
Payment referenceStable short reference derived from intent id (scheme length limits apply — UK Faster Payments refs are short; use compact encoding and store full UUID on intent)
Destination inventory keyTrueLayer client_id (+ environment) → tenant_id

Gateway preflight rejects events that cannot resolve client_id → active (or bounded inactive) destination tenant. Harbor fail-closes if intent tenant ≠ resolved tenant or rail ≠ truelayer_merchant_account.

Amount / currency rules

  • Intent budget currency must match destination merchant-account currency (v1: GBP only).
  • Minor units: store Harbor amounts in integer minor units; convert to TrueLayer’s decimal string form at the API boundary with explicit scale checks.
  • Terminal payout amount ≤ funded / held amount; refund amount ≤ original payment (TrueLayer-enforced; Harbor also checks).

4. Lifecycle state machine

Pay-in (funding)

TrueLayer signalHarbor actionIntent state
Payment created; HPP issuedPersist truelayer_payment_id, auth URLopen (awaiting bank auth)
payment_authorized / payment_executedOptional hydrate onlystill pre-funded
payment_failedMark funding failed; exception retryable or terminal per failure_reasonremain / close per policy
payment_settled (v1 fund gate)Fund gate; ledger fund eventfunded
payment_creditable (phase 1.1 opt-in)Same as settled only if tenant config enables creditable-as-fundfunded
payment_settlement_stalledException manual_reviewstay pre-funded / flag

Important semantic: Faster Payments settle into the merchant account — this is operational escrow, not card-style soft auth. Refunds before settle may remain pending until the pay-in settles (research caveat).

Terminal settlement

EvidenceHarbor driverTrueLayer APIWebhook finalize
passed: trueOpen-loop payoutPOST /v3/payoutspayout_executed / payout_settledreleased
passed: falseRefundPOST /v3/payments/{id}/refundsrefund_executed (confirm exact type names from webhook reference) → refunded

Async terminal rule (Adyen-analogue): HTTP 200 on refund/payout create is not Harbor-terminal. Leave intent in evidence_submitted with truelayer_pending_terminal_* until webhooks (or reconcile) confirm success/failure.

Provider id persistence (on intent / provider state)

FieldPurpose
truelayer_payment_idPay-in id
truelayer_payment_source_idClosed-loop future; store when returned
truelayer_user_idRequired for closed-loop payouts later
truelayer_refund_idTerminal refund
truelayer_payout_idTerminal release
truelayer_merchant_account_idSnapshot at create
truelayer_client_idSnapshot for reconcile vault lookup
Payee beneficiary snapshotSCAN sort_code and account_number and account_holder_name (v1)

5. SQL schema and migrations

Mirror Adyen migrations under go/gateway/sql/migrations/ (date-prefix after Adyen 20260730_*).

5.1 Destination table

CREATE TABLE paybond_tenant_truelayer_destination (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id uuid NOT NULL REFERENCES paybond_tenant (id) ON DELETE CASCADE,
    client_id text NOT NULL,
    merchant_account_id text NOT NULL,
    kid text, -- signing key id shown in TrueLayer Console (optional display)
    client_secret_encrypted text,
    signing_key_encrypted text, -- PKCS#8 PEM or platform key blob
    webhook_uri_token text NOT NULL, -- unguessable path/query token for URI binding
    environment text NOT NULL CHECK (environment IN ('sandbox', 'live')),
    status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
    deactivated_at timestamptz,
    secrets_purged_at timestamptz,
    is_active boolean GENERATED ALWAYS AS (status = 'active') STORED,
    label text NOT NULL DEFAULT '',
    currency text NOT NULL DEFAULT 'GBP'
        CHECK (currency ~ '^[A-Z]{3}$'),
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now(),
    UNIQUE (tenant_id, client_id, environment)
);

CREATE UNIQUE INDEX paybond_tenant_truelayer_destination_active_uidx
    ON paybond_tenant_truelayer_destination (tenant_id)
    WHERE status = 'active';

-- RLS enable like Adyen
ALTER TABLE paybond_tenant_truelayer_destination ENABLE ROW LEVEL SECURITY;

Constraints to port from Adyen:

  • Active rows require non-null ciphertext for client_secret and signing_key.
  • Deactivation sets deactivated_at; secret purge nulls ciphertext after retention and no pending jobs.
  • Sandbox tenants may only store sandbox destinations; live requires paid plan gate (same as Adyen).

5.2 Settlement config

Extend check constraints / allow-lists that currently include adyen_manual_capture to also allow truelayer_merchant_account on:

  • paybond_tenant_settlement_config rail arrays
  • Any related CHECK / readiness columns

Add optional config columns (or JSON fields) on settlement config for:

  • truelayer_fund_on = settled | creditable (default settled)
  • truelayer_release_mode = open_loop_payout (v1 only)

5.3 Webhook job kind

-- extend paybond_webhook_job.job_kind CHECK to include 'truelayer_settlement'

Idempotency key suggestion:

truelayer:{environment}:{event_id}

Fallback if event_id missing (should not happen): truelayer:{environment}:{type}:{payment_or_payout_or_refund_id}:{status} — prefer official event_id so retries collapse.

Note: TrueLayer requires Idempotency-Key for many Payments API requests and idempotency keys are valid for 30 days. Use UUIDv4 values, include the idempotency key in signed JOSE headers where required, and honor the Tl-Should-Retry header in responses to determine safe retries. On 429 (rate limit) responses, apply exponential backoff and respect Retry-After when provided. (See: https://docs.truelayer.com/docs/sign-your-payments-requests and https://docs.truelayer.com/docs/payments-api-errors)

5.4 Secret purge worker

Port adyensecretpurgetruelayersecretpurge:

  • Default purge after 2160h inactive
  • Only when no truelayer_settlement jobs are pending/processing for that destination
  • Set secrets_purged_at; retain audit row

Inactive webhook-signing / client overlap retention: TrueLayer uses platform-signed webhooks (JWKS) rather than per-tenant HMAC secrets. The inactive retention window therefore primarily protects the client_id → tenant binding to allow late-arriving webhooks to be attributed (default 72h). This is not an HMAC ciphertext overlap retention concern in the Adyen sense — however, keep encrypted client_secret / signing_key ciphertext available during the overlap window if late reconcile or recovery requires calling TrueLayer APIs for intents funded under that destination. Purge only after retention and no pending jobs; record purge time in secrets_purged_at and keep an audit row.


6. Gateway implementation

6.1 Packages (mirror Adyen)

Package / fileRole
go/gateway/internal/truelayerwebhook/Signature verify, max-age, binding, process
go/gateway/internal/truelayersecretpurge/Inactive secret purge worker
go/gateway/internal/truelayermap/Inventory mapping (mirror adyenmap) - client_id → tenant lookup helpers
go/gateway/internal/db/settlement_truelayer_destination_custom.goVault CRUD
go/gateway/internal/db/settlement_truelayer_inventory_custom.goclient_id → tenant inventory
go/gateway/internal/httpserver/settlement_truelayer_destination.goAdmin HTTP handlers
go/gateway/internal/httpserver/truelayer_destination_secret_purge_worker.goWire purge
go/gateway/internal/httpserver/webhook_job_processor.goBranch truelayer_settlement → Harbor forward
go/gateway/internal/settlementconfig/config.goRail const and readiness
go/gateway/internal/webhookjob/enqueue.goKind and idempotency

6.2 Admin HTTP APIs

MethodPathAuthzNotes
POST/v1/admin/settlement/truelayer/destinationTenant admin writeUpsert; write-only secrets (blank keeps ciphertext)
GET/v1/admin/settlement/truelayer/destinationTenant admin / read rolesRedacted: never return secret/signing PEM
POST/v1/admin/settlement/truelayer/destination/disconnectTenant admin writeDeactivate active destination

Upsert body (sketch):

{
  "client_id": "sandbox-...",
  "client_secret": "…",          // write-only
  "signing_key_pem": "-----BEGIN…", // write-only
  "kid": "…",
  "merchant_account_id": "…",
  "environment": "sandbox",
  "currency": "GBP",
  "label": "UK escrow MCA"
}

Response returns webhook_url with embedded webhook_uri_token for Console configuration:

https://api.paybond.ai/webhooks/{live|sandbox}/truelayer?t=<webhook_uri_token>

TrueLayer Console allows a single webhook URI per app — the unguessable token and server-side client_id inventory binding mitigate cross-tenant confusion if URI leaks.

6.3 Public webhook ingress

PathEnvironment
POST /webhooks/truelayerCompatibility alias
POST /webhooks/live/truelayerLive
POST /webhooks/sandbox/truelayerSandbox

Processing order (ship blockers):

  1. Read raw body; verify Tl-Signature (TrueLayer webhook validation — verify a detached JWS using RS512 and the public keys fetched from TrueLayer's JWKS). Use the official JWKS endpoints:
    • Live: https://webhooks.truelayer.com/.well-known/jwks
    • Sandbox: https://webhooks.truelayer-sandbox.com/.well-known/jwks Fail closed on signature verification. Prefer TrueLayer's signing libraries where available, or use a JWS library that supports detached payload verification and RS512. Also validate kid/jku and check that the iat / X-TL-Webhook-Timestamp are within an acceptable max-age window to mitigate replay. (See: https://docs.truelayer.com/docs/configure-webhooks-for-your-integration)
  2. Resolve destination via query token and payload client_id (both must agree).
  3. Max-age guard (port Adyen 24h policy using X-TL-Webhook-Timestamp header).
  4. Require binding metadata for payment/refund/payout events that mutate Harbor money state; unbound → observation / manual review only (never auto-fund).
  5. Enqueue one truelayer_settlement job; return 2xx quickly (TrueLayer retries up to ~72h on non-2xx).
    • TrueLayer delivers single-item webhooks (one payment/payout/refund per event). Unlike Adyen's multi-item RunInTx splits, TrueLayer rarely requires multi-item transactional splits. Prefer the durable outbox-before-2xx pattern (enqueue persistent job and ack quickly) to preserve idempotency and allow Harbor to perform durable, tenant-scoped converge work asynchronously.

6.4 Harbor forward

POST {harbor}/internal/v1/truelayer/events

{
  "tenant": "<uuid>",
  "environment": "sandbox",
  "event": { /* original webhook JSON */ }
}

Never forward client_secret, signing PEM, or decrypted vault material. Harbor loads credentials from control-plane DB by tenant and client_id snapshot.

6.5 Config validation / readiness

Enable truelayer_merchant_account in allowed rails only when:

  • Active destination exists for tenant environment
  • client_secret and signing key present (not purged)
  • merchant_account_id present
  • Currency supported
  • Live: paid plan gate (parity with Adyen)

6.6 Env vars / deploy

VariableServicePurpose
PAYBOND_CONTROL_PLANE_SECRET_ENCRYPTION_KEYGW and HarborDestination secret-box (shared with Adyen/SSO vault)
PAYBOND_TRUELAYER_INACTIVE_BINDING_RETENTION_HOURSGatewayLate webhook client_id binding (default 72)
PAYBOND_TRUELAYER_DESTINATION_SECRET_PURGE_AFTERGatewayDefault 2160h
PAYBOND_HARBOR_INTERNAL_WEBHOOK_FORWARD_SECRETGW and HarborExisting forward bearer
PAYBOND_HARBOR_RECOVERY_TRUELAYER_PENDINGHarborDefault on — pending terminal reconcile
PAYBOND_HARBOR_TRUELAYER_PENDING_STALE_SECSHarborDefault 900

Touch deploy/gateway/fly.toml, deploy/admin/fly.toml as needed for docs/env examples; apps/admin/.env.example.

Note: ensure the gateway main.go (or httpserver start wiring) registers and starts the truelayersecretpurge worker similarly to the Adyen purge worker so purge scheduling and graceful shutdown are wired at process start.


7. Harbor / payments crate implementation

7.1 Modules

PathRole
crates/paybond-payments/src/truelayer.rsHTTP client: token, sign, create payment, get payment, refund, payout, get payout
crates/paybond-payments/src/truelayer_sign.rsOutbound Tl-Signature helper
crates/harbor-intent-escrow/src/truelayer_sync.rsWebhook ingest and fund/terminal converge
crates/harbor-intent-escrow/src/internal_truelayer.rsAxum /internal/v1/truelayer/*
crates/harbor-intent-escrow/src/settlement.rsSnapshot and drive_terminal_settlement branch
crates/paybond-types/src/model.rsAdd TruelayerMerchantAccount

7.2 Client sketch

/// Tenant-scoped TrueLayer Payments v3 client.
pub struct TruelayerClient { /* base_url, token cache, signer */ }

impl TruelayerClient {
    pub async fn create_payment(&self, req: CreatePaymentRequest) -> Result<CreatePaymentResponse, TruelayerError>;
    pub async fn get_payment(&self, payment_id: &str) -> Result<Payment, TruelayerError>;
    pub async fn create_refund(&self, payment_id: &str, req: CreateRefundRequest) -> Result<Refund, TruelayerError>;
    pub async fn create_payout(&self, req: CreatePayoutRequest) -> Result<Payout, TruelayerError>;
    pub async fn get_payout(&self, payout_id: &str) -> Result<Payout, TruelayerError>;
}

Idempotency keys:

OperationKey
Create paymentpaybond:tl:pay:{intent_id}
Refundpaybond:tl:refund:{intent_id}:{attempt}
Payoutpaybond:tl:payout:{intent_id}:{attempt}

7.4 Intent create / fund UX

On intent create with rail truelayer_merchant_account:

  1. Authenticate caller tenant; load active destination for environment.
  2. Snapshot merchant account, client_id, currency, payee beneficiary (from settlement config or intent request — server-validated, never trust raw unauthenticated bank details without schema and ownership checks).
  3. create_payment with beneficiary type=merchant_account.
  4. Return capability / funding resource including authorization URL (HPP). Kit/UI redirects buyer.
  5. Do not mark funded until webhook/reconcile fund gate.

Optional POST /harbor/intents/{id}/fund for re-fetching authorization resource if HPP expired (no double payment: reuse same payment id or create only when previous failed terminal).

7.5 Internal endpoints

MethodPathBodyPurpose
POST/internal/v1/truelayer/events{ tenant, environment, event }Webhook ingest
POST/internal/v1/truelayer/reconcile{ tenant, intent_id, … }GET payment/payout/refund; synthesize converge; reject plaintext secrets

Harbor re-checks tenant on every mutation; reject rail mismatch; exclude request bodies from debug logs (port Adyen http_trace denylist). Note: Internal request structs (e.g. internal_truelayer request types) MUST use #[serde(deny_unknown_fields)] on their deserializable types so that any unknown or smuggled fields (including secrets or unexpected tenant identifiers) are rejected at the boundary.

7.6 Tenant isolation invariants

On every Harbor mutation for this rail:

  1. Authenticated automation context carries tenant (mTLS SAN / forward bearer) — never accept tenant solely from webhook JSON.
  2. Intent load is tenant-scoped; abort on miss (no cross-tenant existence oracle beyond existing patterns).
  3. Destination vault decrypt only for that tenant and matching client_id / merchant account snapshot.
  4. Payout beneficiary must equal create-time snapshot (ignore any later client-supplied bank details).
  5. Amount/currency must match funded hold.

Severity-zero: wrong-tenant payout or refund.

7.7 Recovery

Port Adyen pending-mod recovery:

  • If evidence_submitted with stale truelayer_pending_terminal_payout|refund:
    • GET payout/refund/payment
    • Converge to released / refunded / exception
    • Emit level=AUDIT structured event for trusted synthetic ingest (parity with Adyen SEC-006)

8. Admin console and guides

Console

Extend Configuration → Settlement (same manager as Adyen):

  • TrueLayer destination form: client_id, merchant_account_id, environment, currency, label, secret and signing key upload
  • Show webhook URL and copy button
  • Disconnect / rotate secrets (write-only blanks)
  • Readiness chip for truelayer_merchant_account

Files (parity):

  • apps/admin/app/console/configuration/settlement/*
  • apps/admin/lib/settlement-config-manager.ts / settlement-config-wire.ts
  • apps/admin/app/api/settlement/truelayer/destination/route.ts (+ disconnect)
  • Tests under apps/admin/tests/lib/settlement-config-*.test.ts

Guides / docs

DocAction
apps/admin/guides/configure-truelayer-settlement.mdxNew — Console and TrueLayer Console KYB steps
apps/admin/guides/configure-settlement-rails.mdxLink new guide
apps/admin/guides/settlement-with-payment-providers.mdxAdd TrueLayer row
docs/platform/settlement-provider-integration.mdAdd per-rail section when coding lands
docs/security/truelayer-hardening.mdNew hardening checklist (Adyen analogue)
docs/api/gateway-openapi.yaml / harbor-openapi.yamlNew paths
docs/api/gateway.md / harbor.mdNarrative

9. Kit CLI and SDK

After control plane and Harbor driver are stable:

CommandBehavior
paybond truelayer readyReadiness from settlement config (no secret upsert)
paybond truelayer doctorEnv and destination and webhook health heuristics
paybond truelayer status --intent <id>Show stored payment/payout/refund ids and Harbor state

Parity touch list:

  • kit/ts/src/cli/commands/truelayer.ts
  • kit/python/src/paybond_kit/cli/truelayer.py
  • kit/cli-parity/commands.json, contract.json, docs-fragment.md
  • Router / help_text / command_spec both languages

No secret upsert via CLI in v1 (Console / admin API only) — same posture as Adyen.


10. Security threat model (appendix)

Severity-zero / ship blockers

RiskControl (required before production tenants)
Cross-tenant webhook → wrong Harbor fundInventory bind client_id+env+webhook token; Harbor re-check intent tenant; cross-tenant integration tests
Webhook forgeryFail-closed Tl-Signature verify at gateway; never process unsigned bodies
ReplayDurable outbox idempotency on event_id and max-age window; retain keys beyond max-age
Secret exfiltrationSecret-box encrypt; redacted GET; purge after inactive retention; no secrets in Harbor forward body; log denylist
Wrong payee payoutSnapshot beneficiary at create; terminal driver uses snapshot only
Amount / currency mismatchStrict equality checks before refund/payout
SSRF via configurable API baseFixed allow-list of TrueLayer hosts only (api.truelayer.com / api.truelayer-sandbox.com); enforce allow-list in HTTP client config and integration tests; do not accept tenant-supplied base URLs.
Consent / redirect CSRFHPP return URLs include signed state bound to intent+tenant; reject mismatched state
Operator vs tenant RBACDestination upsert requires tenant-admin; platform operators cannot read plaintext secrets

High (must schedule before GA)

  • Signing key rotation runbook (re-upsert PEM; overlap tokens)
  • Merchant-account balance monitoring (balance_notification) → ops alerts
  • Chargeback / recall / recall-like schemes: UK Faster Payments recalls → map to dispute freeze observation (confirm TL event coverage)
  • /attack-chains pass scoped to TrueLayer destination and webhook and payout

Post-v1

  • Creditable-as-fund risk tuning
  • Closed-loop payout path
  • EU multi-MA / multi-currency
  • Own PISP branding

11. Exception taxonomy extensions

Add codes under existing categories (see settlement provider integration):

CodeCategoryTrigger
truelayer_payment_failedretryable / terminal by reasonPay-in failed
truelayer_settlement_stalledmanual_reviewpayment_settlement_stalled
truelayer_pending_payout_staleretryableStale pending payout
truelayer_pending_refund_staleretryableStale pending refund
truelayer_payout_failedmanual_reviewPayout failed after evidence pass
truelayer_refund_failedmanual_reviewRefund failed
truelayer_beneficiary_mismatchmanual_reviewReconcile shows beneficiary ≠ snapshot

12. Test matrix

Gateway

  • Unit: signature verify (valid / invalid / truncated), max-age, binding token mismatch
  • Integration: destination upsert RBAC, cross-tenant isolation, disconnect and inactive binding window
  • Webhook: replay same event_id → single job; sandbox path refuses live destination material
  • Purge: secrets nulled only when inactive and aged and no pending jobs
  • Processor: forward body contains no secrets
  • Integration test filenames to add:
    • settlement_truelayer_destination_integration_test.go
    • truelayer_webhook_integration_test.go
    • truelayer_signature_test.go
    • truelayer_secret_purge_integration_test.go
    • settlement_truelayer_inventory_test.go (inventory / client_id lookup)

Harbor

  • Create payment metadata binding
  • Fund only on settled (default)
  • Terminal payout/refund async finalize
  • Cross-tenant event reject
  • Reconcile rejects secret fields
  • Recovery tick for stale pending terminal
  • Body log exclusion for internal TrueLayer paths

Admin / Kit

  • Wire types and form validation
  • CLI ready / doctor without secret write

E2E (sandbox)

  1. Connect sandbox destination
  2. Create intent → HPP mock bank → settled webhook → funded
  3. Evidence pass → payout → released
  4. Evidence fail → refund → refunded

13. Phased delivery

Phase 0 — Design / commercial (production-ship blockers)

Phase 0 collects design and commercial prerequisites. IMPORTANT: a complete, shippable TrueLayer rail requires both Sandbox AND Live merchant accounts. Live KYB (Know-Your-Business) and contractual commercial terms are production ship blockers and must be completed before enabling live tenant destinations or declaring the rail "shipped".

  • Choose TrueLayer
  • Provision TrueLayer Sandbox app and sandbox merchant account (prerequisite to start coding and sandbox E2E)
  • Start Legal / Finance TrueLayer go‑live process (KYB and commercial terms) — PRODUCTION SHIP BLOCKER
  • Confirm webhook event type names for refund/payout finalize
  • Legal review of escrow float in merchant account (coordinate with KYB)

Callout: Need sandbox and live → KYB on critical path. Start Legal/Finance TrueLayer go‑live in parallel with Phase 1 sandbox implementation so that engineering work and commercial onboarding progress concurrently; do not treat sandbox as sufficient for production readiness.

Phase 1 — MVP (UK GBP)

  1. Migrations and destination vault and settlementconfig rail
  2. Webhook ingress and job kind and Harbor events ingest (fund only)
  3. Harbor create payment and HPP return
  4. Terminal refund and payout drivers and pending recovery
  5. Admin console and configure guide
  6. Hardening checklist doc and cross-tenant tests
  7. Kit CLI ready/doctor

Exit criteria: sandbox E2E fund → evidence → release/refund; security ship-blockers closed.

Phase 2 — EU

  • SEPA Instant / IBAN beneficiaries
  • Multi-currency merchant accounts
  • Creditable-as-fund option

Phase 3 — Optimizations

  • Closed-loop payout when payee ≡ payer
  • VRP / mandates for agent spend loops
  • Wise Platform as release complement (not replacement)

14. File path checklist (implementation inventory)

Gateway

  • sql/migrations/20XXXXXX_truelayer_merchant_account_settlement_rail.sql
  • sql/migrations/20XXXXXX_truelayer_webhook_job_kind.sql
  • sql/migrations/20XXXXXX_truelayer_inactive_binding_retention.sql
  • sql/migrations/20XXXXXX_truelayer_destination_secret_purge.sql
  • internal/truelayerwebhook/*
  • internal/truelayersecretpurge/*
  • internal/httpserver/settlement_truelayer_destination*.go
  • internal/httpserver/settlement_truelayer_destination_integration_test.go
  • internal/db/settlement_truelayer_*.go
  • settlementconfig and webhookjob and router.go
  • internal/truelayermap/* (mirror adyenmap client_id→tenant helpers)
  • internal/httpserver/truelayer_destination_secret_purge_worker.go wired from main.go
  • webhook metadata keys documented and asserted at API boundary (paybond_intent_id, paybond_tenant_id)

Harbor / payments

  • crates/paybond-payments/src/truelayer*.rs and tests
  • crates/paybond-payments/tests/truelayer_v3_test.rs
  • crates/harbor-intent-escrow/src/truelayer_sync.rs
  • crates/harbor-intent-escrow/src/internal_truelayer.rs
  • settlement.rs / recovery.rs / lib.rs wiring
  • crates/paybond-types rail enum
  • Ensure internal_truelayer request types use #[serde(deny_unknown_fields)]
  • Code parity: RunInTx usage reviewed (single-event default) and durable outbox-before-2xx patterns exercised

Admin / Kit / docs

  • Settlement manager UI and API routes and tests
  • apps/admin/app/api/settlement/truelayer/destination/route.ts (+ disconnect)
  • apps/admin/tests/app/console/configuration/settlement/settlement-config-manager.test.ts
  • configure-truelayer-settlement.mdx
  • docs/security/truelayer-hardening.md
  • OpenAPI and kit CLI parity
  • Admin wire types and public-docs-boundary.test.ts for new guide (validate exposed shapes)
  • Kit: enforce TRUELAYER_ARGV_BLOCKED_FLAGS (SEC-011) for CLI parity and blocked runtime flags
  • Add env-var rows to docs/operations/env-var-security-classification.md and enforce via CI checks

15. Decisions log (2026-07-14) — resolved items & remaining commercial blockers

All technical open questions have been resolved with concrete engineering decisions below. Where TrueLayer documentation is the authoritative source, a citation is listed. Items that genuinely require commercial or legal review are marked as Commercial blockers with a recommended interim engineering assumption.

Resolved engineering decisions

  1. Webhook event names (resolved)

    • Decision: Use TrueLayer Payments v3 canonical webhook types for state transitions: payment_authorized, payment_executed, payment_failed, payment_settled, payment_creditable, payment_settlement_stalled, payout_executed, payout_failed, refund_executed, refund_failed. Map payment_settled as the v1 fund gate by default. (Source: TrueLayer Payments webhook reference: https://docs.truelayer.com/docs/payments-api-webhook-reference)
  2. Webhook signature verification (resolved)

  3. Merchant account ownership / multi-tenant model (resolved)

    • Decision: Follow Paybond’s multi-tenant isolation rule — BYO per-tenant TrueLayer App and Merchant Account (Adyen parity). Each tenant stores its client_id, merchant_account_id, and signing key in the destination vault. This minimizes cross-tenant exposure and matches existing Adyen design choices. (TrueLayer merchant accounts overview: https://docs.truelayer.com/docs/merchant-accounts)
    • Rationale: TrueLayer supports merchant accounts per app and exposes merchant_account_id for directing pay-ins/payouts. BYO per-tenant reduces legal/financial coupling and keeps tenant-level KYB/sweeps independent.
  4. Payee beneficiary capture UX (resolved)

    • Decision: Default payee beneficiary stored on the tenant settlement-config (snapshot at intent create). Allow an explicit, server-validated per-intent beneficiary override (only when tenant config permits). Always snapshot beneficiary SCAN/IBAN and account_holder_name at intent creation and use snapshot for terminal payouts. (See payouts docs: https://docs.truelayer.com/docs/make-a-payout-to-an-external-account)
    • Rationale: Snapshotting prevents post-authorization tampering and enforces tenant isolation; per-intent override provides necessary flexibility for exceptional flows.
  5. HPP return URL hosting (resolved)

    • Decision: Use Paybond-hosted HPP return endpoints (admin/gateway public surfaces) for v1 success/failure pages with signed state bound to intent_id and tenant_id. Tenant-provided return URLs are Phase 2 (allow-list / register in TrueLayer Console) only. TrueLayer requires return URIs to be registered in the app settings; the hosted_page return_uri must match a Console-registered redirect URI. (See: https://docs.truelayer.com/docs/hosted-payment-page and https://docs.truelayer.com/docs/application-settings)
    • Implementation notes: include a signed (HMAC or asymmetric) opaque state token referencing intent+tenant and reject mismatches to mitigate CSRF/consent replay.
  6. Faster Payments recall / reclaim mapping (resolved)

    • Decision: Treat Faster Payments recall/reclaim as an operational/financial event mapped to manual_review / dispute-freeze flows in Harbor. Where possible, use refunds or payout failed/returned webhooks to detect a returned/reclaimed outcome and trigger recovery. TrueLayer/UK Faster Payments does not provide an automatic “recall” API; refunds and returned payouts are the supported mechanisms. (See TrueLayer refund/payout returns guidance: https://docs.truelayer.com/docs/payout-and-refund-returns and TrueLayer support notes on reconciliation)
    • Rationale: Faster Payments are final in most cases; the integration should provide an ops runbook and surface returned payouts via payout_failed/refund_failed for manual follow-up.
  7. Rate limits, idempotency, and backoff (resolved)

    • Decision: Implement Idempotency-Key usage and backoff per TrueLayer guidance:
      • Always send Idempotency-Key (UUIDv4) for create/payment/refund/payout requests; keys are valid for 30 days.
      • Honor the Tl-Should-Retry header in responses to determine whether a retry with the same idempotency key is appropriate.
      • On 429 Rate Limit responses, apply exponential backoff and respect Retry-After if provided. Do not retry create-payment/payouts without checking Tl-Should-Retry and idempotency semantics to avoid double-payouts. (See: https://docs.truelayer.com/docs/sign-your-payments-requests and https://docs.truelayer.com/docs/payments-api-errors)

Commercial / legal blockers (require non-engineering approval)

  • Production ship blockers — TrueLayer KYB & live merchant accounts (PROD ship blocker)

    • Statement: A complete, shippable TrueLayer rail requires BOTH sandbox and live merchant accounts. Live merchant-account provisioning and TrueLayer KYB (Know-Your-Business) are on the critical path for production go‑live and are hard blockers for declaring the rail "shipped" or enabling live tenant destinations.
    • Engineering allowance: Engineering MAY provision and use TrueLayer Sandbox (sandbox app and sandbox merchant account) now for development, integration, and full sandbox E2E tests (HPP, webhooks, payouts/refunds). Sandbox work can proceed in parallel with commercial/legal workflows.
    • Access prerequisite: If Paybond does not yet have a TrueLayer sandbox app, sign up for the TrueLayer Console sandbox or request partner sandbox access — this is operationally lighter than live KYB but is a prerequisite to start coding and sandbox E2E.
    • Do NOT enable live tenant destinations or mark tenants production-ready until Legal/Finance accept KYB, fees, sweeping/reserve terms, and any negotiated limits.
    • Notes: live merchant_account creation typically requires TrueLayer partner onboarding and contractual setup (see: https://docs.truelayer.com/docs/merchant-accounts).
  • Any contractual restrictions on payout channels or merchant-account sweeping (Commercial blocker)

    • Recommendation: assume standard open-loop payouts (SCAN/IBAN) are supported for v1; surface any bank-specific restrictions discovered during KYB onboarding and block or qualify tenants otherwise.

Changelog — resolved items (brief)

  • Canonical webhook types, JWKS endpoints, and signature verification: resolved and documented with citations.
  • Merchant-account model decided: BYO per-tenant TrueLayer app and MA (Adyen parity).
  • Beneficiary capture UX decided: settlement-config default and optional per-intent override; snapshot at intent create.
  • HPP return URL hosting decided: Paybond-hosted return pages for v1; tenant-provided redirects deferred to allow-list phase.
  • Faster Payments recall handling: operational/manual-review mapping and refunds/returned-payout detection documented.
  • Idempotency & rate-limit strategy: Idempotency-Key usage, Tl-Should-Retry guidance, and backoff policy documented.

Remaining items requiring commercial/legal sign-off

  • TrueLayer KYB and live merchant-account provisioning — PRODUCTION SHIP BLOCKER (must be coordinated with Legal/Finance/Revenue Ops). Live KYB and accepted commercial terms are required before enabling live tenant destinations or declaring the rail production-ready.
  • Any negotiated limits, fees, or sweeping agreements that would materially change escrow vs platform MA posture.

References (authoritative)



Explore-agent corrective & missing items (merged)

The explore agent review surfaced a focused set of missing or corrective items that matter for parity and security. These have been merged into the checklist above; highlights:

  • Packages & wiring

    • Add go/gateway/internal/truelayermap/ (mirror adyenmap) for client_id → tenant inventory helpers.
    • Ensure truelayersecretpurge worker is registered from main.go / httpserver startup (purge wiring parity with Adyen).
  • Webhook & metadata

    • Require and assert metadata binding keys (paybond_intent_id, paybond_tenant_id) at API boundary and document them in admin guides.
    • Verified API facts preserved: JWKS endpoints (https://webhooks.truelayer.com/.well-known/jwks and sandbox), canonical webhook event names (payment_settled, payout_executed, payout_failed, refund_executed, refund_failed), Tl-Signature using RS512, and X-TL-Webhook-Timestamp max-age checks.
  • Data-retention & purge semantics

    • Clarified: inactive retention is primarily the client_id→tenant binding window (default 72h), not an HMAC-ciphertext overlap. Still keep ciphertext during overlap if late reconcile may need API calls.
  • Harbor / code hardening

    • Mark internal_truelayer request types with #[serde(deny_unknown_fields)] to reject smuggled/unknown fields (prevent secrets or tenant spoofing).
    • Prefer single-event handling (TrueLayer) — multi-item RunInTx splits are usually unnecessary; keep durable outbox-before-2xx for idempotency.
  • Tests & CI

    • Add focused integration tests: settlement_truelayer_destination_integration_test.go, truelayer_webhook_integration_test.go, truelayer_signature_test.go, truelayer_secret_purge_integration_test.go, inventory tests, and a Harbor SEC-006 parity audit test.
    • Add public-docs-boundary.test.ts for admin wire types and assert OpenAPI / docs shapes.
    • Add env-var rows to docs/operations/env-var-security-classification.md and enforce via CI.
  • Security & Ops

    • Enforce SSRF fixed allow-list for TrueLayer API hosts (no tenant-supplied base URLs); enforce in HTTP client config and integration tests.
    • Kit: add TRUELAYER_ARGV_BLOCKED_FLAGS (SEC-011) to block risky CLI/runtime flags.

These are intentionally focused (not an exhaustive path dump). See the File path checklist and Test matrix above for the specific places to implement and the new test filenames to add.