Changelog
Notable changes to the Swappr API. Dates are when the change reached production.
Currency discovery endpoint + cleaner name-enquiry errors + clearer provisioning accounts
A new GET /v1/currencies endpoint lets you discover the currencies the platform supports in your environment. We also cleaned up name-enquiry error messages and made it explicit that a still-provisioning virtual account has no account_number yet.
GET /v1/currencies — currency discovery. Returns the currencies active in your environment ({ code, name, symbol, decimals }), ordered with the home market (NGN) first. Read-only — any valid key can call it; there’s no filter param. Use it to discover valid currency values instead of hard-coding a list. See Currencies.
A provisioning virtual account now returns account_number: null. While a freshly-issued customer virtual account has status: "provisioning", account_number is null (the field is still present) — do not surface or fund it. Once status becomes "active", the real number is populated. provider_ref is present throughout for correlation.
POST /v1/name-enquiry returns cleaner errors. An unresolvable or provider-edge lookup now returns a clear, merchant-facing message instead of raw upstream text. HTTP status and error codes are unchanged.
Customer create rolls back cleanly on validation rejection
When POST /v1/customers is rejected at create for an invalid identity field, the half-created record is now discarded — so you fix the field and retry with the same customer_reference. We also corrected the next_action upload paths in the create response to /api/v1/....
A validation rejection at create is now recoverable with the same customer_reference. On a 422 validation_failed, the failed record is rolled back (the 422 detail no longer includes customer_id). Fix the offending field and retry reusing the same customer_reference with a fresh Idempotency-Key — you no longer need a new reference. (Post-submission identity corrections — after a customer is submitted for verification — still require a new customer.)
next_action upload paths corrected to /api/v1/.... The create-response next_action steps for individual customers now point at /api/v1/customers/{id}/files and /files/attach.
Individual customers: phone now required at create
POST /v1/customers now requires phone for type: individual, alongside the existing id_expiry_date, dob, street, city, and state. The banking partner needs it to provision a virtual account, so we now reject early with a clear 400 instead of failing later at virtual-account creation.
phone is now required for type: individual. Omitting it returns 400 missing_field (field phone). It was previously optional but is needed for virtual-account provisioning. Presence is required; the phone format is not enforced. Business (type: business) customers are unaffected.
Backwards compatibility
- Individual customers were already unable to get a virtual account without a phone (the failure just surfaced later, at VBA creation). Requiring it up front turns that into a clear, early error — it doesn’t remove any working flow.
- Business (
type: business) customers are unaffected.
Every response carries an X-Request-Id for support tracing
Every API response now documents an X-Request-Id header identifying that exact request — quote it when contacting support so we can trace the precise call.
X-Request-Id on every response. Each response (success or error) includes a unique request id. Include it when you contact support and we can trace the exact request — method, path, status, latency, and masked request/response bodies — in our logs. API requests are retained ~13 months for troubleshooting; secrets such as your API key are never stored.
Document uploads recorded + create response signals the next step
POST /v1/customers now returns a next_action and requirements hint for individual customers that are pending verification, so it’s clear an identity document must be uploaded. We also now keep a record of every document uploaded through the API.
next_action / requirements on create. For type: individual customers returned as pending, the create response includes next_action: "upload_identity_document" plus a requirements object listing the upload steps. Purely additive — existing fields are unchanged.
Document upload records. Uploading an identity document (/files → /files/attach) is now recorded against the customer. No request/response shape changed.
Individual customers: address + DOB now required at create
POST /v1/customers now requires dob, street, city, and state for type: individual, alongside the existing id_expiry_date. The banking partner needs them to provision a virtual account and they cannot be added after the customer is created — so we now reject early with a clear 400 instead of failing later at virtual-account creation.
dob, street, city, state are now required for type: individual. Omitting any returns 400 missing_field (with the field name); a malformed dob returns 400 invalid_field. These were previously optional but are mandatory for virtual-account provisioning and are not updatable after create. id_issue_date remains optional.
Backwards compatibility
- Individual customers were already unable to get a virtual account without these fields (the failure just surfaced later, at VBA creation, and was unrecoverable because the record can’t be edited after create). Requiring them up front turns that into a clear, early error — it doesn’t remove any working flow.
- Business (
type: business) customers are unaffected.
KYC file upload returns the Content-Type to send
POST /v1/customers/{id}/files now takes a content_type and returns it inside required_headers as the Content-Type you echo on the upload PUT — so identity documents attach reliably.
POST /v1/customers/{id}/files now requires content_type. Pass the MIME type of the file you’ll upload — one of image/jpeg, image/png, image/webp, image/heic, image/heif, application/pdf. It’s returned inside required_headers as Content-Type. Omitting it returns 400 missing_field; an unsupported value returns 400 invalid_field.
Identity documents now attach reliably. Previously the presigned upload’s required_headers carried no Content-Type, so an upload that echoed them verbatim was stored without a valid content type and POST /v1/customers/{id}/files/attach rejected it. The response now always includes the Content-Type for your file.
Docs clarify the byte-PUT is server-side. The signed upload_url is not browser-CORS-enabled — run step 2 from your backend. Requesting the URL and attaching the file are ordinary Bearer-authenticated calls.
Backwards compatibility
- The previous documented flow (echo
required_headersverbatim, nocontent_type) did not actually complete —attachrejected the file — so adding the required field unblocks the happy path rather than breaking a working one. - If you were already setting
Content-Typemanually on thePUT, you can keep doing so, or drop it and just echorequired_headersnow that they include it; either way, addcontent_typeto the/filesrequest.
Stricter request validation + webhook endpoint hardening
This release tightens input validation on list and report endpoints and hardens webhook endpoint registration. Most changes surface a clearer 400 where invalid input was previously accepted silently — valid requests are unaffected.
Invalid filter values now return 400 instead of being silently ignored. GET /v1/beneficiaries?currency= and GET /v1/webhook_deliveries?event_type= return 400 invalid_filter on an unrecognized value, and GET /v1/reports/*?format= returns 400 unsupported_format for anything other than json / csv / xlsx. Previously an unrecognized value was dropped (returning unfiltered, or default-format, results).
GET /v1/reports/bank-statement now requires currency. A bank statement is single-currency — a running balance across mixed currencies is meaningless — so there is no default. Omitting it returns 400 missing_currency; an unrecognized value returns 400 invalid_filter. Pass one of NGN / GBP / USD / EUR / CAD. (Previously defaulted to NGN.)
Webhook endpoint URLs must be publicly reachable. POST and PATCH /v1/webhook_endpoints require an HTTPS URL that resolves to a public address — loopback, private-network, and link-local hosts are rejected with 400 invalid_url. Replaying a delivery to an endpoint whose URL no longer resolves publicly is rejected the same way. Use a public HTTPS tunnel (not raw localhost) when testing locally.
POST /v1/webhook_endpoints now takes an Idempotency-Key. A same-key + same-body retry returns the original endpoint (including its secret) with 200; a same key with a different body returns 409 idempotency_key_conflict. Send a UUID per logical create.
Webhook test events now sign with the production scheme. The “send test event” action previously used a legacy signature header format; it now signs with the same X-Swappr-Signature: t=<unix>,v1=<hmac> scheme as real deliveries, so a receiver built against the test event verifies live traffic unchanged. See Webhooks → verify the signature.
Backwards compatibility
- Valid requests are unaffected. The new
400s only fire on input that was previously accepted-and-ignored (a badcurrency/event_type/format), or on a bank-statement call that relied on the NGN default. - If you relied on the bank-statement NGN default, add an explicit
?currency=to those calls. - If you registered a non-public webhook URL (e.g. a raw
localhost), switch to a public HTTPS endpoint or tunnel.
Individual vs business FX flows de-conflated
The banking partner has two distinct customer types — individual and business — with separate onboarding, KYC, collections, and payouts. We’ve de-conflated the FX gates so the individual-sender flow no longer requires a business/merchant international account. See Individual vs business.
id_expiry_date is now required for type: individual on POST /v1/customers (YYYY-MM-DD, future date). The banking partner needs it to provision the customer’s virtual account, and it cannot be added after verification. Creates without it are rejected with missing_field (field id_expiry_date).
GBP/USD/EUR payout gate is now per-flow. An individual sender (sender_customer_id) sends from the sender’s own virtual account — a new sender_account_not_provisioned (422) is returned if that customer has no active VBA in the currency. A business/treasury sender keeps the existing no_active_international_account (403) gate against the merchant’s international account. CAD routes via Interac for both.
POST /v1/beneficiaries no longer requires an active international account for GBP/USD/EUR — only the international-accounts feature flag (fx_features_not_enabled if off). A beneficiary is a sender-agnostic saved recipient; the capability check now lives at payout time. Previously this returned no_active_international_account at save time.
POST /v1/customers/{id}/kyc is documented as an acknowledgement for individuals — verification runs automatically once identity documents are attached (no separate submit). Status also refreshes from the partner on read.
These changes are additive / unblocking for existing integrators: the new id_expiry_date requirement only affects new individual creates, and the relaxed beneficiary gate only removes a rejection.
Customer attribution & FX recipients
This release adds end-to-end customer attribution (inflows + outflows tied to a verified end-user), FX beneficiary support, and standardized payout webhooks.
Customers API — POST /v1/customers plus KYC submission, document upload, international account (VIBAN) issuance, and a unified GET /v1/customers/{id}/transactions history. See the Customers reference.
FX beneficiaries — POST /v1/beneficiaries now accepts GBP / USD / EUR recipients via a nested bank + address envelope, with an optional external_reference. See the FX shape.
FX payouts — POST /v1/payouts accepts beneficiary_id, customer_id, and customer_reference. The generic customer_id and the FX-specific sender_customer_id both attribute a payout to the same customer; pass either (or both, if they agree).
FX gating error code — POST /v1/beneficiaries and POST /v1/payouts report no_active_international_account when an FX (GBP / USD / EUR) operation is attempted without an active international account in that currency.
Standardized payout webhooks — payout.processing / payout.paid / payout.failed / payout.reversed now fire on every real status transition (not only on admin force-close), with a consistent payload that includes a customer block when attributed. See Webhooks → Payout lifecycle payload.
wallet_funded customer block — VA-credit webhooks for customer-owned accounts now carry a customer block.
Per-customer wallet filter — GET /v1/wallets/{id}/transactions?customer_id= returns the raw ledger view of one customer’s movements (requires the customer_transaction_view permission).
Backwards compatibility
- NGN and CAD beneficiaries are unchanged — the existing flat NGN shape and the CAD Interac shape continue to work exactly as before.
- Existing payout requests are unchanged —
beneficiary_id/customer_id/customer_referenceare all optional additions. Non-FX integrations need no changes. - Idempotency contract preserved —
POST /v1/beneficiariesstill returns200withcreated: falseon an idempotent collision (FX rows dedup the same way as NGN). No409. - Webhook payloads are additive — the
customerblock is omitted (not null) when a credit/payout isn’t customer-attributed, so existing parsers are unaffected.
Deprecated
- Inline FX recipient details on
POST /v1/payoutsare soft-deprecated in favor of referencing a saved beneficiary bybeneficiary_id. Inline FX continues to work for now; a future release will announce a hard-deprecation window before removing it. Migrate FX payouts to create a beneficiary first, then passbeneficiary_id.
Migration notes
- FX beneficiaries: if you build FX payouts inline today, start creating beneficiaries via
POST /v1/beneficiaries(GBP example usesbank.sort_codelike"200000") and passbeneficiary_idon the payout. - Customer transactions: grant the
customer_transaction_viewpermission to any API key that needsGET /v1/customers/{id}/transactionsor the?customer_id=wallet filter — without it those calls return403. - Webhooks: route payout events on the dotted
eventvalue (payout.paid) in the body, or the underscore form (payout_paid) in theX-Swappr-Eventheader — pick one consistently. Keep handlers idempotent (key payouts onreference).