Changelog

Changelog

Notable changes to the Swappr API. Dates are when the change reached production.

August 7, 20262026-08-07-bankcode-list-gate

Changed: NGN bank_code is validated against the bank list, not a length pattern

CHANGED

A bank_code is now accepted if GET /v1/banks publishes it — not by length. Real NGN codes come in several widths (CBN 3-digit like 058, NIBSS/NIP 5-digit like 50515 for Moniepoint and 50211 for Kuda, 6-digit like 000013), and the bank list publishes some 5-digit codes that the old “3-digit CBN or 6-digit NIP” rule rejected with 400 invalid_format. Any code the list carries now resolves; a code no bank carries returns 422 unknown_bank_code. Don’t validate bank_code by length — look it up in GET /v1/banks.

CHANGED

POST /v1/name-enquiry now validates bank_code against the bank list. A code no bank carries returns 422 unknown_bank_code (input echoed) instead of falling through to a downstream unresolvable — consistent with the payout contract. A non-numeric bank_code still returns 400 invalid_format.

July 31, 20262026-07-31-collections-gate-codes

Added: the four collections gate refusal codes

ADDED

POST /v1/collections now documents its four gate refusal codes. collections_not_enabled and collections_paused (both 403 permission_error) are account-level and not retryable — contact support. collections_disabled and collections_rail_disabled (both 503 provider_error) are temporary and platform-side — retryable with backoff; the two return the same message and the code is what distinguishes them. The gates ship permissive, so this documents existing behaviour rather than changing it.

July 31, 20262026-07-31-fx-sender-attribution

Changed: clearer FX sender-attribution docs

CHANGED

The FX sender-attribution sections now say how to supply the sender without describing the account model behind it. For the business/treasury flow the instruction is simply to omit sender_customer_id — the sender resolves to your merchant treasury customer automatically. The docs previously wrapped that in an explanation of which account the funds leave, which read as a requirement on your request when it was not. Which requirements apply for a given FX currency depend on how Technest has configured your account. The heading for accounts with both sending flows enabled is now Both flows enabled. No behaviour change — every error code, HTTP status and gate condition is unchanged.

July 31, 20262026-07-31-customer-examples

Fixed: the create-customer example was missing fields you cannot add later

FIXED

The create-customer example omitted phone, dob, street, city and state — and four of those can never be added after the fact. Once a customer is verified the banking partner rejects updates to dob, street, city, state and id_expiry_date, so a customer created without them is permanently ineligible for a virtual account, with no way to repair the record — you would have to start again under a new customer_reference. The failure also surfaces late and misleadingly, at virtual-account issuance rather than at create. All three tabs of the create-customer example now send a complete individual body, and the guide’s example does too. The field reference already described these correctly; only the copyable examples were wrong. The API’s behaviour is unchanged — these fields have always been required.

FIXED

The feature-gate error code was documented as feature_not_enabled; the API returns remittances_not_enabled. A handler branching on the documented string never fires. Corrected on Customers and Beneficiaries. Documentation correction only.

FIXED

The remittance guide requested a file upload with file_category: 'id_document', which is not a valid value and returns 400 invalid_field. The accepted values are identity, identity_back, proof_of_address and liveness_check; the guide now uses identity.

July 29, 20262026-07-29-collections

New: Collections — take payments from your payers over open banking

ADDED

POST /v1/collections opens a hosted checkout and returns a checkout_url to send your payer to. Available in GBP and EUR. When the payment settles, the amount is credited to your wallet in that currency and you receive the usual wallet_funded webhook — the same event a virtual-account credit produces, so if you already handle wallet funding, settlement is already wired. GET /v1/collections/{id} and GET /v1/collections retrieve and list them. Full reference: Collections.

ADDED

The capability is per-key: enable Create collections on the API key under API keys. A key without it returns 403 forbidden_collection_create. The capability lives on the key itself, so a working collections integration is unaffected by later changes to the team member who created the key. Reading collections needs no capability beyond authentication.

ADDED

Idempotency-Key is required, and a replay returns the same checkout_url. That is stronger than the usual idempotency guarantee and it is deliberate: a retry must never open a second checkout, because your payer may already be part-way through paying the first. The same key with a different body is a 409 idempotency_key_conflict, as elsewhere.

ADDED

A collection must be worth more than it costs to collect — 422 amount_below_minimum. Collecting has a cost that does not scale to zero, so a small enough collection can complete successfully while leaving nothing to credit. Swappr refuses to open one rather than let a payer pay for a transfer that delivers nothing. The error carries detail.provider_cost_minor and detail.minimum_amount_minor; read the minimum rather than deriving it, because the cost is not always flat and the answer is not always “cost plus one”.

CHANGED

Wait for status: "settled" before releasing goods. processing and pending_settlement mean a payer has authorised a payment, not that the money has arrived, and a payment can still fail after authorisation. Reconcile on settled_amount_minor, which is what actually arrived and may differ from the amount_minor you requested.

July 28, 20262026-07-28-batch-errors

The documented batch validation-error response was never emitted — here is the real one

FIXED

POST /v1/batches has never returned validation_failed, and there is no detail.row_errors anywhere in the API — delete any handler branching on either. The batch reference and the bulk payouts guide both published an error envelope with code: "validation_failed" and a detail.row_errors array, and the three row codes inside that example — invalid_recipient, name_mismatch and globally_blocked — have never been emitted by any endpoint. Code written against that shape cannot execute: the branch is never entered, and nothing signals it. This is a documentation correction; the endpoint’s behaviour is unchanged and has always been what is described below.

CHANGED

Batch rejections come in two shapes, and only one of them lists rows. A structural rejection (400, plus 404 currency_not_supported) stops at the first offending row and names its index in message — for example items[3].recipient_account_number must be a 10+ digit string. There is no per-row array. A semantic rejection (422) means the body parsed, so every row was checked and all failing rows are reported together. All-or-nothing is unchanged in both cases: nothing is created and no wallet moves unless every row passes.

ADDED

Five codes carry a per-row array, it sits top-level on error, and the key is not the same for all of them. sender_info_required, unknown_bank_code, limit_violation and fraud_rule_blocked use error.violations; recipient_unresolvable uses error.unresolved. None of them nest it under error.detail. A client that reads only error.violations will silently drop every unresolved-recipient row and see an empty list, so branch on error.code first. Entry fields differ per code and are listed on the Batches reference.

ADDED

Every code the endpoint can return is now documented, and the OpenAPI spec declares its error responses. The reference previously listed none of them and the spec declared only 201. The full set now covers the structural family (invalid_body, invalid_amount, invalid_format, invalid_id_type, items_empty, items_too_many, currency_not_supported, currency_mismatch, env_mismatch), account and key state (bulk_disabled_for_key, bulk_requires_ip_allowlist, idempotency_key_conflict, wallet_not_found, wallet_frozen), and the semantic family (sender_info_required, unknown_bank_code, recipient_unresolvable, insufficient_balance, limit_violation, fraud_rule_blocked).

CHANGED

Per-row outcomes live on the items endpoint, not in an error. “Which rows failed?” has two different answers. Before acceptance, nothing exists and the answer is in the error response. After a 201, every row passed validation and became a payout — rows then succeed or fail individually at dispatch, and you read that from GET /v1/batches/{id}/items, where each item carries status plus failure_code and failure_message, filterable with ?status=failed.

July 28, 20262026-07-28-provider-paused

New: provider_paused, a temporary 403 on the FX payout routes

ADDED

provider_paused (403, permission_error) is a new code on POST /v1/payouts. It means Technest has temporarily stopped one of the routes your account uses, and every route that could have carried this payout is currently paused. Your configuration is untouched — nothing has been disabled or removed — so the same request will succeed once the pause is lifted.

It is deliberately distinct from provider_not_entitled, which shares its status and type. provider_not_entitled says your account is not enabled for a route and is permanent until Technest changes your configuration; provider_paused says your account is enabled and the route has been stopped for the moment. Branch on the code, not on the status.

Neither is worth an automatic retry loop — contact Technest — but only provider_paused is expected to clear on its own. It applies to the FX currencies only (CAD, GBP, USD, EUR) and never to NGN.

July 28, 20262026-07-28-payout-error-codes

The payout error tables now list every code the endpoint can return

FIXED

Two codes we documented have never been returned by the API — correct any case that branches on them. The payout error tables listed merchant_blocked and globally_blocked, both as 422. Neither string has ever been emitted. The real codes are beneficiary_blacklisted (recipient on your merchant blacklist) and beneficiary_globally_blocked (recipient on the platform-wide blacklist), and both are 403, not 422 — so a handler keyed on the old strings, or on the old status, never fires. This is a documentation correction; the API’s behaviour is unchanged.

ADDED

Seven more codes are now documented on every payout currency page. insufficient_funds (402), wallet_frozen (403), beneficiary_blacklisted (403), beneficiary_globally_blocked (403), merchant_reference_duplicate (409), idempotency_conflict (409) and routing_failed (422) are listed on NGN, CAD, GBP, USD and EUR. beneficiary_cooldown (429), previously listed on the NGN page alone, is on all five — it applies to every currency. Note that idempotency_conflict and idempotency_key_conflict are different: the first compares a prior payout’s amount and recipient account, the second compares the whole request body.

ADDED

provider_not_entitled (403) is documented for the first time. It has been live since the FX entitlement release and was never published — this closes a documentation gap rather than changing behaviour. It is returned when routes exist for the currency but your account is not enabled for any of them, and it is distinct from routing_failed, which means no route exists at all. Entitlement refusals are permanent until Technest changes the configuration, so they should not be retried. It applies to the FX currencies only (CAD, GBP, USD, EUR) and never to NGN.

CHANGED

permission_error now covers entitlement, and the Postman guide’s cool-down status is corrected. The Errors page describes 403 as four flavours — key permission, account suspension, IP allowlist, and entitlement — and lists the four new 403 codes. The Postman quickstart showed the cool-down step returning 422; it returns 429.

July 28, 20262026-07-28-error-mapping

Payout errors return their own code and status

CHANGED

Payout errors now return their specific code and status. Failures that previously collapsed into payout_creation_failed / 422 now return the error’s own contract — for example a recipient in cool-down returns beneficiary_cooldown / 429, a frozen wallet returns wallet_frozen / 403, and insufficient balance returns insufficient_funds / 402. Clients branching on 422 for these cases should branch on the code instead.

July 28, 20262026-07-28-fx-dualmode

An account can be configured for both sender flows, and CAD is documented as dual-rail

CHANGED

sender_customer_id can now be optional — and when it is, it selects the flow per payout. Technest can enable an account for both the individual and business/treasury flows. On such an account, supplying sender_customer_id attributes the payout to that end customer and sends from the sender’s own account (sender_account_not_provisioned if they have none); omitting it attributes the payout to your treasury customer and sends from your merchant international account (no_active_international_account if you have none). customer_id_mismatch does not apply to this configuration — supplying a sender is a valid instruction, not an error. The binary phrasing on CAD, GBP, USD, EUR, the payouts overview and individual vs business is now scoped to accounts configured for a single flow. Nothing changes for those accounts.

CHANGED

CAD is presented as dual-rail on the payouts hub. The CAD page has documented both Interac e-Transfer and EFT since they shipped, but the hub page and a callout still described CAD as Interac-only. They now name both rails. The account-check exemption is unchanged and still correct — it applies by currency (the checks cover GBP, USD and EUR only), so it holds on Interac and EFT alike.

July 28, 20262026-07-28-fx-sender-contract

Documentation fix: the FX sender requirement depends on your flow

Documentation only — no API behaviour changed. The FX payout pages presented sender_customer_id as a flat requirement while the individual vs business page stated that business/treasury accounts send no per-payout sender. Both could not be true, and the reference pages were the ones integrators follow.

FIXED

The sender requirement is now documented as flow-dependent. In the individual flow sender_customer_id is required on every CAD/GBP/USD/EUR payout, and an inline sender block does not substitute for it — omitting it returns missing_field. In the business/treasury flow you send no per-payout sender_customer_id; the sender resolves to your merchant treasury customer automatically, and sending one returns customer_id_mismatch. Corrected on CAD, GBP, USD, EUR and the payouts overview.

FIXED

customer_id_mismatch has a second cause, now published. It was documented only as customer_idsender_customer_id. It is also returned when a business/treasury account sends a per-payout sender_customer_id — so an account could receive it without having sent customer_id at all. Both causes are now in every FX error table.

FIXED

no_treasury_customer (400) is now documented. Returned to a business/treasury account whose treasury customer is not provisioned yet. It was reachable but appeared in no page or spec.

FIXED

sender_info_required (422) described correctly. It is not “no sender_customer_id and no inline sender block” — it is returned when your account is configured to require sender identity and the merged sender details (inline sender block + the referenced customer’s stored KYC) are still incomplete.

FIXED

The OpenAPI spec now names the FX sender error codes. POST /v1/payouts documents 400 (missing_field · customer_id_mismatch · no_treasury_customer) and 422 (sender_info_required), and the sender_customer_id / sender field descriptions carry the flow split.

July 27, 20262026-07-27-fx-recipient-union

GBP/USD/EUR payouts now require the full recipient identity and address

A GBP, USD, or EUR payout must carry the complete recipient identity + address, so it is deliverable no matter which network routes it. Previously some of these fields were accepted only when supplied — a request that worked on one network could fail on another. This is a tightening (a rejected request never moves money); complete requests are unaffected.

CHANGED

GBP/USD/EUR require the recipient identity + address up front. All three now require recipient.name, recipient.bank_name, recipient.email, and recipient.street / recipient.city / recipient.country (ISO 3166-1 alpha-2), in addition to the existing bank fields. GBP also requires recipient.zip_code (this supersedes the earlier standalone GBP zip_code note). USD also requires recipient.state, recipient.zip_code, recipient.account_type (checking/savings), and the recipient bank’s address recipient.bank_address (line1/city/country). EUR does not require state or zip_code. A missing field returns 400 missing_field naming it. See GBP, USD, EUR.

CHANGED

Saved CAD EFT beneficiaries now capture the recipient address. A CAD EFT beneficiary (institution_number + transit_number) now stores the recipient’s email and full address (country / street / city / state / zip_code), so a payout to it by beneficiary_id is deliverable. Interac beneficiaries are unchanged (email + name). See beneficiaries.

July 27, 20262026-07-27-cad-method-rail-selector

Choose the CAD rail per payout with method

CAD is a dual-rail currency — Interac e-Transfer and EFT. You can now pick the rail per payout instead of relying only on your account’s routing configuration.

ADDED

method selects the CAD rail. Set the top-level method field to "interac" or "eft" on a CAD payout to choose the rail explicitly. Omit it and the rail is inferred from the recipient shape you send (EFT coordinates ⇒ EFT; Interac email + name ⇒ Interac), falling back to your account’s default CAD routing. An explicit method must match the recipient shape — a contradiction returns missing_field or invalid_field. This mirrors how method already works for USD (ach / wire). See CAD payouts.

July 27, 20262026-07-27-beneficiary-cad-eft-usd-bank-address

Saved beneficiaries now carry CAD-EFT and USD bank_address

Saved beneficiaries gained the same recipient fields as the inline payout contract, so you can store a CAD-EFT or USD bank-transfer recipient once and reuse it via beneficiary_id.

ADDED

POST /v1/beneficiaries accepts a CAD EFT recipient. Alongside Interac (interac_email + interac_first_name + interac_last_name), a CAD beneficiary can be saved by bank account: name + institution_number (3-digit) + transit_number (5-digit) + account_number. Presence of the EFT coordinates selects the EFT shape. See beneficiaries.

ADDED

USD beneficiaries accept bank_address. The bank-transfer USD rail’s recipient bank address — bank_address { line1, city, country } — can now be stored on a beneficiary and is replayed on every payout to it. The new fields round-trip on GET /v1/beneficiaries and GET /v1/beneficiaries/{id}.

July 27, 20262026-07-27-cad-eft-usd-bank-address

CAD via EFT and USD bank_address added to the payout recipient contract

The payout recipient contract gains the fields two more rails need. Which rail your CAD or USD payouts use is set by your account’s routing configuration — send the recipient shape that matches it.

ADDED

CAD now has an EFT recipient shape alongside Interac. In addition to Interac (email + first_name + last_name), a CAD payout can now identify the recipient by bank account: name + institution_number (3-digit) + transit_number (5-digit) + account_number + bank_name, plus email and address. Send whichever shape matches your account’s configured CAD rail; the wrong shape returns a clear missing_field error. See CAD payouts.

ADDED

USD gains recipient.bank_address for the bank-transfer rail. The bank-transfer USD rail requires the recipient bank’s address — bank_address { line1, city, country } — plus state + zip_code for US recipients. Optional on the ACH/wire rail. See USD payouts.

July 27, 20262026-07-27-customer-validation

Rejected customer creates now fail cleanly and tell you which field

A field-level rejection on POST /v1/customers was being reported as a transient provider error. That left a half-created customer behind that later steps refused to work with, and the real reason — which field was wrong — was discarded.

FIXED

A rejected identity payload now returns 422 validation_failed instead of provider_error, and leaves no half-created customer behind. Previously this class of rejection was classified as a provider fault, so the partially-created record was kept. That record was returned by GET /v1/customers and GET /v1/customers/{id} as a normal customer, but every follow-up call — file upload, KYC submission, virtual-account creation — rejected it with customer_not_provisioned, several steps and potentially a long time after the actual mistake. The create is now all-or-nothing: the record is rolled back, and you may retry with the same customer_reference and a fresh Idempotency-Key. The recovery_hint no longer suggests using a new customer_reference, which was the opposite of what works.

ADDED

error.detail.fields names each rejected field on a 422 validation_failed. Each entry is { field, message }, where field is one of the documented create-request fields. The summary message is unchanged, and fields is omitted entirely when no field-level detail is available — so treat it as additive and keep parsing message. See Customers.

July 26, 20262026-07-26-collection-fees

Collection surfaces now report the inbound funding fee

Inbound funding may carry a fee. It was already being charged; it just wasn’t visible on the surfaces you read. It is now — and we’ve corrected a documentation error that described the credited amount as net when it is gross.

CHANGED

Collections now report a real fee_minor, and a new net_minor tells you what you kept. GET /v1/customers/{id}/transactions previously returned "0" as the fee on every collection row. It now returns the funding fee actually deducted for that inflow (fee and tax combined), still "0" when no fee applies. A new net_minor field carries amount_minor − fee_minor on collections; on payouts it equals amount_minor, since a payout fee is charged on top rather than deducted from what the beneficiary receives. amount_minor and total_minor are unchangedamount_minor remains the gross received, and total_minor remains amount_minor + fee_minor. GET /v1/wallets/{id}/funding-events gains the same fee_minor and net_minor fields.

ADDED

wallet_funded now carries feeMinor and netMinor. Both are minor-unit strings, present on every rail — virtual-account and Interac — and "0" / equal to amountMinor when no fee applies. amountMinor keeps its existing meaning and value. If you credit an end user off this webhook, credit them netMinor. See Webhooks.

FIXED

Documentation correction: amountMinor is the gross, not the net. The wallet_funded field reference described amountMinor as “Net amount credited to your wallet”. That was wrong. It is the full amount the sender transferred; where a funding fee applies it is charged as a separate debit, so your balance moves by the net while amountMinor shows the gross. Anyone crediting an end user off amountMinor and treating it as net would over-credit by exactly the fee on every inflow. Use netMinor.

July 20, 20262026-07-20-idempotency-scope

Idempotency keys are scoped per environment — and never expire

Sandbox and live are now fully separate idempotency namespaces, and we’ve corrected what this page previously said about key expiry.

CHANGED

Idempotency keys are scoped per merchant, per environment. Sandbox and live are separate namespaces, so a key that exists in sandbox no longer blocks or replays the same key in live. This matters if you derive keys from your own business references — order IDs, invoice numbers, payroll rows — which naturally repeat between a file you tested in sandbox and the one you run in live. Previously that collision could return your sandbox result to a live request. Applies to payouts, batches, conversions and partner swaps. See Idempotency.

FIXED

Documentation correction: keys do not expire. This page previously stated that an idempotency key expired after 24 hours and could then be reused. That was never the case — keys are retained indefinitely for your account in that environment, so reusing one later returns the original cached response (or 409 if the body differs). Do not plan to recycle keys after a waiting period. Take particular care if you use business references such as invoice or payroll IDs as keys: replaying a file that reuses the same references returns each row’s original response instead of paying again.

July 20, 20262026-07-20-payout-capability

API keys now carry an explicit payout-create capability

Creating payouts through the API now requires the key to be explicitly enabled for it, matching how bulk and cross-currency capabilities already work.

ADDED

“Create payouts” capability on API keys. POST /v1/payouts now requires the key to carry the payout-create capability. Tick Create payouts when creating a key in API keys, or toggle it on an existing key. Without it the request returns 403 permission_denied with a message naming the capability. Enabling it requires the payout.create permission — a teammate without that permission can still create the key, but someone with it must switch the capability on. Turning it off requires no permission. Cross-currency payouts require this capability and payout.fx on the same key.

CHANGED

Existing keys were migrated automatically — no action needed. Every active key was enabled based on the permissions of whoever created it, so integrations running today continue unchanged. Newly created keys default to off until you tick the box.

July 12, 20262026-07-12-conversions

New POST /v1/conversions — convert between your wallets

Move money between your own wallets at the rate Swappr quotes. POST /v1/conversions debits one currency and credits another in a single call, in either direction — valid only when an active rate is available for the pair.

ADDED

POST /v1/conversions. Send from_currency, to_currency, and exactly one of from_amount_minor (source basis — debit) or to_amount_minor (target basis — credit). The response (object: "conversion") returns the id, both amounts, the applied rate, and status: "completed". The rate is applied at execution; a stale rate is rejected (fx_rate_stale) with no money moved. The API key must carry the conversion capability (enable it in API keys); Idempotency-Key is required. See Conversions.

ADDED

POST /v1/conversions/quote. A read-only, advisory preview — resolves the active rate and derives the exact debit/credit at the given basis. No money moves, no capability gate (indicative: true, with as_of). See Preview conversion.

July 9, 20262026-07-09-key-expiry

Live API keys now expire on a schedule you choose

Live API keys now carry a mandatory expiry set when you create the key. Sandbox keys are unaffected — they never expire.

ADDED

Mandatory live-key expiration. When you create a live key you now pick how long it stays valid: 30, 90, 180, or 365 days. The expiry is immutable once set, and rotating a live key always assigns a fresh duration from the moment of rotation (it never inherits or extends the old expiry). You’re reminded at T-14 days, T-7 days, T-3 days, T-12 hours, and T-2 hours before a key expires.

ADDED

New key_expired error code (401, authentication_error). A request made with an expired live key returns key_expired with the exact expiry timestamp in the message — distinct from invalid_api_key, so your error handling can prompt a rotation rather than treating it as a bad key. See Key expiration.

July 3, 20262026-07-03-payout-quote

New POST /v1/payouts/quote — preview fees before you send

Preview what a payout will cost before you send it. POST /v1/payouts/quote returns your account’s actual applicable payout fee (plus tax, levy, and total debit) and, for a cross-currency quote, the derived beneficiary amount, source debit, and rate. It’s read-only and advisory — no money moves and no payout is created.

ADDED

POST /v1/payouts/quote. Send the same amount fields as a payout (currency, optional funding_currency, exactly one of amount_minor / funding_amount_minor, optional fee_inclusive / amount_basis) — no recipient, no send-time guards. The response (object: "payout_quote") carries fee_minor + tax_minor + levy_minor + total_debit_minor, plus an fx block on cross-currency quotes with source_debit_minor, converted_minor, fee_source_minor, rate, as_of, and indicative: true. Read tier — a valid key is the only requirement. The fee is exact for your account; the rate + default-route levy are indicative. See Preview fees.

July 2, 20262026-07-02-xc-payouts-page

Cross-currency payouts now have their own reference page

Cross-currency payouts now have their own top-level topic in the API reference — a peer of Payouts, Batches, and Wallets — instead of a section under NGN payouts. Documentation-only reorganisation; the POST /v1/payouts funding_currency contract is unchanged.

CHANGED

Cross-currency payouts moved to their own top-level topic. The full method — the destination / source amount options, the fx response block, fee_inclusive, rate-freshness behaviour, and the error table — now lives at Cross-currency payouts. No request, response, or error change.

July 2, 20262026-07-02-fx-source-method

Cross-currency payouts: source-currency (fixed-send) method

Cross-currency payouts now accept a source method — name what leaves your funding wallet (funding_amount_minor) and we derive the beneficiary amount, instead of naming the beneficiary amount (amount_minor) and deriving the debit. Same POST /v1/payouts endpoint; the destination method is unchanged and fully backward-compatible.

ADDED

funding_amount_minor (source method). Anchor a cross-currency payout on the amount leaving your funding wallet. XOR with amount_minor (send exactly one). Optional fee_inclusive chooses fee placement — false/default = fee on top, true = fee carved from funding_amount_minor. Optional min_receive_minor floors the derived beneficiary amount (the source-method mirror of max_debit_minor). An optional amount_basis ("source" / "destination") label self-documents the call.

ADDED

fx.fee_source_minor in the response. The payout fee expressed in your funding currency, so a source-anchored merchant reconciles the fee in the currency they sent. The destination-currency fee stays on the top-level fee_minor + tax_minor.

ADDED

New validation errors (all 400 unless noted): ambiguous_amount (both amount fields), amount_required (neither), guard_field_wrong_method (a guard used with the wrong method), amount_basis_mismatch (the amount_basis label disagrees with the fields), plus 422 min_receive_not_met and funding_below_fee for the source method.

July 2, 20262026-07-02-rates

New GET /v1/rates endpoint — read your FX rates

Read the effective exchange rates for your account before you build a cross-currency payout. The rate returned is the same one the funding_currency flow applies.

ADDED

GET /v1/rates. Returns your effective FX rates in your environment — one row per currency pair as { from_currency, to_currency, rate }, where rate is a decimal string matching the fx.rate field on a payout. Your negotiated override wins over the platform default; you only ever see your own account’s rates. Optional from / to query params narrow to a pair (an unknown code → 400). Rates are indicative — the rate applied to a payout is the one active at the time it’s processed.

July 2, 20262026-07-02-per-key-fx

Cross-currency payout access is now per-key

Enabling funding_currency on POST /v1/payouts is now a per-key capability rather than one inherited from the key creator’s permissions. Request contract, error codes, and statuses are unchanged.

CHANGED

The cross-currency gate now reads a per-key setting. Enable cross-currency payouts per key on the API keys page (enabling it requires the payout_fx permission); a key without it that sends funding_currency still gets 403 (permission_denied). Account-level enablement is self-serve — request access under Settings → FX payouts and an admin reviews and approves.

July 1, 20262026-07-01-name-enquiry

Name-enquiry routing hardened + stable opaque resolved_by_provider

We hardened how POST /v1/name-enquiry picks a rail and made resolved_by_provider a stable opaque code. No change to the request contract or any other response field.

CHANGED

resolved_by_provider now returns a stable opaque rail code. It is a diagnostic-only value (for example rail-01), not a bank or provider name, and is null when the result is served from cache. As before, it may change without notice — don’t branch on it.

FIXED

Name resolution now routes through the standard eligibility cascade. Omitting the optional bank_name no longer affects which rail resolves the account — the endpoint resolves the canonical bank name internally and walks the same eligible-rail cascade used for payouts. This fixes lookups (both 3-digit and 6-digit bank codes) that could intermittently fail. The request contract is unchanged (account_number + bank_code required, bank_name optional).

June 29, 20262026-06-29-fx-payouts

Cross-currency payouts — fund an NGN payout from another wallet

Pay a beneficiary in one currency while funding the payout from a different wallet currency — e.g. pay ₦ from your CAD wallet — in a single POST /v1/payouts call.

ADDED

funding_currency on POST /v1/payouts. Set it (different from currency) to fund the payout by converting from that wallet at the prevailing rate. The beneficiary receives exactly amount_minor; the converted amount + fee are debited from your funding wallet. Optional max_debit_minor caps the funding-wallet debit (rate-movement protection). Requires the payout_fx permission + account enablement. The response gains an fx block (funding_currency, funding_wallet_id, source_debit_minor, rate, converted_minor, conversion_id). If the payout fails after conversion, the converted funds stay in your destination wallet. See Cross-currency payouts.

June 27, 20262026-06-27-auto-convert-webhook

New wallet_auto_converted webhook event

A new webhook event fires whenever one of your wallets auto-converts into another of your currencies.

ADDED

wallet_auto_converted webhook event. Fired when a wallet auto-converts (per-inflow on deposit, or a whole-balance sweep at a threshold) into another of your currencies. The payload carries both legs (source debit + destination credit); key idempotency on conversionId. Auto-convert is enabled by the merchant in the dashboard, not via the API. See Webhooks.

June 24, 20262026-06-24-currencies

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.

ADDED

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.

CHANGED

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.

FIXED

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.

June 24, 20262026-06-24-create-recovery

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/....

CHANGED

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.)

FIXED

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.

June 18, 20262026-06-18-phone

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.

CHANGED

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.
June 18, 20262026-06-18-request-id

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.

ADDED

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.

June 18, 20262026-06-18-doc-record

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.

ADDED

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.

ADDED

Document upload records. Uploading an identity document (/files/files/attach) is now recorded against the customer. No request/response shape changed.

June 17, 20262026-06-17-fields

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.

CHANGED

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.
June 17, 20262026-06-17

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.

CHANGED

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.

FIXED

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.

CHANGED

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_headers verbatim, no content_type) did not actually complete — attach rejected the file — so adding the required field unblocks the happy path rather than breaking a working one.
  • If you were already setting Content-Type manually on the PUT, you can keep doing so, or drop it and just echo required_headers now that they include it; either way, add content_type to the /files request.
June 15, 20262026-06-15

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.

CHANGED

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).

CHANGED

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.)

CHANGED

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.

CHANGED

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.

FIXED

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 bad currency / 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.
June 8, 20262026-06-08

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.

CHANGED

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).

CHANGED

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.

CHANGED

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.

CHANGED

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.

June 6, 20262026-06-06

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.

ADDED

Customers APIPOST /v1/customers plus KYC submission, document upload, international account (VIBAN) issuance, and a unified GET /v1/customers/{id}/transactions history. See the Customers reference.

ADDED

FX beneficiariesPOST /v1/beneficiaries now accepts GBP / USD / EUR recipients via a nested bank + address envelope, with an optional external_reference. See the FX shape.

ADDED

FX payoutsPOST /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).

ADDED

FX gating error codePOST /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.

CHANGED

Standardized payout webhookspayout.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.

ADDED

wallet_funded customer block — VA-credit webhooks for customer-owned accounts now carry a customer block.

ADDED

Per-customer wallet filterGET /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 unchangedbeneficiary_id / customer_id / customer_reference are all optional additions. Non-FX integrations need no changes.
  • Idempotency contract preservedPOST /v1/beneficiaries still returns 200 with created: false on an idempotent collision (FX rows dedup the same way as NGN). No 409.
  • Webhook payloads are additive — the customer block 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/payouts are soft-deprecated in favor of referencing a saved beneficiary by beneficiary_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 pass beneficiary_id.

Migration notes

  1. FX beneficiaries: if you build FX payouts inline today, start creating beneficiaries via POST /v1/beneficiaries (GBP example uses bank.sort_code like "200000") and pass beneficiary_id on the payout.
  2. Customer transactions: grant the customer_transaction_view permission to any API key that needs GET /v1/customers/{id}/transactions or the ?customer_id= wallet filter — without it those calls return 403.
  3. Webhooks: route payout events on the dotted event value (payout.paid) in the body, or the underscore form (payout_paid) in the X-Swappr-Event header — pick one consistently. Keep handlers idempotent (key payouts on reference).