Skip to content

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 of transfer_amount/ source_amount, transfer_method (LOCAL|SWIFT), reason (enum), reference, and beneficiary (saved beneficiary_id OR inline beneficiary{}).
  • Beneficiary: POST /api/v1/beneficiaries/create; required fields are DYNAMIC — fetch per destination via POST /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.*). Pin x-api-version (funding/approval changed 2024-01-31).
  • Per-statement join keys: request_id (our correlation id) + reference; show transfer_id/short_reference/status. No free-form metadata{} on Transfers.
  • Sandbox: AIRWALLEX_API_BASE_URL=https://api-demo.airwallex.com (+ demo keys); simulate status via POST /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/login with x-client-id + x-api-key → Bearer token. Payouts calls (transfers, beneficiaries, fx quotes) pin x-api-version: 2025-06-30 — the version whose funding/approval + transfer-status model this code reasons about (funding reworked 2024-01-31; PAID added 2024-04-30; 2025-06-30 is the current approval/funding status doc). Sandbox base URL https://api-demo.airwallex.com.
  • Beneficiary schema: POST /api/v1/beneficiary_api_schemas/generate with {account_currency, bank_country_code, entity_type, transfer_method, local_clearing_system?, country_code?}fields[]. Each field carries key/path (e.g. beneficiary.bank_details.account_number), required, enabled, a rule{type,pattern} (regex), and a UI type (INPUT|SELECT|DYNAMIC_SELECT|RADIO|TRANSFER_METHOD) with options[] / 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 before valid_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 of transfer_amount/source_amount, and beneficiary_id OR inline beneficiary{}. 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.
  • reason enum (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} (+ list GET /api/v1/transfers). Webhooks: verify x-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 the transfer. prefix + reads data.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, the reason enum, and a status-classification map. (2) lib/airwallex/client.ts — pinned x-api-version: 2025-06-30 for Payouts and added generateBeneficiarySchema, 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) Rebuilt components/finance/AirwallexTransferDrawer.tsx as 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 v4 request_id on entering review and reuses it across retries (Airwallex dedups), so a network retry can't double-send; the route rejects a missing/invalid request_id and 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 uses source_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. The fx/quote route exists for a future firm-quote-in-review enhancement. • Webhook is a signature-verifying scaffold (verify → log transfer.*); 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_modules install 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 (needs AIRWALLEX_* demo creds pointed at api-demo.airwallex.com).
  • Open follow-ups: (a) live-sandbox verification incl. confirming the exact beneficiary-schema fields[] shape and wiring DYNAMIC_SELECT option loading (currently a text fallback); (b) the legacy pages/api/airwallex/transfer.ts
    • client.createPayment are now UNUSED by the UI (only the dead lib/banking/adapters/airwallex.ts still references the route) — candidate for removal once the new path is proven; (c) wire the webhook to a durable transfer store + notifications; (d) set AIRWALLEX_WEBHOOK_SECRET and 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-version all verified against the contract. Fixes applied from the review (same branch): (1) status honesty — SCHEDULED reclassified 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_reason now surfaced on a failed result; (3) added a SWIFT-only swift_charge_option control (SHA/OUR) so the payer can choose to cover intermediary fees; (4) server transfers/create now 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-side request_id fallback 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 same request_id (safe), but a lost-response-then-"back-to-edit" sequence could still create a second transfer; a server-side pre-send lookup by request_id (or a one-time confirm token) is the durable fix. Also deferred: webhook replay/dedupe hardening before the persistence TODO lands, and wiring DYNAMIC_SELECT option loading in the new-beneficiary form (currently a text fallback).
  • 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 into main per the 🟡🟡 branch policy (Vercel target, manual deploy — no auto-deploy fires on the push).
  • How verified: contract re-verified against official docs (above); tsc --noEmit clean; 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 the main merge commit for this ship. Future rework SHAs append here.
  • Open follow-ups (carried past done): (a) live-sandbox verification incl. confirming the beneficiary-schema fields[] shape + wiring DYNAMIC_SELECT; (b) server-side request_id pre-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 setting AIRWALLEX_WEBHOOK_SECRET + registering the webhook URL; (d) retire the now-unused legacy pages/api/airwallex/transfer.ts + client.createPayment once 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, NO x-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 (flat fields[] with key/path/required/rule.pattern — matches the types), numeric transfer_amount, reason/transfer_method/ swift_charge_option, and the transfer id. 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, not beneficiary_id — the create route now normalizes beneficiary_id = beneficiary_id ?? id and the drawer reads both; (2) source_currency is required on transfers/create even when it equals transfer_currency (rejected as missing otherwise) — the drawer now always sends the funding wallet currency and the route defaults it to transfer_currency; (3) the transfer's human reference is short_reference_id, not short_reference — the result screen + type now prefer it. Plus two schema-form usability fixes: enum rule.pattern fields (e.g. bank_account_category=Checking|Savings, type, state) now render as dropdowns, and the stray top-level transfer_method schema field is dropped.
  • Not an app issue: the sandbox simulation/.../transition to PAID 500s on an invalid jump (SCHEDULED→PAID) — it must follow the valid status sequence; the route passes next_status through 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 main merge commit (this ship). Earlier: 909107d, daf8251, the status→done commit, the #837 merge, and 434e20b (#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 --noEmit clean" line — that verification was INSUFFICIENT. The #837 merge (44add12) left 11 event-handler params as implicit any plus a miscased SafetycertificateOutlined icon import; a bare/filtered tsc reported clean but next build failed type-checking, so main was undeployable from 44add12 until the owner fixed it in 11f96e6 ("type AirwallexTransferDrawer handlers so main builds again"). Since auto-deploy is off and deploys are manual, an un-type-checking main silently blocks every agent's production deploy. Lesson recorded as a build-gate rule in AGENTS.md (run a full next build / heavy tsc before merging).
  • This change: refined the 5 Radio.Group onChange={(e: any) => …} stopgaps to precise structural types { target: { value: <union> } } — removes the any without naming antd's RadioChangeEvent (whose export resolves to a namespace → the TS2709 that forced the any). Verified with NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit AND a full next 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.tsx only (type annotations on 5 handlers; no behaviour change).