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 gatewaypaybond_webhook_joboutbox 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)
| Concern | V1 choice | Rationale |
|---|---|---|
| Provider surface | Payments API v3 only | Single API for pay-in, refund, payout |
| Geography | UK first (GBP / Faster Payments) | Simplest SCAN beneficiary model; expand EU/SEPA in phase 2 |
| Escrow hold | Pay-in beneficiary = tenant’s TrueLayer merchant_account_id | Funds settle into merchant account; Harbor policy holds until evidence |
| Funding signal | Prefer payment_settled; optionally accept configured payment_creditable | Settled = strongest fund gate; creditable is risk-configurable later |
| Terminal refund | POST /v3/payments/{payment_id}/refunds | Tied to original pay-in; cannot exceed paid amount |
| Terminal release | POST /v3/payouts open-loop to snapshotted payee SCAN (v1) | Escrow release to payee bank details captured at intent create |
| Auth UX | TrueLayer Hosted Payment Page (HPP) / payment link redirect | Avoid BYO bank-consent UI in v1 |
| Mandates / VRP | Out of scope | Recurring later |
| Own PISP license | No | TrueLayer 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
endLayer ownership
| Layer | Owns for TrueLayer | Does not own |
|---|---|---|
| Gateway | Destination CRUD and secret-box vault, webhook ingress and Tl-Signature verify, truelayer_settlement jobs, config rail enablement, secret purge | Creating payments / refunds / payouts |
| Harbor | Payment create and HPP URL, fund ingest, drive_terminal_settlement refund/payout drivers, recovery reconcile, intent snapshots | Long-lived dispute case rows |
| Postgres (Gateway) | paybond_tenant_truelayer_destination, webhook outbox, settlement config versions | Intent escrow rows (Harbor sled) |
Rail identifier
| Surface | Value |
|---|---|
| Settlement rail string | truelayer_merchant_account |
| Rust enum | SettlementRail::TruelayerMerchantAccount |
| Go const | settlementconfig.RailTruelayerMerchantAccount |
| Webhook job kind | truelayer_settlement |
Naming follows adyen_* → truelayer_* (destination table, packages, CLI namespace).
3. TrueLayer product contract (v1)
Official references:
- Payments API basics
- Create a payment / Create payments to merchant account
- Payout and refund basics
- Create payout
- Refund a payment
- Payment webhooks / Webhook reference
- Configure webhooks
- Test payments in sandbox
Environments
| Sandbox | Live | |
|---|---|---|
| API base | https://api.truelayer-sandbox.com | https://api.truelayer.com |
| Auth token | https://auth.truelayer-sandbox.com/connect/token | https://auth.truelayer.com/connect/token |
| Grant | client_credentials scope payments | same |
| Request signing | Private key → Tl-Signature on outbound API calls | same |
| Webhook verify | Validate inbound Tl-Signature with TrueLayer JWKS / signing keys | same |
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_idandclient_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
kidto exerciseTl-Signatureflows 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.
- TrueLayer Sandbox App credentials:
Auth model (backend-only)
- Tenant destination stores
client_id(plaintext, binding key) and encryptedclient_secretand encrypted request-signing private key (or key kid and PEM). - Harbor (and only Harbor for money movement) obtains short-lived bearer tokens via client credentials.
- Every mutating Payments API call includes
Authorization: Bearer …,Idempotency-Key, andTl-Signature. - Agents / Kit never receive
client_secretor signing keys.
Binding metadata (mandatory)
Every pay-in must encode Harbor correlation so webhooks can bind without trusting unauthenticated client fields alone:
| Field | Value |
|---|---|
TrueLayer metadata (or user custom fields per TL schema) | paybond_intent_id, paybond_tenant_id |
Payment reference | Stable 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 key | TrueLayer 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 signal | Harbor action | Intent state |
|---|---|---|
| Payment created; HPP issued | Persist truelayer_payment_id, auth URL | open (awaiting bank auth) |
payment_authorized / payment_executed | Optional hydrate only | still pre-funded |
payment_failed | Mark funding failed; exception retryable or terminal per failure_reason | remain / close per policy |
payment_settled (v1 fund gate) | Fund gate; ledger fund event | funded |
payment_creditable (phase 1.1 opt-in) | Same as settled only if tenant config enables creditable-as-fund | funded |
payment_settlement_stalled | Exception manual_review | stay 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
| Evidence | Harbor driver | TrueLayer API | Webhook finalize |
|---|---|---|---|
passed: true | Open-loop payout | POST /v3/payouts | payout_executed / payout_settled → released |
passed: false | Refund | POST /v3/payments/{id}/refunds | refund_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)
| Field | Purpose |
|---|---|
truelayer_payment_id | Pay-in id |
truelayer_payment_source_id | Closed-loop future; store when returned |
truelayer_user_id | Required for closed-loop payouts later |
truelayer_refund_id | Terminal refund |
truelayer_payout_id | Terminal release |
truelayer_merchant_account_id | Snapshot at create |
truelayer_client_id | Snapshot for reconcile vault lookup |
| Payee beneficiary snapshot | SCAN 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_secretandsigning_key. - Deactivation sets
deactivated_at; secret purge nulls ciphertext after retention and no pending jobs. - Sandbox tenants may only store
sandboxdestinations; 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_configrail arrays- Any related CHECK / readiness columns
Add optional config columns (or JSON fields) on settlement config for:
truelayer_fund_on=settled|creditable(defaultsettled)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 adyensecretpurge → truelayersecretpurge:
- Default purge after 2160h inactive
- Only when no
truelayer_settlementjobs arepending/processingfor 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 / file | Role |
|---|---|
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.go | Vault CRUD |
go/gateway/internal/db/settlement_truelayer_inventory_custom.go | client_id → tenant inventory |
go/gateway/internal/httpserver/settlement_truelayer_destination.go | Admin HTTP handlers |
go/gateway/internal/httpserver/truelayer_destination_secret_purge_worker.go | Wire purge |
go/gateway/internal/httpserver/webhook_job_processor.go | Branch truelayer_settlement → Harbor forward |
go/gateway/internal/settlementconfig/config.go | Rail const and readiness |
go/gateway/internal/webhookjob/enqueue.go | Kind and idempotency |
6.2 Admin HTTP APIs
| Method | Path | Authz | Notes |
|---|---|---|---|
POST | /v1/admin/settlement/truelayer/destination | Tenant admin write | Upsert; write-only secrets (blank keeps ciphertext) |
GET | /v1/admin/settlement/truelayer/destination | Tenant admin / read roles | Redacted: never return secret/signing PEM |
POST | /v1/admin/settlement/truelayer/destination/disconnect | Tenant admin write | Deactivate 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
| Path | Environment |
|---|---|
POST /webhooks/truelayer | Compatibility alias |
POST /webhooks/live/truelayer | Live |
POST /webhooks/sandbox/truelayer | Sandbox |
Processing order (ship blockers):
- 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/jwksFail closed on signature verification. Prefer TrueLayer's signing libraries where available, or use a JWS library that supports detached payload verification and RS512. Also validatekid/jkuand check that theiat/X-TL-Webhook-Timestampare within an acceptable max-age window to mitigate replay. (See: https://docs.truelayer.com/docs/configure-webhooks-for-your-integration)
- Live:
- Resolve destination via query token and payload
client_id(both must agree). - Max-age guard (port Adyen 24h policy using
X-TL-Webhook-Timestampheader). - Require binding metadata for payment/refund/payout events that mutate Harbor money state; unbound → observation / manual review only (never auto-fund).
- Enqueue one
truelayer_settlementjob; 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
RunInTxsplits, 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.
- TrueLayer delivers single-item webhooks (one payment/payout/refund per event). Unlike Adyen's multi-item
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_secretand signing key present (not purged)merchant_account_idpresent- Currency supported
- Live: paid plan gate (parity with Adyen)
6.6 Env vars / deploy
| Variable | Service | Purpose |
|---|---|---|
PAYBOND_CONTROL_PLANE_SECRET_ENCRYPTION_KEY | GW and Harbor | Destination secret-box (shared with Adyen/SSO vault) |
PAYBOND_TRUELAYER_INACTIVE_BINDING_RETENTION_HOURS | Gateway | Late webhook client_id binding (default 72) |
PAYBOND_TRUELAYER_DESTINATION_SECRET_PURGE_AFTER | Gateway | Default 2160h |
PAYBOND_HARBOR_INTERNAL_WEBHOOK_FORWARD_SECRET | GW and Harbor | Existing forward bearer |
PAYBOND_HARBOR_RECOVERY_TRUELAYER_PENDING | Harbor | Default on — pending terminal reconcile |
PAYBOND_HARBOR_TRUELAYER_PENDING_STALE_SECS | Harbor | Default 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
| Path | Role |
|---|---|
crates/paybond-payments/src/truelayer.rs | HTTP client: token, sign, create payment, get payment, refund, payout, get payout |
crates/paybond-payments/src/truelayer_sign.rs | Outbound Tl-Signature helper |
crates/harbor-intent-escrow/src/truelayer_sync.rs | Webhook ingest and fund/terminal converge |
crates/harbor-intent-escrow/src/internal_truelayer.rs | Axum /internal/v1/truelayer/* |
crates/harbor-intent-escrow/src/settlement.rs | Snapshot and drive_terminal_settlement branch |
crates/paybond-types/src/model.rs | Add 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:
| Operation | Key |
|---|---|
| Create payment | paybond:tl:pay:{intent_id} |
| Refund | paybond:tl:refund:{intent_id}:{attempt} |
| Payout | paybond:tl:payout:{intent_id}:{attempt} |
7.4 Intent create / fund UX
On intent create with rail truelayer_merchant_account:
- Authenticate caller tenant; load active destination for environment.
- 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).
create_paymentwith beneficiarytype=merchant_account.- Return capability / funding resource including authorization URL (HPP). Kit/UI redirects buyer.
- Do not mark
fundeduntil 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
| Method | Path | Body | Purpose |
|---|---|---|---|
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:
- Authenticated automation context carries tenant (mTLS SAN / forward bearer) — never accept tenant solely from webhook JSON.
- Intent load is tenant-scoped; abort on miss (no cross-tenant existence oracle beyond existing patterns).
- Destination vault decrypt only for that tenant and matching
client_id/ merchant account snapshot. - Payout beneficiary must equal create-time snapshot (ignore any later client-supplied bank details).
- Amount/currency must match funded hold.
Severity-zero: wrong-tenant payout or refund.
7.7 Recovery
Port Adyen pending-mod recovery:
- If
evidence_submittedwith staletruelayer_pending_terminal_payout|refund:GETpayout/refund/payment- Converge to
released/refunded/ exception - Emit
level=AUDITstructured 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.tsapps/admin/app/api/settlement/truelayer/destination/route.ts(+ disconnect)- Tests under
apps/admin/tests/lib/settlement-config-*.test.ts
Guides / docs
| Doc | Action |
|---|---|
apps/admin/guides/configure-truelayer-settlement.mdx | New — Console and TrueLayer Console KYB steps |
apps/admin/guides/configure-settlement-rails.mdx | Link new guide |
apps/admin/guides/settlement-with-payment-providers.mdx | Add TrueLayer row |
docs/platform/settlement-provider-integration.md | Add per-rail section when coding lands |
docs/security/truelayer-hardening.md | New hardening checklist (Adyen analogue) |
docs/api/gateway-openapi.yaml / harbor-openapi.yaml | New paths |
docs/api/gateway.md / harbor.md | Narrative |
9. Kit CLI and SDK
After control plane and Harbor driver are stable:
| Command | Behavior |
|---|---|
paybond truelayer ready | Readiness from settlement config (no secret upsert) |
paybond truelayer doctor | Env 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.tskit/python/src/paybond_kit/cli/truelayer.pykit/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
| Risk | Control (required before production tenants) |
|---|---|
| Cross-tenant webhook → wrong Harbor fund | Inventory bind client_id+env+webhook token; Harbor re-check intent tenant; cross-tenant integration tests |
| Webhook forgery | Fail-closed Tl-Signature verify at gateway; never process unsigned bodies |
| Replay | Durable outbox idempotency on event_id and max-age window; retain keys beyond max-age |
| Secret exfiltration | Secret-box encrypt; redacted GET; purge after inactive retention; no secrets in Harbor forward body; log denylist |
| Wrong payee payout | Snapshot beneficiary at create; terminal driver uses snapshot only |
| Amount / currency mismatch | Strict equality checks before refund/payout |
| SSRF via configurable API base | Fixed 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 CSRF | HPP return URLs include signed state bound to intent+tenant; reject mismatched state |
| Operator vs tenant RBAC | Destination 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-chainspass 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):
| Code | Category | Trigger |
|---|---|---|
truelayer_payment_failed | retryable / terminal by reason | Pay-in failed |
truelayer_settlement_stalled | manual_review | payment_settlement_stalled |
truelayer_pending_payout_stale | retryable | Stale pending payout |
truelayer_pending_refund_stale | retryable | Stale pending refund |
truelayer_payout_failed | manual_review | Payout failed after evidence pass |
truelayer_refund_failed | manual_review | Refund failed |
truelayer_beneficiary_mismatch | manual_review | Reconcile 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.gotruelayer_webhook_integration_test.gotruelayer_signature_test.gotruelayer_secret_purge_integration_test.gosettlement_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/doctorwithout secret write
E2E (sandbox)
- Connect sandbox destination
- Create intent → HPP mock bank → settled webhook → funded
- Evidence pass → payout → released
- 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)
- Migrations and destination vault and settlementconfig rail
- Webhook ingress and job kind and Harbor events ingest (fund only)
- Harbor create payment and HPP return
- Terminal refund and payout drivers and pending recovery
- Admin console and configure guide
- Hardening checklist doc and cross-tenant tests
- 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.sqlsql/migrations/20XXXXXX_truelayer_webhook_job_kind.sqlsql/migrations/20XXXXXX_truelayer_inactive_binding_retention.sqlsql/migrations/20XXXXXX_truelayer_destination_secret_purge.sqlinternal/truelayerwebhook/*internal/truelayersecretpurge/*internal/httpserver/settlement_truelayer_destination*.gointernal/httpserver/settlement_truelayer_destination_integration_test.gointernal/db/settlement_truelayer_*.gosettlementconfigandwebhookjobandrouter.gointernal/truelayermap/*(mirror adyenmap client_id→tenant helpers)internal/httpserver/truelayer_destination_secret_purge_worker.gowired frommain.go- webhook metadata keys documented and asserted at API boundary (
paybond_intent_id,paybond_tenant_id)
Harbor / payments
crates/paybond-payments/src/truelayer*.rsand testscrates/paybond-payments/tests/truelayer_v3_test.rscrates/harbor-intent-escrow/src/truelayer_sync.rscrates/harbor-intent-escrow/src/internal_truelayer.rssettlement.rs/recovery.rs/lib.rswiringcrates/paybond-typesrail enum- Ensure
internal_truelayerrequest types use#[serde(deny_unknown_fields)] - Code parity:
RunInTxusage 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.tsconfigure-truelayer-settlement.mdxdocs/security/truelayer-hardening.md- OpenAPI and kit CLI parity
- Admin wire types and
public-docs-boundary.test.tsfor 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.mdand 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
-
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. Mappayment_settledas the v1 fund gate by default. (Source: TrueLayer Payments webhook reference: https://docs.truelayer.com/docs/payments-api-webhook-reference)
- Decision: Use TrueLayer Payments v3 canonical webhook types for state transitions:
-
Webhook signature verification (resolved)
- Decision: Gateway MUST validate incoming webhooks using TrueLayer's JWS scheme and the
Tl-Signatureheader (prefer this over legacyX-Tl-Signature). Verify signatures using keys fetched from the JWKS URI:- Live JWKS: https://webhooks.truelayer.com/.well-known/jwks
- Sandbox JWKS: https://webhooks.truelayer-sandbox.com/.well-known/jwks
- Implementation notes: expect RS512 algorithm; validate detached JWS against the raw request body, check
kid/jku, and enforce max-age usingX-TL-Webhook-Timestampto mitigate replay. Use TrueLayer signing libraries where available (they provide language bindings and examples) or a JWS library that supports RS512 detached payload verification. (See: https://docs.truelayer.com/docs/configure-webhooks-for-your-integration and https://docs.truelayer.com/docs/payment-webhooks)
- Decision: Gateway MUST validate incoming webhooks using TrueLayer's JWS scheme and the
-
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_idfor directing pay-ins/payouts. BYO per-tenant reduces legal/financial coupling and keeps tenant-level KYB/sweeps independent.
- Decision: Follow Paybond’s multi-tenant isolation rule — BYO per-tenant TrueLayer App and Merchant Account (Adyen parity). Each tenant stores its
-
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.
-
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_urimust 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.
- 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
-
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
refundsorpayoutfailed/returnedwebhooks 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_failedfor manual follow-up.
- Decision: Treat Faster Payments recall/reclaim as an operational/financial event mapped to manual_review / dispute-freeze flows in Harbor. Where possible, use
-
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-Retryheader 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-Afterif provided. Do not retry create-payment/payouts without checkingTl-Should-Retryand 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)
- Always send
- Decision: Implement Idempotency-Key usage and backoff per TrueLayer guidance:
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-Retryguidance, 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)
- TrueLayer Payments API — Payments webhooks / webhook reference: https://docs.truelayer.com/docs/payments-api-webhook-reference
- Configure webhooks / signature verification guidance: https://docs.truelayer.com/docs/configure-webhooks-for-your-integration
- JWKS (live): https://webhooks.truelayer.com/.well-known/jwks
- JWKS (sandbox): https://webhooks.truelayer-sandbox.com/.well-known/jwks
- Merchant accounts overview: https://docs.truelayer.com/docs/merchant-accounts
- Create payments / hosted_page / return_uri: https://docs.truelayer.com/docs/create-a-payment
- Payouts / open-loop SCAN/IBAN: https://docs.truelayer.com/docs/make-a-payout-to-an-external-account
- Refunds / returns: https://docs.truelayer.com/docs/refund-a-payment and https://docs.truelayer.com/docs/payout-and-refund-returns
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/(mirroradyenmap) for client_id → tenant inventory helpers. - Ensure
truelayersecretpurgeworker is registered frommain.go/ httpserver startup (purge wiring parity with Adyen).
- Add
-
Webhook & metadata
- Require and assert
metadatabinding 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/jwksand sandbox), canonical webhook event names (payment_settled,payout_executed,payout_failed,refund_executed,refund_failed),Tl-Signatureusing RS512, andX-TL-Webhook-Timestampmax-age checks.
- Require and assert
-
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.
- Clarified: inactive retention is primarily the
-
Harbor / code hardening
- Mark
internal_truelayerrequest types with#[serde(deny_unknown_fields)]to reject smuggled/unknown fields (prevent secrets or tenant spoofing). - Prefer single-event handling (TrueLayer) — multi-item
RunInTxsplits are usually unnecessary; keep durable outbox-before-2xx for idempotency.
- Mark
-
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.tsfor admin wire types and assert OpenAPI / docs shapes. - Add env-var rows to
docs/operations/env-var-security-classification.mdand enforce via CI.
- Add focused integration tests:
-
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.
16. Related
- International settlement rails research — why TrueLayer is #1
- Settlement provider integration — control-plane architecture to extend
- Adyen hardening — security parity template
- Configure Adyen settlement — UX/docs parity template
- Intent lifecycle