EUR 3P Payout via Schuman
Engineering reference for the EUR 3P Payout product. Covers the client-facing Platform API design and the Schuman Financial provider integration layer. Use this alongside the Schuman sandbox docs and Tyler's v1 Platform API spec.
What This Covers#
This docs site is the single engineering reference for the EUR payouts workstream. It covers two things:
- Client-Facing API - the
/paymentsresource as clients see it: endpoints, state machine, fee model, beneficiary validation, and webhooks. - Provider Integration - the internal Schuman abstraction layer: how client resources map to Schuman objects, provisioning sequence, payment lifecycle orchestration, polling service, balance proxy, and reconciliation.
Audience#
| Person | Role | Primary sections |
|---|---|---|
| Saloni | Engineering Lead (Primary audience) | All sections. This doc is the single reference for Saloni's team to build against. |
| Ishaan Negi | Engineering | Provider Integration, Payment Lifecycle, Polling Service |
| Sagar Chudamani | Engineering | All sections - architecture sign-off |
Product Context#
OpenFX is building a single API that lets clients fund in USDC, GBP, or USD, convert to EUR via OpenFX's trading engine, and pay a third-party beneficiary via SEPA - all in one instruction. Schuman Financial is the licensed entity executing the SEPA payout. OpenFX is the FX provider and technical integrator.
Build Target#
| Item | Detail |
|---|---|
| First live payout | 1st week of June 2026 |
| Day-one clients | Finmo, One.io, Mural, Rollafi |
| Funding flows | USDC, GBP, USD (SWIFT) |
| Partner | Schuman Financial Infrastructure |
| Schuman API | Platform API v1.1 (OAS 3.0) - 1P/3P module, currently POC status |
What OpenFX Builds#
- Payout instruction API (quote, confirm, execute)
- FX conversion triggered by payout instruction (just-in-time)
- EUR delivery to client's Schuman EUR IBAN
- Payment instruction relay to Schuman (held until EUR lands in client's Schuman EUR IBAN account)
- Status tracking (polling Schuman, delivering webhooks to client)
- Balance visibility (USDC, GBP, USD, EUR) on OpenFX platform
What Schuman Provides#
- Licensed SEPA payment infrastructure (Banking Circle)
- IBAN per client (EUR account for payout execution)
- 3P payout execution via
POST /1p-3p/payment/3p/{customerId} - IBAN-to-name validation (Confirmation of Payee check)
- Compliance and AML screening on the payout leg
What OpenFX Does NOT Do#
- Touch the payment flow of funds. OpenFX's involvement ends when EUR lands in the client's Schuman account.
- Compliance screening on the payout leg. Schuman handles this as the licensed entity.
- Hold customer funds in EUR. EUR transits through the client's Schuman account, not OpenFX.
Fund Flow
All EUR payout funding flows follow a two-leg pattern: a 1P FX leg (OpenFX controls) and a 3P payout leg (Schuman controls). The two patterns are just-in-time FX-funded and collection-funded.
Universal Flow (FX-Funded)#
All three funding currencies (USDC, GBP, USD) follow this sequence. The only variable is what the client sends to OpenFX's trade engine in step 4.
POST /payments/{id}/submit - triggers execution pipeline.cnv_ record created.acc_. OpenFX exits the fund flow here.GET /1p-3p/balance/{customerId} - polled on schedule. Payout instruction held until balance confirms. Payment in pending_funding internal stage.POST /1p-3p/payment/3p/{customerId} with beneficiary IBAN, name, amount, reference, and SEPA type.GET /1p-3p/transactions/{customerId}/{transactionId} polled to terminal state. On completion, fires payment.completed webhook to client.pending_funding state. The client's FX conversion has already executed, so the client holds EUR exposure until the payout completes.Just-in-Time Funding Design Principle#
For FX-funded payouts, the trade is only placed when a payout instruction is made. The sequence is: instruction then trade then EUR delivery then payout execution. EUR arrives and is immediately paid out. This means OpenFX does not pre-fund EUR positions or hold EUR on behalf of clients.
Collection-Funded Flow#
A client may collect EUR from their own customers (invoices, marketplace settlements) into their Schuman EUR IBAN. That balance sits in the account until the client initiates a payout instruction against it. No FX conversion occurs.
GET /1p-3p/balance/{customerId} - confirms sufficient EUR available. No FX conversion needed.POST /1p-3p/payment/3p/{customerId} - same as FX-funded flow, step 8.exchangeRate: null and no cnv_ record.fx_executing and funding_schuman, going straight from confirmed to submitted_to_schuman.Schuman API Endpoints Referenced#
| Step | Schuman API Call | Purpose | Notes |
|---|---|---|---|
| Pre-check | POST /1p-3p/iban/validation | Verify IBAN-to-name match (CoP) | Call before FX conversion. Reject mismatches early. |
| Balance check | GET /1p-3p/balance/{customerId} | Confirm EUR has landed in IBAN | Poll until balance >= payout amount |
| Execute | POST /1p-3p/payment/3p/{customerId} | Submit 3P SEPA payout | IBAN must match provided beneficiary name |
| Status | GET /1p-3p/transactions/{customerId}/{transactionId} | Poll payout status | No webhook from Schuman - must poll |
Other Useful Schuman Endpoints#
| Endpoint | Use |
|---|---|
GET /1p-3p/status/{customerId} | Check if 1P/3P is enabled for the client |
GET /1p-3p/fda-viban/{customerId} | Get the client's IBAN details (needed for EUR delivery addressing) |
GET /1p-3p/transactions/{customerId} | List all 1P/3P transactions for reconciliation |
GET /transfer/{customerId}/bank-transfers | Fiat transfer history (reconciliation against EUR deliveries) |
GET /address/{customerId}/allowed-chains | Supported blockchains (if USDC receipt via Schuman ever needed) |
POST /1p-3p/iban/validation before submitting payouts.Resource Model
EUR payouts use the unified /payments resource with the sepa_bank payment method type. The platform API is aligned with Tyler McIntyre's v1 Platform API architecture. Six core resources are involved, each with a typed ID prefix for traceability.
Core Resources#
| Resource | Prefix | What it represents | EUR payout example |
|---|---|---|---|
| Account | acc_ | A client's money account (type: demand_deposit) | Client's Schuman EUR IBAN, client's USDC balance on OpenFX |
| Account Number | an_ | The identifier on an account (IBAN, wallet address) | The EUR IBAN string itself |
| Counterparty | cpt_ | Beneficiary identity shell (name, address, jurisdiction) | The 3P beneficiary receiving EUR |
| Payment Method | pm_ | Rail-specific delivery details on a counterparty | Type sepa_bank with iban + optional bic. Covers both SCT and SEPA Instant (rail variant selected per-payment via sepaType, not per-payment-method). |
| Payment | pmt_ | The payment instruction and its lifecycle | The EUR payout object |
| Conversion | cnv_ | FX conversion record | USDC/GBP/USD to EUR trade |
| Quote | qte_ | A locked rate offer | FX rate + fees, valid for confirmation window |
| Transaction | txn_ | Ledger entry | EUR credit to Schuman IBAN |
Money Format#
All monetary amounts use string-based decimal values:
{ "currency": "EUR", "value": "10000.00" }
This avoids floating-point issues and is consistent across all amount fields: source, destination, fees, and balances.
SEPA Variant#
The sepaType field on the payment specifies the SEPA variant:
| sepaType value | Rail | Settlement speed |
|---|---|---|
credit_transfer | Standard SCT | D+1 |
instant | SEPA Instant | Seconds |
Both use the same endpoints, state machine, and webhook events. The difference is settlement speed and potentially fee. SEPA Instant availability on client VIBANs to be confirmed with Schuman during sandbox testing.
Endpoints
EUR payouts use three payment creation patterns. The staged intent flow is the default for EUR payouts because clients need to review the FX quote and CoP result before committing.
Payment Creation Patterns#
| Pattern | Endpoints | Use case | EUR payout fit |
|---|---|---|---|
| Staged intent | POST /payments/intents then POST /payments/{id}/submit |
Client gets a preview (quote, fees, CoP result), reviews, then confirms | Primary. Maps to the current quote-then-confirm flow. |
| Direct with quote | POST /payments with use_quote: true |
Lock rate and execute in one call | Repeat payments to known beneficiaries where CoP has already passed. |
| Direct at market | POST /payments with at_market: true |
Execute immediately at current rate | No review step. Client accepts market rate at execution time. |
Staged Intent Flow (Primary)#
| Step | Endpoint | Purpose | Key fields |
|---|---|---|---|
| 1. Create intent | POST /payments/intents |
Get quote with FX rate, fees, CoP result. Returns qte_ with expiresAt. |
sourceAccountId (acc_), counterpartyId (cpt_), paymentMethodId (pm_), amount, currency, amountInputSide (send or receive), sepaType: credit_transfer or instant |
| 2. Submit | POST /payments/{id}/submit |
Confirm and trigger execution (FX conversion + payout). Returns pmt_ in processing state. |
Payment ID. For close_match or unavailable CoP results, must include acknowledgement. |
| 3. Get status | GET /payments/{id} |
Current payment state, processing stage, tracking identifiers. | Payment ID |
| 4. Get timeline | GET /payments/{id}/timeline |
Chronological audit trail of all state transitions and events. | Payment ID |
| 5. Cancel | POST /payments/{id}/cancel |
Cancel before FX executes. Only valid in created state. After processing begins, cancellation is no longer possible. |
Payment ID |
expiresAt. This is a payment quote (FX + delivery + compliance), not a bare FX quote. Validity window should be 5-10 minutes to allow for detail review and CoP acknowledgement. Higher spread compensates for the extended rate-lock cost. TBC with Core Trading.Cross-Currency Fields#
amountInputSide controls quoting direction:
send- client specifies the source amount (funding currency)receive- client specifies the destination amount (EUR to beneficiary)
This replaces the previous source_amount / destination_amount split.
Beneficiary Validation#
| Endpoint | Purpose | Notes |
|---|---|---|
POST /counterparties/{id}/payment-methods/{id}/validate |
Trigger VoP/CoP check against Schuman POST /1p-3p/iban/validation |
Call before creating a payment intent. |
Balances#
| Endpoint | Purpose | Notes |
|---|---|---|
GET /accounts/{accountId}/balances |
Four-bucket balance model: available, pending, held, total |
Two checks per payout. |
Balance check 1 (at payment creation): Verify the client has sufficient funding currency (USDC/USD/GBP) in their OpenFX account. This is the available balance on the funding acc_.
Balance check 2 (before payout submission to Schuman): Verify EUR has landed in the client's Schuman IBAN. Proxied from Schuman's GET /1p-3p/balance/{customerId} and mapped into the four-bucket model on the EUR acc_.
Idempotency#
All POST endpoints require an Idempotency-Key header (client-generated UUID, unique per client, valid for 48 hours). On duplicate submission, the API returns the original response without re-executing.
POST /payments/{id}/submit. Duplicate confirmations must not trigger duplicate FX trades.Engineering owns the detailed request/response schemas. The fund flow defines what data is needed at each step.
Fee Model
Fees are returned in the intent response as a feeComponents array with typed entries, plus a totalDebitAmount showing what the client actually pays (source amount + all fees).
Fee Types#
| Fee type | Description |
|---|---|
fx_markup | FX spread component |
processing_fee | OpenFX processing fee |
sepa_fee | SEPA rail fee (pass-through from Schuman, if applicable). May differ between credit_transfer and instant. |
Response Structure#
{
"feeComponents": [
{ "type": "fx_markup", "amount": { "currency": "EUR", "value": "12.50" } },
{ "type": "processing_fee", "amount": { "currency": "EUR", "value": "2.00" } },
{ "type": "sepa_fee", "amount": { "currency": "EUR", "value": "0.50" } }
],
"totalDebitAmount": { "currency": "USD", "value": "10015.00" }
}
The feeComponents array is extensible for future fee types.
Payment State Machine
The client sees a simplified state machine. Internal orchestration granularity is hidden behind processing. Internal stages are exposed via processingStage on the payment object and via timeline events.
Client-Facing States#
| State | Description | Trigger to next state |
|---|---|---|
created | Intent created, quote returned, awaiting client submission | POST /payments/{id}/submit |
requires_action | Client action needed (CoP acknowledgement, RFI from Schuman compliance). requiresActionReason specifies what. | Client resolves the action |
processing | Payment is executing. Internal stages (FX, EUR delivery, Schuman submission) hidden from client. | Schuman confirms delivery |
in_review | Schuman AML/compliance screening in progress. Client cannot act, only wait. Depends on Schuman exposing a compliance hold status via GET /1p-3p/transactions - unconfirmed. | Schuman clears or escalates |
completed | Beneficiary received EUR | - |
failed | Any failure (FX, funding, Schuman rejection, SEPA failure) | - |
canceled | Client cancelled before FX execution | POST /payments/{id}/cancel from created |
returned | SEPA credit transfer returned by beneficiary's bank post-completion | Beneficiary bank returns funds (e.g., account closed) |
requiresActionReason Enum#
| Reason | When it fires | Client must... |
|---|---|---|
beneficiary_verification_required | CoP result is close_match or unavailable | Acknowledge the mismatch/unavailability and resubmit |
rfi_pending | Schuman compliance requests additional info (beneficiary docs, source of funds) | Submit requested information via OpenFX. Depends on Schuman providing an RFI submission mechanism - unconfirmed. |
quote_expired | Client took too long to submit | Request a new intent |
Internal Processing Stages#
Not client-facing. Exposed via processingStage on the payment object and via timeline events for clients that want granular visibility.
| processingStage | Maps to client state | What is happening |
|---|---|---|
fx_executing | processing | FX trade in progress |
funding_schuman | processing | EUR delivery to client's Schuman IBAN in progress |
pending_funding | processing | Waiting for EUR to land in IBAN (GET /1p-3p/balance polling) |
submitted_to_schuman | processing | Payout instruction sent to Schuman, awaiting execution |
schuman_compliance_hold | in_review | Schuman AML/compliance check in progress |
processingStage skips fx_executing and funding_schuman, starting at submitted_to_schuman.Tracking & Reconciliation
Every payment object carries a trackingIdentifiers object for end-to-end traceability across all three legs. The three-leg reconciliation model links FX conversion, EUR funding, and Schuman payout into a single traceable chain.
Tracking Identifiers#
| Field | Source | Purpose |
|---|---|---|
trackingIdentifiers.endToEndId | SEPA end-to-end ID | Traces the SEPA credit transfer through the banking network |
trackingIdentifiers.providerTransactionId | Schuman transaction ID | Links to Schuman's GET /1p-3p/transactions records |
exchangeRate.conversionId | cnv_ from OpenFX trade engine | Links to the FX conversion record (null for collection-funded payouts) |
Three-Leg Reconciliation Model#
Each EUR payout has three legs that must be traceable:
| Leg | Resource | How it links to the payment |
|---|---|---|
| 1. FX Conversion | cnv_ | exchangeRate.conversionId on the payment object |
| 2. EUR Funding | txn_ | Ledger entry for EUR delivery to Schuman IBAN, visible in timeline events |
| 3. Schuman Payout | Schuman transaction | trackingIdentifiers.providerTransactionId and trackingIdentifiers.endToEndId |
txn_ linking in leg 2 assumes a ledger exists. OpenFX does not currently have a ledger. For MVP with four clients and low volume, the per-transaction reference model (payout_id, trade_id, schuman_transaction_id) provides an interim audit trail. This gap is being addressed through the Embed acquisition and a multi-currency ledger effort. The Schuman payout integration should be designed so that ledger entries can be retrofitted without rearchitecting the reconciliation flow.Reconciliation Surfaces#
Two reconciliation surfaces:
- OpenFX ledger vs. Schuman IBAN balance: Every EUR delivery to Schuman must match a corresponding debit on OpenFX's side. Use
GET /transfer/{customerId}/bank-transfersfor Schuman's view. - Payout instructions vs. Schuman execution: Every
POST /1p-3p/payment/3pmust result in a matching transaction inGET /1p-3p/transactions/{customerId}.
Beneficiary Validation
Pre-validate beneficiaries before payment creation via VoP/CoP. Validation lives on the counterparty/payment-method path and wraps Schuman's IBAN validation API. This runs before FX conversion - invalid IBANs are rejected before any money moves.
Endpoint#
POST /counterparties/{cpt_id}/payment-methods/{pm_id}/validate
Wraps Schuman's POST /1p-3p/iban/validation.
Validation Sequence#
POST /1p-3p/iban/validation. EU VoP regulation mandatory from Oct 2025.CoP Result Handling#
| CoP Result | Meaning | OpenFX handling |
|---|---|---|
match | IBAN and name match exactly | Proceed. No client action needed. |
close_match | Name is similar but not identical (e.g., "ABC Ltd" vs "ABC Limited") | Payment moves to requires_action with reason beneficiary_verification_required. Client must explicitly acknowledge before proceeding. |
no_match | Name does not match account holder | Reject payment. Client must correct beneficiary details on the counterparty/payment-method. |
unavailable | Receiving bank does not support VoP, or check timed out | Payment moves to requires_action with reason beneficiary_verification_required. Client must explicitly acknowledge the risk before proceeding. |
close_match and unavailable, the POST /payments/{id}/submit call requires the client to have resolved the requires_action state. Eng to design the acknowledgement mechanism (likely via POST /payments/{id}/actions).Webhooks
Webhook event naming follows the platform convention: payment.{state}. Webhook payload always includes the full payment object. Clients should not need to poll after receiving a webhook.
Event Reference#
| Event | Fires when | Key payload fields |
|---|---|---|
payment.created | Intent created, quote returned | Payment object with quote details, feeComponents, exchangeRate |
payment.requires_action | CoP acknowledgement needed, or Schuman RFI | requiresActionReason, action details |
payment.processing | Client submits, execution begins | Payment object with processingStage |
payment.completed | Beneficiary received EUR | Full payment object with timestamps, trackingIdentifiers |
payment.failed | Any failure state | Error code, processingStage at failure, suggested remediation |
payment.canceled | Client cancels payment | Payment object with cancellation timestamp |
payment.returned | SEPA transfer returned post-completion | Payment object with return reason code, original completion timestamp |
Resource Mapping
How each client-facing resource maps to Schuman's API objects. Key point: Schuman has no concept of counterparties or payment methods. It receives raw beneficiary details (IBAN, name) on each payout call. OpenFX owns the identity layer entirely.
Provider Abstraction Principle#
Schuman is never exposed to API consumers. Clients interact exclusively with Tyler's unified /payments resource. Schuman is one provider behind the SEPA rail.
Every Schuman-specific integration must be designed as swappable. No Schuman concepts, IDs, or terminology should leak into the client-facing API surface. The provider interface for Schuman is implemented behind Schuman's EMI entity, Salvus SAS.
Design rule: Every piece of Schuman-specific logic - API calls, status mappings, polling cadences, balance construction - must be isolated behind the provider interface. No Schuman assumptions should be baked into the orchestration layer or the client-facing API.
Mapping Table#
| Client-Facing Resource | ID Prefix | Schuman Equivalent | Notes |
|---|---|---|---|
| Account (EUR IBAN) | acc_ |
Schuman customerId + VIBAN from POST /address/{customerId}/viban |
Client's Schuman EUR IBAN is surfaced as a first-class acc_ with type demand_deposit and an_ account number (the IBAN itself) |
| Counterparty | cpt_ |
No Schuman equivalent | Beneficiary identity is an OpenFX-only construct. Schuman receives beneficiary details (IBAN + name) inline per-transaction. |
| Payment Method | pm_ |
No Schuman equivalent | sepa_bank type with iban + optional bic. Stored in OpenFX, passed to Schuman at payout time. |
| Payment | pmt_ |
Schuman transactionId from POST /1p-3p/payment/3p/{customerId} |
OpenFX pmt_ wraps the full lifecycle (FX + funding + Schuman payout). Schuman only knows about the final payout leg. |
| Conversion | cnv_ |
No Schuman equivalent | FX conversion is internal to OpenFX's trade engine. Schuman has no visibility into FX. |
ID Mapping to Store#
| OpenFX | Schuman |
|---|---|
entityId | customerId |
acc_ (EUR account) | Schuman VIBAN |
Provisioning
When a new client is activated for EUR payouts, Tyler's Layer 0 (Identity/Entities) provisioning triggers the following Schuman calls in sequence. This is a one-time setup per client.
Four-Step Provisioning Sequence#
POST /customerPOST /customer/{customerId}/tc-acceptancePOST /address/{customerId}/vibanacc_ with the IBAN stored as an_. This is the account that holds EUR for payouts.PATCH /1p-3p/activate/3p/{customerId}Manual Onboarding Flow (v1)#
| Step | Who | What |
|---|---|---|
| 1 | OpenFX | Collect required docs from client (KYB, corporate docs, UBO declarations) |
| 2 | OpenFX ops | Submit docs to Schuman compliance team |
| 3 | Schuman | Approves client. OpenFX calls provisioning APIs (four-step sequence above) |
| 4 | OpenFX | Client's IBAN is created and whitelisted for EUR delivery |
| 5 | Client | Activates Send on OpenFX platform |
Target: Client goes from "I want EUR payouts" to "first payout executed" within 5 business days, assuming docs are ready.
Payment Lifecycle
Orchestration logic for payment execution. Two flows: staged intent (FX-funded, primary) and collection-funded. Both converge at Schuman payout submission.
Staged Intent Flow (Primary)#
Standard flow: client holds a non-EUR balance and wants to pay out EUR via SEPA.
Step 1 - Create Intent#
POST /payments/intents
- OpenFX generates a quote (
qte_) - Check 1: Verify
availablebalance on fundingacc_covers the source amount - Returns preview: FX rate, fee breakdown, net amount to beneficiary
- No Schuman call. Nothing is committed.
Step 2 - Submit Payment#
POST /payments/{id}/submit
Triggers the execution pipeline in sequence:
| Sub-step | Action | Notes |
|---|---|---|
| 2a | FX conversion via trade engine | cnv_ created. Source currency sold for EUR. |
| 2b | EUR delivery to client's Schuman IBAN | EUR sent to the VIBAN associated with the client's acc_. |
| 2c | Balance polling | GET /1p-3p/balance/{customerId} - poll until EUR balance reflects the expected amount. Check 2: Confirm EUR has landed before proceeding. |
| 2d | Payout submission | POST /1p-3p/payment/3p/{customerId} with beneficiary IBAN (from pm_), beneficiary name (from cpt_), amount (EUR), payment reference, and SEPA type. |
| 2e | Transaction status polling | GET /1p-3p/transactions/{customerId}/{transactionId} - poll until terminal state (completed, failed, rejected, returned). |
credit_transfer (SCT, D+1) or instant (seconds). Mapped from the sepaType field on the client-facing payment. TBC whether Schuman's API accepts this as a parameter or if instant requires a different endpoint/flag.Collection-Funded Flow#
Client already holds EUR in their Schuman IBAN (from a prior pay-in or collection). Skip steps 2a-2c:
- Check 2:
GET /1p-3p/balance/{customerId}to confirm sufficient EUR available - Payout submission (step 2d)
- Transaction status polling (step 2e)
No FX conversion, no cnv_ created. The payment object's exchangeRate field is null.
VoP/CoP Validation#
Triggered by: POST /counterparties/{cptId}/payment-methods/{pmId}/validate
Internally calls: POST /1p-3p/iban/validation (Schuman)
| Schuman VoP Result | Client-Facing Result | Payment Effect |
|---|---|---|
| Match | match | Payment proceeds normally |
| Close match | close_match | Payment enters requires_action with requiresActionReason: beneficiary_verification_required |
| No match | no_match | Payment blocked. Client must correct beneficiary details. |
| Unavailable | unavailable | Payment enters requires_action with requiresActionReason: beneficiary_verification_required |
When CoP acknowledgement is required, the client must explicitly acknowledge the mismatch via POST /payments/{id}/actions before the payment can proceed.
Internal State Machine
Full mapping from client-facing state to OpenFX internal stage to Schuman API state. The client sees simplified states; OpenFX tracks granular processing stages internally.
GET /1p-3p/transactions are unconfirmed. The mapping below uses assumed values based on POC observations. Must be validated during sandbox testing (Week 1 priority).Full State Mapping#
| Client-Facing State | Internal Stage | Schuman API State | Trigger |
|---|---|---|---|
created | intent_created | (none) | POST /payments/intents |
requires_action | awaiting_cop_ack | (none) | VoP returns close_match or unavailable |
processing | fx_executing | (none) | POST /payments/{id}/submit |
processing | fx_completed | (none) | Trade engine confirms conversion |
processing | funding_schuman | (none) | EUR delivery initiated to VIBAN |
processing | pending_funding | (none) | Polling GET /1p-3p/balance for EUR arrival |
processing | submitted_to_schuman | pending / processing | POST /1p-3p/payment/3p returns |
processing | schuman_processing | Schuman processing state | Polling GET /1p-3p/transactions |
in_review | schuman_compliance_hold | Compliance hold (TBC) | Schuman flags compliance review |
completed | completed | completed / settled | Schuman confirms execution |
failed | fx_failed | (none) | Trade engine failure |
failed | funding_failed | (none) | EUR delivery failure |
failed | payout_failed | failed / rejected | Schuman rejects payout |
canceled | canceled | (none) | Client cancels before FX execution |
returned | returned | returned | Beneficiary bank returns funds |
Balance Proxy
Tyler's v1 API exposes a four-bucket balance model. Schuman's balance API returns a single figure. OpenFX constructs the four buckets by overlaying internal state on Schuman's reported balance.
Four-Bucket Construction#
| Bucket | Source | Description |
|---|---|---|
available | GET /1p-3p/balance/{customerId} | Actual available EUR as reported by Schuman |
pending | OpenFX internal state | EUR deliveries in transit - known to OpenFX but not yet reflected in Schuman's balance |
held | OpenFX internal state | EUR earmarked for submitted-but-not-yet-completed payouts |
total | Computed | available + pending + held |
Why OpenFX Must Maintain Its Own View#
Schuman only reports a single balance number. It does not distinguish between available, pending, or held. OpenFX tracks pending (EUR deliveries in flight) and held (EUR committed to active payouts) based on its own internal state machine. The available bucket comes from Schuman. The rest is OpenFX's overlay.
Polling Cadence for Balance Refresh#
TBD during sandbox testing. Balance updates from Schuman may have latency. Initial assumption: poll on demand (when needed for a payment check) rather than continuously.
Polling Service
Schuman has no webhooks. OpenFX must poll for two things: balance arrival after EUR delivery, and transaction status after payout submission. Both loops are designed as a shared polling service.
Balance Polling (Post-EUR-Delivery)#
Endpoint: GET /1p-3p/balance/{customerId}
Purpose: Detect when EUR lands in the client's Schuman IBAN after FX conversion and delivery.
| Window | Frequency |
|---|---|
| First 5 minutes | Every 30 seconds |
| Minutes 5-60 | Every 2 minutes |
| After 60 minutes | Every 10 minutes |
| Timeout | 4 hours - escalate to ops if EUR has not arrived |
Transaction Status Polling (Post-Payout-Submission)#
Endpoint: GET /1p-3p/transactions/{customerId}/{transactionId}
Purpose: Track payout through to terminal state.
| Window | Frequency |
|---|---|
| First 5 minutes | Every 30 seconds |
| After 5 minutes | Every 2 minutes |
| Timeout | 24 hours (SEPA Credit Transfer settles D+1) |
Shared Polling Service Design#
Design both polling loops as a shared polling service rather than ad-hoc per-payment logic.
Responsibilities:
- Manage poll schedules per payment (register, tick, deregister on terminal state)
- Handle Schuman rate limits (back off if rate-limited)
- Emit internal events when state changes are detected
- Internal events trigger webhook delivery to clients via Tyler's webhook infrastructure
- Track and log all poll attempts for debugging
Reconciliation Linkage
Every EUR payout has up to three legs. All three must be traceable from the pmt_ object. This section defines the identifier chain and how to reconcile against Schuman.
Three-Leg Tracing#
| Leg | Identifier | Source | Exposed Via |
|---|---|---|---|
| FX Conversion | cnv_ | OpenFX trade engine | exchangeRate.conversionId on payment object |
| EUR Funding | txn_ | OpenFX ledger entry | Timeline event with txnId |
| Schuman Payout | Schuman transactionId | GET /1p-3p/transactions | trackingIdentifiers.providerTransactionId on payment object |
| SEPA End-to-End | endToEndId | SEPA credit transfer reference | trackingIdentifiers.endToEndId on payment object |
exchangeRate is null. Only two legs exist (EUR Funding + Schuman Payout), or just one (Schuman Payout) if EUR was already sitting in the IBAN.Cross-Reconciliation Against Schuman#
| Schuman Endpoint | Reconciles Against |
|---|---|
GET /transfer/{customerId}/bank-transfers | EUR funding txn_ entries in OpenFX's ledger |
GET /1p-3p/transactions/{customerId} | pmt_ records using providerTransactionId |
Fee Passthrough
Fees are determined at quote time and locked into the payment object. All three fee components appear in the feeComponents array.
Fee Component Sources#
| Fee Component | Source | Determined At | Description |
|---|---|---|---|
fx_markup |
OpenFX spread (bps) | Quote time (POST /payments/intents) |
FX margin on the currency conversion |
processing_fee |
OpenFX platform fee | Quote time | OpenFX's per-transaction processing fee |
sepa_fee |
Schuman payout fee | Quote time (fixed per-transaction, TBC) | Schuman's fee for executing the SEPA credit transfer |
Total Debit Calculation#
totalDebitAmount = source amount + fx_markup + processing_fee + sepa_fee
Migration Path
When OpenFX's NL PI licence transfers via the Embed acquisition, EUR volume migrates off Schuman. The provider abstraction makes this a backend swap, not a client migration.
What Stays the Same#
- Client-facing API: Zero changes. Same
POST /payments, same state machine, same webhooks, same balance model. - Reconciliation: Three-leg model stays. "Schuman Payout" leg becomes "OpenFX Payout" with direct SEPA access.
- Fee structure: Same
feeComponentsarray, same fields. Underlying cost may change.
What Changes#
| Component | Change |
|---|---|
| Provider layer | Schuman integration swapped for owned-infrastructure integration. All Schuman-specific code is behind the provider interface. |
| Account migration | Client EUR IBANs move from Schuman VIBANs to OpenFX's own banking partner. acc_ IDs may change (or be remapped). Clients must be notified. |
| Polling service | May be replaced by direct event feeds from owned infrastructure (no more polling if webhooks are available). |
| Balance proxy | May simplify if owned infrastructure provides multi-bucket balances natively. |
Design Implication#
Every piece of Schuman-specific logic (API calls, status mappings, polling cadences, balance construction) must be isolated behind the provider interface. No Schuman assumptions should be baked into the orchestration layer or the client-facing API. This is not optional - it is the enabling condition for a zero-client-impact migration.
Sandbox Validation
Week 1 items to confirm during Schuman sandbox testing. These are blockers for the build phase - each item affects implementation decisions.
Week 1 Items#
| # | Item | Why It Matters |
|---|---|---|
| 1 | Confirm Schuman's exact transaction status values from GET /1p-3p/transactions |
Needed to finalize the internal state machine mapping (currently using assumed values from POC observations) |
| 2 | Confirm balance API latency after EUR delivery | Determines polling cadence for balance checks. If latency is high, clients wait longer in pending_funding state. |
| 3 | Confirm Schuman's compliance hold behavior and how it surfaces in the API | Needed to implement in_review client state. Currently unconfirmed whether GET /1p-3p/transactions exposes a compliance hold status. |
| 4 | Confirm rate limits on balance and transaction polling endpoints | Required for polling service design. No batch endpoint exists - burst rate determines max concurrent clients supported. |
| 5 | Confirm Schuman's endToEndId generation and format |
Required for the SEPA end-to-end tracing model. If Schuman generates the endToEndId, OpenFX must capture it from the payout response. |
| 6 | Confirm per-transaction payout fee from Schuman | Blocks go-live pricing. The sepa_fee component in feeComponents is currently TBC. |
| 7 | Confirm SEPA Instant availability on VIBANs, how to request instant vs SCT via the API, and any fee differential or amount caps | Determines whether sepaType: instant is launchable in v1 or deferred. Affects API design for the sepaType parameter mapping to Schuman. |
Full Open Questions (from PRD)#
| Item | Priority | Owner | Notes |
|---|---|---|---|
| Schuman 1P/3P production timeline | P0 | Rushil + Schuman | Module is POC. When GA? Breaking changes? SLA? |
| EUR delivery mechanism | P0 | Eng + Rushil | How does EUR get from OpenFX to client's IBAN? SEPA transfer? Does OpenFX open its own Schuman account for internal book transfer? |
| Schuman API rate limits | P0 | Eng (sandbox test) | No batch endpoint. What's the max burst rate for single payout calls? |
| Tripartite agreement | P0 | Katherine / Legal | Client-OpenFX-Schuman agreement. Not yet drafted. Blocks client activation. INR precedent exists. |
| Schuman 3P payout pricing | P0 | Rushil + Dan | Does EUROP fee schedule apply, or separate? Negotiate combined volume. |
| Ledger dependency for reconciliation | P0 | Rushil + Nathan | OpenFX does not currently have a ledger. Interim per-transaction reference model works for MVP. Embed acquisition and multi-currency ledger effort addressing separately. Confirm timeline and ensure Schuman integration is ledger-compatible when it lands. |
| Webhook support from Schuman | P1 | Rushil + Schuman | Current API has no push notifications. Can they add? |
| Mint-back on IBANs | P1 | Rushil + Schuman | Auto-sweep to EUROP could drain payout balances. How to exclude? Less critical with just-in-time funding model. |
| FX rate lock duration | P1 | Rushil + Alex Rowles | What quote validity window does the trading engine support? |
| Compliance hold and RFI via API | P1 | Rushil + Schuman | Does GET /1p-3p/transactions return a status indicating compliance hold? Can Schuman expose an endpoint for submitting RFI responses? Needed to support in_review and requires_action states. |
| SEPA Instant via Banking Circle | P1 | Rushil + Schuman | Confirm: (1) is SEPA Instant available on client VIBANs, (2) is there a fee differential, (3) any amount caps or country restrictions on instant. |