API referenceCustomers

Customers

Customers are the verified end-users you provision international accounts for. Each customer is KYC-verified once, then issued one or more international virtual accounts (GBP / USD / EUR) or a CAD Interac routing address. Inflows and outflows attributed to a customer are queryable as a single transaction history.

Customer endpoints require the international accounts feature on your account. If it isn’t enabled, every customer endpoint returns 403 feature_not_enabled. Contact Technest support to enable it.

The customer object

{
  "object": "customer",
  "id": "cus_xxxxxxxxxxxx",
  "customer_reference": "your-internal-user-id",
  "interac_email": "jane@example.com",
  "status": "verified",
  "env": "live",
  "created_at": "2026-01-15T10:00:00Z"
}

Field notes:

  • id is the Swappr customer id (cus_...). Use it on every nested call (/v1/customers/{id}/...) and as customer_id when attributing a payout.
  • customer_reference is your identifier for this user — pass it on create and we echo it back everywhere. It also dedupes creates (see below).
  • status is one of pending / verified / rejected / closed. Account provisioning and payouts require verified.
  • interac_email is the routing address for CAD Interac e-Transfer. Null for customers who don’t use CAD.

POSTCreate a customer

POST/v1/customers

Creates a customer record and registers them with our banking partner. This does not start verification — the customer stays pending until you upload an identity document (see Upload identity documents and the optional Submit KYC acknowledgement below). Requires an Idempotency-Key header — use a unique value (UUID) per logical create.

Body parameters
typestringRequired

individual or business.

emailstringRequired

Valid email address.

countrystringRequired

ISO 3166-1 alpha-2 (e.g. NG, GB, CA) — must be a supported country.

id_typestringRequired

Government ID type. Allowed values are country-specific (see below).

id_numberstringRequired

The ID number.

tinstringRequired

Tax identification number. Label varies by country (BVN/NIN for NG, SSN/EIN for US, SIN/BN for CA, NINO/UTR for GB) — always passed via tin.

zip_codestringRequired

Postcode — validated against the country’s format.

first_namestringConditional

Required when type=individual. Paired with last_name.

last_namestringConditional

Required when type=individual. Paired with first_name.

business_namestringConditional

Required when type=business.

id_expiry_datestringConditional

ID expiry (YYYY-MM-DD, future date). Required for individuals — the banking partner needs it to provision a virtual account and it cannot be added after verification.

customer_referencestring

Your internal user id. Dedupes creates (see idempotency).

interac_emailstring

CAD Interac routing address.

phonestringConditional

Contact phone. Required for individuals — the banking partner needs it to provision a virtual account. Presence is required; the format isn’t validated.

dobstringConditional

Date of birth (YYYY-MM-DD, a past date). Required for individuals — needed to provision a virtual account; can’t be added after verification.

streetstringConditional

Address component. Required for individuals — needed to provision a virtual account; can’t be added after verification.

citystringConditional

Address component. Required for individuals — needed to provision a virtual account; can’t be added after verification.

statestringConditional

Address component. Required for individuals — needed to provision a virtual account; can’t be added after verification.

id_issue_datestring

ID issue date (YYYY-MM-DD). Optional, but not updatable after create.

⚠️

id_type is country-specific and cannot be corrected after submission. Each country accepts a different set (e.g. NG individuals accept International Passport or Driver’s License, not the NIN slip). If verification is rejected on id_type / id_number / name fields, you must retry with a new customer_reference — the PATCH endpoint can’t fix those fields. Submitting the wrong id_type returns invalid_id_type with the allowed list for that country.

Idempotency & retries

A customer_reference is unique per merchant per env. Creating again with the same customer_reference does not create a duplicate:

  • If the prior attempt was rejected by verification, the same customer_reference is refreshed with your new body and re-submitted (the customer id stays stable for audit).
  • If a prior attempt succeeded, you get the existing customer back.

On a transient provider error (HTTP 502, retry_safe: true in the error detail), retry with the same customer_reference + a fresh Idempotency-Key — the orphan-replay flow resumes from your existing record. On a validation rejection (422), fix the offending field and retry — you may reuse the same customer_reference (the failed record was discarded) with a fresh Idempotency-Key. A validation 422 does not return a customer_id (the half-created row was rolled back).

Errors

CodeHTTPMeaning
missing_field400A required field is absent
country_not_supported400country isn’t in the supported list
invalid_id_type400id_type not accepted for that country/type
invalid_postcode400zip_code doesn’t match the country format
validation_failed422Identity rejected at create — fix the field, then retry reusing the same customer_reference with a fresh Idempotency-Key (no customer_id is returned)
provider_error502Transient provider error — retry with the same customer_reference

Create response

For individual customers returned as pending, the create response adds a next_action and a requirements hint pointing at the document-upload steps — so it’s clear an identity document must be uploaded before verification can start. These fields are purely additive; the base customer fields are unchanged, and they’re absent for business customers (and on list / retrieve / idempotent-replay responses).

{
  "object": "customer",
  "id": "cus_...",
  "status": "pending",
  "next_action": "upload_identity_document",
  "requirements": {
    "identity_document": {
      "required": true,
      "reason": "Verification has not started. Upload an identity document to begin.",
      "steps": [
        "POST /api/v1/customers/{id}/files with { file_category: \"identity\", content_type } to get an upload URL",
        "PUT the file bytes to the returned upload_url, echoing required_headers verbatim (server-side; the URL is not browser-CORS-enabled)",
        "POST /api/v1/customers/{id}/files/attach with { id_file: <file_id> } to attach it and start verification"
      ]
    }
  }
}
Request
curl https://api.swappr.me/api/v1/customers \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "individual",
    "customer_reference": "user_8821",
    "email": "oliver@example.com",
    "country": "GB",
    "first_name": "Oliver",
    "last_name": "Whitmore",
    "id_type": "passport",
    "id_number": "P1234567",
    "id_expiry_date": "2030-01-01",
    "tin": "AB123456C",
    "zip_code": "SW1A 1AA"
  }'

GETList customers

GET/v1/customers

Returns a Stripe-style list of customer objects. Use q to answer “did my user X get provisioned here?”.

Query parameters
limitinteger

1-100, default 50.

starting_afterstring

Cursor (last customer id from the previous page).

statusstring

Filter: pending / verified / rejected / closed.

qstring

Search by customer_reference, interac_email, or name (case-insensitive contains).

Request
curl 'https://api.swappr.me/api/v1/customers?status=verified' \
  -H "Authorization: Bearer sk_test_..."

GETRetrieve a customer

GET/v1/customers/{id}

Returns the customer object.

Request
curl https://api.swappr.me/api/v1/customers/cus_xxxxxxxxxxxx \
  -H "Authorization: Bearer sk_test_..."

PATCHUpdate a customer

PATCH/v1/customers/{id}

Narrow scope — contact + address fields only. Identity fields (id_type, id_number, name) are immutable after submission; to correct those, create a new customer with a new customer_reference.

Returns the updated customer object with the latest verification status.

Request
curl -X PATCH https://api.swappr.me/api/v1/customers/cus_xxxxxxxxxxxx \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+2348023456789" }'

POSTSubmit KYC

POST/v1/customers/{id}/kyc

Optional — this endpoint does not start verification, and you can safely skip it.

For individual customers (type: individual — the customer flow this API serves), verification is document-triggered: it begins automatically the moment you attach an identity document (id_file) — see Upload identity documents below. There is no separate KYC submit for individuals. This /kyc call simply records kycSubmittedAt (and stores any supplemental KYC details you pass) as a clean audit acknowledgement — it is never required for verification to happen.

Business / KYB customers verify differently. A business is onboarded with Technest directly — beneficial owners plus business documents, reviewed in roughly 1–5 business days — a separate process not driven by this endpoint (and not self-serve via this API today). See Individual vs business.

status reflects the partner’s verification state. Verification completes asynchronously (a few minutes, up to ~2h). Watch the customer.status_changed webhook for the transition to verified / rejected, or re-read the customer — status also refreshes from the banking partner on read.

Response
{
  "object": "kyc_submission",
  "status": "pending"
}

POSTUpload identity documents

POST/v1/customers/{id}/files

Two steps — request a signed upload URL, then PUT the file bytes directly:

1. Request an upload URLPOST /v1/customers/{id}/files with the document slot (file_category) and the file’s content_type (e.g. image/jpeg, application/pdf). Returns a file_upload object with a signed upload_url and the required_headers you must echo (these now include the Content-Type for your file).

2. PUT the bytes to upload_url with the required_headers verbatim, from your backend. Do not send your Bearer token — the signed URL authorizes the upload itself.

3. Attach the uploaded filePOST /v1/customers/{id}/files/attach, mapping file_ids to typed document slots (e.g. id_file).

Send the required_headers exactly as returned — they include the Content-Type for the content_type you specified. A wrong or missing Content-Type causes storage to reject the file and the attach step fails.

⚠️

Run the byte-upload from your backend. The signed upload_url is not browser-CORS-enabled — a PUT from a browser or single-page app fails with a CORS Failed to fetch. Perform step 2 server-side (see Backend architecture). Steps 1 and 3 are ordinary authenticated API calls.

Response
{
  "object": "file_upload",
  "file_id": "file_xxxxxxxxxxxx",
  "upload_url": "https://...signed-url...",
  "required_headers": { "Content-Type": "application/pdf" }
}

POSTIssue an international account (VIBAN)

POST/v1/customers/{id}/virtual_accounts

Issues a dedicated international virtual account for a verified customer in one of the supported currencies.

Body parameters
currencystringRequired

One of GBP / USD / EUR.

NGN virtual accounts are not issued for customers this way; CAD uses an Interac e-Transfer routing address (set via interac_email) rather than a virtual account. The customer must have a verified status and the merchant must hold an active wallet in that currency.

The call is idempotent per (customer, currency) — only one virtual account exists per customer per currency. List a customer’s issued accounts with GET /v1/customers/{id}/virtual_accounts.

This account also authorizes the customer’s outbound payouts. In the individual flow a GBP/USD/EUR payout sends from the sender’s own virtual account, so a payout with sender_customer_id set to a customer who has no active VBA in that currency is rejected with sender_account_not_provisioned. Issue the VBA first. (CAD sends via Interac and needs no VBA.) See Individual vs business.

⚠️

A freshly-issued account starts with status: "provisioning" while the banking partner allocates the real account number. While provisioning, account_number is null — do not surface or fund it. Poll GET /v1/customers/{id}/virtual_accounts (or wait for the virtual_account.activated webhook); once status becomes "active" the real account_number is populated. provider_ref is present throughout for correlation.

Request
{ "currency": "GBP" }

GETList customer transactions

GET/v1/customers/{id}/transactions

A unified, audit-grade history of everything attributed to one customer — collections (money received into their international account) and payouts (money sent on their behalf), merged into one time-ordered feed.

Requires the customer_transaction_view permission on the API key, in addition to the international-accounts feature. Grant it under Settings → API Keys. Without it the endpoint returns 403.

Query parameters
limitinteger

1-100, default 25.

starting_afterstring

Opaque cursor from the previous page’s next_cursor.

typestring

Filter: collection or payout.

statusstring

Filter: successful / pending / failed / reversed.

currencystring

Filter to one currency.

from_datestring

YYYY-MM-DD range start, inclusive of the whole day. Paired with to_date.

to_datestring

YYYY-MM-DD range end, inclusive of the whole day. Paired with from_date.

Field reference

FieldTypeNotes
typestringcollection (inflow) or payout (outflow)
idstringThe underlying record id
referencestringFor collections, the ledger entry id (le_...). For payouts, the payout reference (swappr_... / po_...). See the asymmetry note below
amount_minorstringPrincipal in minor units (BigInt-safe string)
fee_minorstring"0" for collections. For payouts, the combined fee + tax in minor units
currencystringISO 4217 code
statusstringRolled-up status — successful / pending / failed / reversed
provider_referencestring | nullnull for collections. For payouts, the NIP session id (or the provider’s internal ref)
senderobject | nullPresent on collections only — currently null (sender capture for customer collections is roadmap)
recipientobjectPresent on payouts — account_name, account_number, bank_code, bank_name
raw_provider_event_idstringCollections only — the underlying provider event id
created_atstringISO 8601 timestamp
⚠️

Reference shape differs by type. Collections carry the internal ledger entry id (le_...) as their reference; payouts carry the human-readable payout reference (swappr_...). Key any reconciliation off type + id rather than assuming a single reference format across both.

What collection covers: only credits that landed via the customer’s international account. A reversed payout shows up in this feed as a payout row with status: reversed — it does not re-appear as a fresh collection. For the raw ledger view of every credit movement attributed to a customer (including reversal credit-backs), use GET /v1/wallets/{id}/transactions?customer_id={id}.

Response
{
  "object": "list",
  "has_more": true,
  "next_cursor": "eyJjIjoiMjAyNi0wNS0wNVQxMjozNDo1NloiLCJpIjoiY3VzX3h4eCJ9",
  "data": [
    {
      "object": "customer_transaction",
      "type": "collection",
      "id": "le_xxxxxxxxxxxx",
      "reference": "le_xxxxxxxxxxxx",
      "amount_minor": "5000000",
      "fee_minor": "0",
      "currency": "GBP",
      "status": "successful",
      "provider_reference": null,
      "sender": null,
      "raw_provider_event_id": "evt_xxxxxxxxxxxx",
      "created_at": "2026-05-05T12:34:56Z"
    },
    {
      "object": "customer_transaction",
      "type": "payout",
      "id": "po_xxxxxxxxxxxx",
      "reference": "swappr_xxxxxxxxxxxx",
      "amount_minor": "2500000",
      "fee_minor": "1500",
      "currency": "GBP",
      "status": "successful",
      "provider_reference": "TRF_xxxxxxxxxxxx",
      "recipient": {
        "account_name": "JANE DOE",
        "account_number": "12345678",
        "bank_code": "200000",
        "bank_name": "Example Bank"
      },
      "created_at": "2026-05-06T09:10:11Z"
    }
  ]
}