Airwallex "Make Transaction" on the real Payouts→Transfers API
Goal¶
Build a real outbound-payment flow on Airwallex's CURRENT Transfers API. The
existing "Send transfer" drawer posts to the LEGACY /api/v1/payments/create
({source_id, beneficiary_id, amount, currency}) — wrong product. Rebuild on
Payouts → Transfers, with beneficiary management we don't have today.
Research (2026-06-13, official docs + repo)¶
- Create transfer:
POST /api/v1/transfers/create— required:request_id(UUID idempotency),source_currency,transfer_currency, one oftransfer_amount/source_amount,transfer_method(LOCAL|SWIFT),reason(enum),reference, and beneficiary (savedbeneficiary_idOR inlinebeneficiary{}). - Beneficiary:
POST /api/v1/beneficiaries/create; required fields are DYNAMIC — fetch per destination viaPOST /api/v1/beneficiary_api_schemas/generate(drive the form off the returned fields[]+regex). We only list/get today. - FX (if source≠transfer ccy):
POST /api/v1/fx/quotes/create→ quote_id. - Flow: auth → bene schema → create/lookup bene → (FX quote) → create transfer →
fund/approve (auto-debits wallet; may route to PENDING_APPROVAL) → status/webhook
(
transfer.*). Pinx-api-version(funding/approval changed 2024-01-31). - Per-statement join keys:
request_id(our correlation id) +reference; showtransfer_id/short_reference/status. No free-form metadata{} on Transfers. - Sandbox:
AIRWALLEX_API_BASE_URL=https://api-demo.airwallex.com(+ demo keys); simulate status viaPOST /api/v1/simulation/transfers/{id}/transition. - SAFETY: the send must be an explicit user confirm step, never automatic.
Plan¶
New server routes wrapping the above (beneficiaries/schema, beneficiaries/create, fx/quote, transfers/create, transfers/{id}); add createTransfer/createBeneficiary/ createFxQuote to lib/airwallex/client.ts; schema-driven beneficiary form + review-&-confirm screen in the Make-Transaction drawer (T-023 mounts the button). Build + test in sandbox before prod.
Log¶
- 2026-06-13 created from the Airwallex API research. Not started; follows T-023.
- 2026-06-13 APPROACH APPROVED by owner ("nod to T-024"): build on the Transfers API (new beneficiary-schema + create + transfer + status routes), sandbox-first (api-demo.airwallex.com), explicit confirm-before-send. Cleared to build after T-023 pt2 (the visible Make-Transaction button this engine plugs into).
Verified contract (2026-07-01, official Airwallex docs)¶
Re-verified the live contract against the official guide pages before writing
money-movement code (the /docs/api/... reference pages are a JS SPA shell to a
fetcher, but the /docs/payouts/... guide pages render server-side and were
readable). Source URLs at the bottom of this section.
- Auth + headers: unchanged —
POST /api/v1/authentication/loginwithx-client-id+x-api-key→ Bearer token. Payouts calls (transfers, beneficiaries, fx quotes) pinx-api-version: 2025-06-30— the version whose funding/approval + transfer-status model this code reasons about (funding reworked 2024-01-31;PAIDadded 2024-04-30; 2025-06-30 is the current approval/funding status doc). Sandbox base URLhttps://api-demo.airwallex.com. - Beneficiary schema:
POST /api/v1/beneficiary_api_schemas/generatewith{account_currency, bank_country_code, entity_type, transfer_method, local_clearing_system?, country_code?}→fields[]. Each field carrieskey/path(e.g.beneficiary.bank_details.account_number),required,enabled, arule{type,pattern}(regex), and a UItype(INPUT|SELECT|DYNAMIC_SELECT|RADIO|TRANSFER_METHOD) withoptions[]/dynamic_options{query_url,query_params[]}. NOTE: some versions wrap each entry as{enabled, field:{…}}; the form normalizes both. Exact field shape is UNVERIFIED against a live call (no sandbox keys) — flagged below. - Beneficiary create:
POST /api/v1/beneficiaries/create—{beneficiary:{entity_type, type?, bank_details{…}|digital_wallet{…}, address{…}, additional_info{…}, company_name|first_name+last_name}, transfer_methods:[…], nickname?, payer_entity_type?}. Field requirements are per-destination — drive off the schema above. - FX quote:
POST /api/v1/fx/quotes/create—{buy_currency, sell_currency, buy_amount|sell_amount, validity?}→{quote_id, rate, valid_from_at, valid_to_at, …}; book beforevalid_to_at. - Transfer create:
POST /api/v1/transfers/create— required:request_id(v4 UUID idempotency),transfer_currency,transfer_method(LOCAL|SWIFT),reason(enum),reference, one oftransfer_amount/source_amount, andbeneficiary_idOR inlinebeneficiary{}. Optional:source_currency(when it differs → FX),transfer_date,fee_paid_by(PAYER|BENEFICIARY, default PAYER),swift_charge_option(SHARED|PAYER),lock_rate_on_create(default true),quote_id. Default pre-funding auto-debits the wallet at create; a transfer may still route to an approval/funding-pending status. reasonenum (verbatim): audio_visual_services, bill_payment, business_expenses, construction, donation_charitable_contribution, education_training, freight, goods_purchased, investment_capital, investment_proceeds, living_expenses, loan_credit_repayment, medical_services, pension, professional_business_services, real_estate, taxes, technical_services, transfer_to_own_account, travel, wages_salary, other_services.- Transfer statuses (v2025-06-30): approval — IN_APPROVAL, APPROVAL_RECALLED, APPROVAL_REJECTED, APPROVAL_BLOCKED; funding/schedule — REQUIRES_FUNDING_CONFIRMATION, SCHEDULED, OVERDUE; in-flight — PROCESSING, SENT; terminal — PAID (success), FAILED, CANCELLED. PAID is not always final — it can revert to FAILED if the clearing system/recipient bank rejects.
- Status/retrieve:
GET /api/v1/transfers/{id}(+ listGET /api/v1/transfers). Webhooks: verifyx-signature= HMAC-SHA256(x-timestamp+ raw-body, endpoint-secret), hex, before trusting; envelope{id, name, account_id, created_at, data:{object}};transfer.*events (exact event-name list is behind the SPA — handler matches on thetransfer.prefix + readsdata.object.status, so it doesn't hard-depend on names). - Sandbox simulation:
POST /api/v1/simulation/transfers/{id}/transition{next_status: PROCESSING|SENT|PAID|FAILED|CANCELLED, failure_type?}(2024-01-31+). - Sources: airwallex.com/docs/payouts/transfers/create-a-transfer · …/create-a-transfer/transfer-statuses · …/beneficiaries/using-api-and-form-schemas · …/beneficiaries/create-beneficiaries · …/developer-tools/sandbox-environment/payouts/simulate-transfer-status-transition · …/developer-tools/webhooks/listen-for-webhook-events.
Decision log¶
- 2026-07-01 — Rebuilt the Send-transfer flow on the Payouts → Transfers API. status → doing.
- Attestation: read AGENTS.md + the api-catalog; checked the board by scope (T-024 is the sole task covering the Airwallex payout engine — no dup); this entry keeps T-024 current.
- Source: Bank Access (Diagnosticcs) · local Claude Code (owner-named this session; no stable claude.ai/code URL — see registry caveat for local agents).
- What changed (code, branch
claude/airwallex-payouts-api-ewqq83): (1)lib/airwallex/types.ts— added the Transfers/Beneficiary-schema/FX-quote types, thereasonenum, and a status-classification map. (2)lib/airwallex/client.ts— pinnedx-api-version: 2025-06-30for Payouts and addedgenerateBeneficiarySchema,createBeneficiary,createFxQuote,createTransfer,getTransfer,getTransfers,simulateTransferTransition. (3) New routes:beneficiaries/schema,beneficiaries/create,fx/quote,transfers/create,transfers/[id],transfers/simulate(sandbox-guarded),webhook. (4) Rebuiltcomponents/finance/AirwallexTransferDrawer.tsxas a 3-step form → review → confirm flow. - Proposed by: Bank Access (Diagnosticcs). Approved by: the owner — approach was pre-approved 2026-06-13 ("nod to T-024") and re-issued this session as the primary task.
- Rationale / deliberate choices:
• Explicit confirm, two layers. The drawer never sends from the form — it
routes through a read-only review screen, and the API route refuses
unless
confirm:true. Defence-in-depth for the hard rule below. • Idempotency. The client mints a v4request_idon entering review and reuses it across retries (Airwallex dedups), so a network retry can't double-send; the route rejects a missing/invalidrequest_idand never mints one server-side. • Source = wallet currency, not a Global Account. The old drawer picked the source from/global-accounts(those are for RECEIVING) — conceptually wrong for a payout. The payout source is a wallet currency balance (/accounts→ balancesByCurrency); fixed. • FX. For cross-currency the send usessource_currency+lock_rate_on_create(rate locked at create, shown on the result) rather than a pre-created quote in the review UI — avoids quote-expiry races between review and confirm. Thefx/quoteroute exists for a future firm-quote-in-review enhancement. • Webhook is a signature-verifying scaffold (verify → logtransfer.*); persisting status/notifying is a follow-up (no durable transfer store yet). - Owner evidence (verbatim, this session): "Rebuild it on the real Payouts → Transfers API: beneficiary schema (beneficiary_api_schemas/generate) → create/lookup beneficiary → optional FX quote → transfers/create → status/webhook. … The one hard rule from the task: the send must be an explicit user‑confirm step, never automatic."
- Verification: built against the re-verified contract above; typecheck
pending a clean
node_modulesinstall in-session. NOT yet verified against the live Airwallex sandbox — no demo keys were present in the sandbox env or GCP Secret Manager, so an end-to-end sandbox run (auth → schema → create bene → create transfer → simulate transition) is the owner-gated follow-up (needsAIRWALLEX_*demo creds pointed atapi-demo.airwallex.com). - Open follow-ups: (a) live-sandbox verification incl. confirming the exact
beneficiary-schema
fields[]shape and wiringDYNAMIC_SELECToption loading (currently a text fallback); (b) the legacypages/api/airwallex/transfer.tsclient.createPaymentare now UNUSED by the UI (only the deadlib/banking/adapters/airwallex.tsstill references the route) — candidate for removal once the new path is proven; (c) wire the webhook to a durable transfer store + notifications; (d) setAIRWALLEX_WEBHOOK_SECRETand register the webhook URL in Airwallex.
- Blast radius: touches only the Airwallex Payouts surface (
lib/airwallex/*,pages/api/airwallex/*, the Airwallex transfer drawer). No change to OCBC, to balances/transactions/statement sync, or to the legacy Payments route. Agents working on OCBC or Airwallex read-side sync are unaffected. -
Adversarial review (2026-07-01, two independent reviewers). No confirmation-bypass and no double-send risk found (the two-layer confirm gate
- request_id idempotency lifecycle verified correct); webhook signature
verification, the sandbox-only guard on
transfers/simulate, and the amount units / enums /x-api-versionall verified against the contract. Fixes applied from the review (same branch): (1) status honesty —SCHEDULEDreclassified as an attention state and any unknown/unmapped transfer status now defaults to "not sent — verify in Airwallex" instead of a benign "submitted", so the funding/approval nudge is never silently hidden; (2)failure_reasonnow surfaced on a failed result; (3) added a SWIFT-onlyswift_charge_optioncontrol (SHA/OUR) so the payer can choose to cover intermediary fees; (4) servertransfers/createnow validates the inline- beneficiary branch (entity_type + bank_details/digital_wallet) so the money endpoint never forwards an unshaped beneficiary; (5) review screen warns when a same-currency amount exceeds the wallet's available balance; (6) the client-siderequest_idfallback now always yields a valid RFC-4122 v4 UUID (a weak fallback would have been rejected by the server's UUID gate in a non-secure context). Residual known limitation (follow-up): idempotency is client-only — the common confirm→error→retry path reuses the samerequest_id(safe), but a lost-response-then-"back-to-edit" sequence could still create a second transfer; a server-side pre-send lookup byrequest_id(or a one-time confirm token) is the durable fix. Also deferred: webhook replay/dedupe hardening before the persistence TODO lands, and wiringDYNAMIC_SELECToption loading in the new-beneficiary form (currently a text fallback).
- request_id idempotency lifecycle verified correct); webhook signature
verification, the sandbox-only guard on
-
2026-07-01 — DONE (merged to
main). Verdict + handoff. - Source: Bank Access (Diagnosticcs) · https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4.
- Outcome vs plan: delivered the full plan — the "Send transfer" drawer no
longer touches the legacy
/payments/create; it runs on the Payouts → Transfers API with schema-driven beneficiary management and an explicit review-&-confirm send. Owner instructed "merge to main please" (2026-07-01), so merged directly intomainper the 🟡🟡 branch policy (Vercel target, manual deploy — no auto-deploy fires on the push). - How verified: contract re-verified against official docs (above);
tsc --noEmitclean; ESLint clean on the new files; two independent adversarial reviews found no confirm-bypass and no double-send, with their fixes folded in. - ⚠️ NOT battle-tested yet: the flow has never executed a real transfer,
not even in the sandbox (no demo keys were available this session). Treat the
code as merged-but-unexercised until the owner-gated live-sandbox pass
runs (auth → schema → create beneficiary → create transfer → simulate
transition against
api-demo.airwallex.com). If that pass surfaces issues, reopen this task (append rework SHAs; keep the list). - Related commit SHAs (append-only, newest last):
909107d(client/types/ routes/drawer rebuild),daf8251(registry ledger row), plus the status→done commit and themainmerge commit for this ship. Future rework SHAs append here. - Open follow-ups (carried past
done): (a) live-sandbox verification incl. confirming the beneficiary-schemafields[]shape + wiringDYNAMIC_SELECT; (b) server-siderequest_idpre-send lookup to close the lost-response-then-edit double-send window (client-only idempotency today); (c) webhook → durable transfer store + notifications, plus replay/dedupe hardening and settingAIRWALLEX_WEBHOOK_SECRET+ registering the webhook URL; (d) retire the now-unused legacypages/api/airwallex/transfer.ts+client.createPaymentonce the new path is proven. -
Blast radius: Airwallex Payouts surface only (
lib/airwallex/*,pages/api/airwallex/*, the transfer drawer). OCBC, balances/transactions/ statement sync, and the legacy Payments route are untouched. -
2026-07-02 — LIVE SANDBOX VERIFIED + 3 contract mismatches fixed (stays done; rework appended).
- Source: Bank Access (Diagnosticcs) · https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4.
- What happened: owner provided demo keys; ran the flow end-to-end against
api-demo.airwallex.com. Confirmed working as coded: auth (account-level, NOx-login-as— the org key's login-as was denied, plain login works),x-api-version: 2025-06-30,balances/current, the beneficiary-schema shape (flatfields[]withkey/path/required/rule.pattern— matches the types), numerictransfer_amount,reason/transfer_method/swift_charge_option, and the transferid. Beneficiary created (201) and a transfer created (status SCHEDULED→PROCESSING) successfully. - 3 real bugs the live API exposed (all now fixed):
(1) create-beneficiary returns the new id as
id, notbeneficiary_id— the create route now normalizesbeneficiary_id = beneficiary_id ?? idand the drawer reads both; (2)source_currencyis required ontransfers/createeven when it equalstransfer_currency(rejected as missing otherwise) — the drawer now always sends the funding wallet currency and the route defaults it totransfer_currency; (3) the transfer's human reference isshort_reference_id, notshort_reference— the result screen + type now prefer it. Plus two schema-form usability fixes: enumrule.patternfields (e.g.bank_account_category=Checking|Savings,type,state) now render as dropdowns, and the stray top-leveltransfer_methodschema field is dropped. - Not an app issue: the sandbox
simulation/.../transitionto PAID 500s on an invalid jump (SCHEDULED→PAID) — it must follow the valid status sequence; the route passesnext_statusthrough correctly. - Verification level: contract-level against the live sandbox (the drawer/routes now emit the exact payloads the sandbox accepted). A full in-browser run of the deployed drawer against the sandbox still needs the demo creds set in Vercel — a thin follow-up now that the contract is proven.
-
Related commit SHAs (append): the fix commit for the above + its
mainmerge commit (this ship). Earlier:909107d,daf8251, the status→done commit, the #837 merge, and434e20b(#839, unrelated AGENTS rule). -
2026-07-02 pt3 — Build-break correction + handler-type refinement + process gate.
- Source: Bank Access (Diagnosticcs) · https://claude.ai/code/session_01S4ztAsDZfR97qXjhPdwLn4.
- ⚠️ Correction to the 2026-07-01 "How verified:
tsc --noEmitclean" line — that verification was INSUFFICIENT. The #837 merge (44add12) left 11 event-handler params as implicitanyplus a miscasedSafetycertificateOutlinedicon import; a bare/filteredtscreported clean butnext buildfailed type-checking, somainwas undeployable from44add12until the owner fixed it in11f96e6("type AirwallexTransferDrawer handlers so main builds again"). Since auto-deploy is off and deploys are manual, an un-type-checkingmainsilently blocks every agent's production deploy. Lesson recorded as a build-gate rule in AGENTS.md (run a fullnext build/ heavytscbefore merging). - This change: refined the 5 Radio.Group
onChange={(e: any) => …}stopgaps to precise structural types{ target: { value: <union> } }— removes theanywithout naming antd'sRadioChangeEvent(whose export resolves to a namespace → the TS2709 that forced theany). Verified withNODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmitAND a fullnext build(both clean) — not a filtered tsc this time. - Full T-024 commit SHA list (append-only, newest last):
909107d(rebuild),daf8251(registry row),7d9ae70(status→done),44add12(#837 merge),4f08564(#840 sandbox-driven fixes),11f96e6(owner build-fix: typed handlers + icon), + this refinement's squash-merge commit. (434e20b/#839 was the AGENTS merge-rule, NOT a T-024 commit.) - Blast radius: unchanged —
components/finance/AirwallexTransferDrawer.tsxonly (type annotations on 5 handlers; no behaviour change).