Alfred PSP — Integration Architecture

Created by
Nazar Sabardin (vakotrade BE)
Document Status
Draft v1.0
Last edited
May 25, 2026
Version
1.0  ·  initial release
Audience
Product · PM · Compliance · BE · QA · Support
Integration Architecture

SumSub leads KYC; Alfred runs in reliance mode under the hood.

The ANDX widget uses SumSub as the user-facing KYC vendor — same vendor as every other vakotrade KYC surface — while Alfred PSP runs as the fiat-on/off-ramp behind it. Alfred has its own KYC pipeline that we drive in reliance mode: after SumSub completes user verification, vakotrade BE forwards the identity data to Alfred via their reliance KYC API, Alfred runs AiPrise sanctions/PEP screening in the background, and the result is mirrored into our DB as a kyc-property. The user never sees Alfred's KYC UI. This document covers the decision, the bridge architecture, the open Alfred-side blockers, and the operational concerns the whole team should be aware of.

1
Primary KYC · SumSub
2
KYC pipelines bridged
10
Countries · Alfred-supported
1
Alfred-side blocker · open
Working today
Reliance KYC bridge (SumSub → Alfred) with file pipe shipped for individuals, 5 fiat rails wired (SPEI · PIX · COELSA · ACH-PSE · ACH_DOM) all green for deposit + withdrawal, hardened action-time gates, durable webhook idempotency, dev-only QA tooling.
§4.1 — §4.8
Blocked on Alfred
US BANK_USA fiat-account registration still fails on every US customer (409/111485 bridged, 422/110003 non-bridged). Cascade: no fiat account ⇒ no US offramp.
§4.11 · Open with Alfred
For QA: see the QA Test Guide — sequence diagrams, dev endpoints with live "Fire" buttons, per-country test matrix.

The two pipelines we bridge

SumSub feeds KYC into vakotrade; vakotrade forwards a reliance handoff to Alfred. Payments then flow through Alfred and back to us via signed webhooks.

KYC reliance pipeline
SumSub-led
User-facing on SumSub; Alfred is invisible.
1
User completes SumSub flow
Selfie + ID document, address, tax_id (if SumSub captures it). SumSub returns review verdict via webhook to our kyc module.
2
Platform sets kyc_status: approved
Our UpdateUserKycOperation writes the kyc-property and emits NotificationTrigger.kyc_approved on the event bus.
3
EnsureAlfredCustomerOperation fires
PspAlfredEventsProcessor listens to kyc_approved and runs the 5-stage Alfred provisioning: country gate → create customer → submit KYC text → pull photos from S3 + POST /kyc/file → finalize via /kyc/status/circle.
4
Alfred runs AiPrise check
Their internal sanctions/PEP screening runs in the background. Result is async.
5
Alfred webhooks KYC verdict back
Signed POST to /psp/alfred/webhook with eventType: KYC, status: APPROVED|REJECTED. Handler writes alfred_kyc_status into user_kyc_data. Widget unblocks/blocks based on this.
Payment lifecycle
Onramp + offramp
Widget → vakotrade BE → Alfred → signed webhook → Payment row.
1
Widget mounts
Calls GET /psp/alfred/eligibility — re-checks platform KYC + Alfred KYC + country + customer provisioned. Renders form or blocker.
2
Session created (24h TTL)
POST /psp/alfred/session binds the cached session to the trader JWT. All subsequent calls re-bind through requireSession(session_id, traderId).
3
Initiate deposit OR withdrawal
Deposit: POST /onramp returns virtual deposit address + bank instructions. Withdrawal: enqueues canonical WithdrawalFiatCreateJob chain; /offramp only runs after auto-approve clears.
4
User pays / Alfred dispatches
Deposit: user transfers fiat from their bank. Withdrawal: Alfred wires fiat to the registered bank account.
5
Alfred webhooks terminal state
ON_CHAIN_COMPLETED (onramp) → DepositManualCreateJob with DB uniqueness on remote_txid. FIAT_TRANSFER_COMPLETED (offramp) → WithdrawalCompleteJob. FAILEDWithdrawalRejectJob + balance restored.

Sequence — reliance KYC handoff, end-to-end

Five actors, seventeen messages. SumSub captures and stores; vakotrade BE forwards text and pulls photos from S3 to relay to Alfred.

User SumSub S3 vakotrade BE Alfred 1. Open verification 2. Capture selfie + ID + tax_id + PEP flag 3. Upload photos to S3 4. Webhook: KYC verdict 5. Emit kyc_approved event 6. POST /customers/create 7. 201 {customerId} 8. POST /customers/{id}/kyc 9. 201 {submissionId} 10. GetObject(selfie,IDf,IDb) 11. Photo blobs 12. POST /kyc/file (×3) 13. 201 {fileId} ×3 14. POST /kyc/status/circle AiPrise sanctions/PEP screen runs async on Alfred's side 15. Webhook: APPROVED|REJECTED 16. Write alfred_kyc_status 17. Widget unblocks · deposit/withdrawal ready
Read this way: purple = our outbound to Alfred; orange = Alfred's response or async webhook; cyan = S3 file traffic; green = SumSub upload + widget unblock; grey = internal action on the actor. SumSub uploads photos to S3 (step 3) and our BE pulls them back (steps 10–11) only after Alfred has assigned a submissionId for the customer — that's when POST /kyc/file becomes valid against Alfred's reliance API.

Architecture decisions, area by area

Click each card to expand. Each one names the decision, the rationale, and the trade-off it carries.

1
Reliance KYC — Alfred trusts our SumSub verdict
DIFFERENTIATOR Client requirement §4.1
Primary vendor
SumSub
Alfred mode
Reliance + AiPrise
User-visible
SumSub only
The decision

This isn't a free architectural choice — we are obligated to take this approach. The client (ANDX) refuses to switch from SumSub to Alfred's KYC provider and refuses to embed Alfred's widget. SumSub must remain the single user-facing KYC vendor across all of vakotrade.

Alfred operates as the fiat PSP — it has its own KYC pipeline by default, but we drive it in reliance mode: SumSub completes verification, we forward the captured identity data to Alfred, and Alfred runs their internal sanctions/PEP screen (AiPrise) on top. The user never lands on Alfred's UI.

Trade-offs
  • We carry the cost of bridging two KYC schemas (SumSub field names → Alfred kycSubmission shape).
  • Alfred's review verdict is async — only the final APPROVED / REJECTED status comes back via webhook.
2
SumSub → Alfred bridge (EnsureAlfredCustomerOperation)
SHIPPED 5-stage pipeline §4.2
Trigger
kyc_approved event
Stages
5
Idempotent
Yes
5-stage pipeline
  • 1 · Country gate — drop early if address_country is missing or not in ALFRED_SUPPORTED_COUNTRIES. Logs ALFRED_KYC_SKIPPED_UNSUPPORTED_COUNTRY.
  • 2 · Create customerPOST /customers/create { email, type:"INDIVIDUAL", country }; alfred_customer_id persisted to users_properties. Recovery path: 409 "Customer already registered" → look up by email+country.
  • 3 · Submit KYC textPOST /customers/{id}/kyc with the SumSub-derived field set per country (firstName, lastName, dateOfBirth, address, city, state, zipCode, phoneNumber, pep, dni, plus country-specific: cuit for AR, cpf for BR, typeDocumentCol for CO, email for CN/HK/US). Returns submissionId; cached in Redis.
  • 4 · Submit KYC files — pull selfie + ID front + ID back from S3 (where SumSub stored them during user verification) and forward each one to Alfred via POST /customers/{id}/kyc/file. Shipped 2026-05-29 for individuals after Alfred granted the api-key scope (was Bug #3, now resolved). KYB (business customers) file pipe is tracked separately and remains TODO.
  • 5 · Finalize via /kyc/status/circle — flips Alfred-side review to under-review. In sandbox we additionally POST /webhooks {KYC, COMPLETED} (via the dev approve-kyc endpoint) to fast-forward AiPrise.
Why idempotent

The handler can fire multiple times for the same user (event replay, retry after partial failure). Each stage checks whether its outcome is already persisted before calling Alfred — re-running on a fully provisioned user is a no-op.

3
Dual-status tracking — platform KYC + Alfred KYC
SHIPPED kyc_property §4.3
Properties
2
Storage
user_kyc_data
Source
Webhook
Two independent statuses
  • kyc_status — platform-level, written by SumSub flow. Values: approved / pending / rejected / under_review.
  • alfred_kyc_status — Alfred-side, written by inbound webhook. Values: APPROVED / PENDING / REJECTED.
Why two

SumSub approval doesn't automatically mean Alfred approves — Alfred's AiPrise screen runs on top and can reject a user SumSub passed. The widget gates on both: only users with platform approved AND Alfred APPROVED see the deposit/withdrawal form. Distinct error codes (USER_NOT_VERIFIED vs ALFRED_KYC_PENDING vs ALFRED_KYC_REJECTED) let the FE render the right blocker.

4
Two-layer country gating — ours + Alfred's
SHIPPED Defence-in-depth §4.4
Layer 1
vakotrade set
Layer 2
/allConfigs
Cross-border
Refused
Layers
  • Layer 1 — ours. ALFRED_SUPPORTED_COUNTRIES set in alfred.constants.ts: MX · AR · BR · CO · DO · US · CN · HK · CL · PE · BO · PY. Anyone else is dropped before any Alfred call. Skip is logged, not thrown.
  • Layer 2 — Alfred. GET /allConfigs drives our rail picker — what's listed as onRampSupported / offRampSupported determines whether the deposit / withdrawal option even renders.
  • Cross-border refusal. Alfred 409s any operation where customer country ≠ operation country. We pre-empt this with ALFRED_CROSS_BORDER_NOT_SUPPORTED.
5
Webhook ingest — HMAC + durable idempotency
SHIPPED Security-critical §4.5
Auth
HMAC-SHA256
Replay window
5 min
Idempotency
DB-backed
Defence layers
  • Signature. Every inbound carries Signature: t=<unix>,s=<hex>; we HMAC-SHA256 the body with alfred_webhook_secret. Replay protection via 5-minute timestamp window.
  • Concurrency lock. Redis Lock("ALFRED_WEBHOOK:{referenceId}:{status}") deduplicates concurrent deliveries.
  • Durable idempotency. Before DepositManualCreateJob, paymentsService.findOneMaster({ psp_service_id: 'ALFRED', type: 'deposit', remote_txid }). Re-delivery returns the existing Payment row — no double-credit.
  • Amount validation. Webhook to_amount goes through Number.isFinite() && > 0 before crediting; failure logs ALFRED_ONRAMP_INVALID_AMOUNT and throws.
6
Withdrawal pipeline — canonical job chain reused
SHIPPED Auto-approve clears first §4.6
Job chain
3 stages
Auto-approve
Limits · KYT · Travel-Rule
Reversible
On FAILED
Chain
  • WithdrawalFiatCreateJob — debits the user, freezes balance, persists Alfred session context.
  • WithdrawalAutoApproveJob — runs platform-wide limits, KYT/AML, Travel Rule.
  • WithdrawalFiatPspRequestJob — only at this point does Alfred's /offramp get called. The widget polls in the meantime.
  • Alfred webhook → WithdrawalCompleteJob (completed) or WithdrawalRejectJob (reversed + balance restored).
Why reuse the canonical chain

Same KYT/limits/Travel-Rule pipeline that protects crypto withdrawals — no fork in compliance logic, no duplicated controls. Alfred is just one more PSP-routed branch off the existing trunk.

7
Action-time gates — re-verify everything at click time
HARDENED Security review §4.7
Checks
5
Where
_assertActionAllowed
Triggered
initiate-* endpoints
Why action-time and not just session-time

The session has a 24h TTL — state can change in that window (KYC revoked, maintenance turned on, withdrawals frozen). Every initiate-deposit / initiate-withdrawal call re-runs the same gate stack the GraphQL withdrawal endpoint runs, inline in the service since most NestJS guards are GraphQL-only.

Gates
  • Maintenance mode (settingsService.maintenance_mode)
  • Withdrawal freeze (withdrawal-side only)
  • Platform KYC still approved
  • Alfred KYC still APPROVED
  • Alfred customer still provisioned (alfred_customer_id exists)
9
Sandbox doesn't auto-progress — dev endpoints required
CONSTRAINT QA-only §4.9
What's missing in Alfred's dev
  • No testnet. Alfred dev returns real mainnet crypto addresses for offramp depositAddress (verified 2026-05-25 — sample 0x0C065CB462Ed093b2F60aa9Ad3F20A5e10627F5F on Polygon resolves to a funded EOA with 14 mainnet tx, nonce 0xe). There is no testnet sandbox to send synthetic crypto to.
  • KYC review does not auto-progress to COMPLETED — customers stuck at CREATED block /onramp with 111406.
  • No real bank-payment integration in dev — ONRAMP / OFFRAMP webhook chains never fire on their own; we have to inject them via our own dev endpoints to drive lifecycle.
  • No way to test the AiPrise approve/reject loop other than synthetic webhook injection.
Our workaround — dev endpoints

Five dev-only routes under /psp/alfred/__dev/*, guarded by an exchange check (VAKOTEST / *UAT only) and a shared X-Dev-Secret header. They drive the lifecycle: provision-customer, seed-kyc-and-provision, approve-kyc (push KYC COMPLETED webhook), emit-onramp (full webhook chain), emit-offramp. Full details and live "Fire" buttons in the QA Test Guide.

10
Bug #1 — AR / CO / DO onramp regression — RESOLVED 2026-05-29
RESOLVED Alfred fixed §4.10 · Closed 2026-05-29
OK
MX · BR · AR · CO · DO
Status
Closed
Retested
2026-05-29
Original symptom (historical)

Identical end-to-end flow (create → KYC submit → push KYC COMPLETED webhook) on five fresh customers: MX and BR returned POST /onramp → 201; AR, CO, and DO returned 400 / 111301 UNKNOWN_ERROR (404 in AR's case). Reported to Alfred with full curl reproduction.

Fix verification 2026-05-29

Alfred shipped the fix. The 2026-05-29 retest reran the same 4-step flow on fresh AR / CO / DO customers — all three now return 201 with a real transaction and bank-payment instructions, same shape as MX / BR. The 5-country deposit matrix is fully green.

11
Bug #2 — BANK_USA fiat-account creation broken
BLOCKED US only §4.11
Two failure paths
  • Path A — bridged US customer: 409 / 111485 "Error creating external account" with valid US routing.
  • Path B — non-bridged: 422 / 110003 "Customer not enabled for USA accounts".
Impact

US withdrawal is not testable today. Cascade: without a fiat account no offramp can be initiated. Possibly business-level enablement gating on Alfred's side that needs to be flipped per our businessId.

12
Bug #3 — KYC document upload 400/111301 — RESOLVED 2026-05-29
RESOLVED Scope granted + pipe shipped §4.12 · Closed 2026-05-29
Original symptom (historical)

POST /customers/{id}/kyc/file returned 400 / 111301 for every payload variant (4 JSON shapes, 4 multipart shapes, 2 alt endpoints). Root cause: /customers/*/kyc/file scope was not enabled on our restricted-dev api-key.

Fix verification 2026-05-29

Alfred granted the scope on our api-key. We shipped the SumSub → multipart pipe for individuals: POST /customers/{id}/kyc/file now returns 201 for SELFIE, ID_FRONT, ID_BACK. AiPrise pipeline flips customers to COMPLETED after the canonical 3 files land. KYB (business customers) file upload remains a TODO and is tracked separately — not a regression on the individual reliance flow.

In one paragraph

Alfred PSP integration is SumSub-led, reliance-model: SumSub completes the user-facing KYC, vakotrade BE forwards the identity data + photos to Alfred via their reliance API, and Alfred's AiPrise screen runs in the background. The user never sees Alfred's KYC UI. Five fiat rails are wired (SPEI · PIX · COELSA · ACH-PSE · ACH_DOM) and all five are green for both deposit and withdrawal after the 2026-05-29 retest.

One Alfred-side blocker remains: US BANK_USA registration (Bug #2) — cascades into no US offramp. Bug #1 (AR / CO / DO onramp) and Bug #3 (KYC photo upload) are resolved.

Contents

1The architectural decisionwhy reliance, why SumSub-led
2Components and data flowSumSub · vakotrade · Alfred · widget
3Per-country availabilitywhat works, what's blocked

1. The architectural decision

Two KYC pipelines were available: (a) Alfred's own KYC — user lands on Alfred's flow, Alfred owns the identity record; or (b) reliance mode — SumSub captures everything, vakotrade BE forwards a structured identity record to Alfred, Alfred trusts our verdict and runs AiPrise on top.

Why we picked reliance (option b)

What "reliance" means in practice

Omnibus deposit routing

2. Components and data flow

┌──────────────┐ │ SumSub │ primary KYC vendor (user-facing) │ · selfie │ │ · ID doc │ │ · tax_id? │ └──────┬───────┘ │ webhook: KYC review verdict ↓ ┌─────────────────────────────────────────────────┐ │ vakotrade BE │ │ │ │ kyc module ──→ emits NotificationTrigger.kyc_approved │ │ │ PspAlfredEventsProcessor │ │ │ │ │ └─→ EnsureAlfredCustomerOperation │ │ 1. country gate │ │ 2. POST /customers/create │ │ 3. POST /customers/{id}/kyc │ │ 4. pull from S3 → POST /kyc/file │ │ 5. POST /kyc/status/circle │ │ │ │ PspAlfredController │ │ /session /initiate-deposit │ │ /initiate-withdrawal /webhook │ │ /fiat-accounts /__dev/* │ └────────────┬──────────────────┬─────────────────┘ │ HTTPS ↑ HTTPS (HMAC signed) ↓ │ ┌──────────────┐ webhook: KYC verdict, onramp/offramp lifecycle │ Alfred PSP │───┘ │ · AiPrise │ │ · bank rails │ └──────────────┘ │ ┌──────────────────────────────┐ │ Widget (shift-markets) │ user-facing UI │ · deposit/withdrawal forms │ │ · session polling │ │ · 10 error-code blockers │ └──────────────────────────────┘

Where state lives

3. Per-country availability

What's available today on Alfred's dev. Production status pending STG/PROD credentials (see §5).

CountryRailDepositWithdrawalNote
MXSPEIOKOKFull end-to-end working.
BRPIXOKOKFull end-to-end working.
ARCOELSA (ARS)OKOKFull end-to-end working after Alfred shipped the Bug #1 fix.
COACH (PSE — needs bank_id)OKOKFull end-to-end with valid PSE code (e.g. 1007). Inline iframe via redirectUrl.
DOACH_DOMOKOKFull end-to-end. BANCO POPULAR rail confirmed.
USBANK_USAcascadesBug #2Can't register US bank → no offramp.
CNBANK_CNconfig offOK *Alfred config onRampSupported: false. * Untested — no bridged customer.
CL / BO / PEACH_CHL / ACH_BOL / B89config offuntestedOfframp-only by Alfred config. No bridged customer to test.