Skip to content
Seismic Orchestration

POST /customers/:id/accounts/fiat/virtual

Ask Seismic to provision a virtual bank account for this customer in the chosen currency. The Account materializes immediately with status: "pending" (its id is stable and can be referenced right away), but the generated account-number / IBAN lands asynchronously once provisioning completes. Until then the account has no details block at all. The account flips to status: "active" and details appears when the real bank details are issued.

See Virtual accounts for the currencies available. A virtual create in any other currency returns a 400 validation error. Register an existing bank account with POST /customers/:id/accounts/fiat/external instead.

Shape and rails are both inferred from currency:

currencyinferred shapeinferred rails
USDus["ach", "wire", "swift"]
EURiban["sepa"]
GBPuk["fps", "bacs"]
AEDiban["npss"]

A future iteration will accept optional shape / rails overrides. For now currency is the sole driver of both.

Shared semantics live on the create-account index.

Per-currency clearance

An approved customer is not automatically cleared for every currency. Virtual-account access is granted per currency, on its own timeline, and a customer already collecting in one currency can still be waiting on another.

Requesting a currency the customer isn't cleared for returns 409 VirtualAccountNotApproved, with currency and status on the error body:

{
  "ok": false,
  "error": {
    "code": "VirtualAccountNotApproved",
    "currency": "EUR",
    "status": "not_started",
    "message": "no EUR virtual account is approved for this customer (approval status `not_started`)"
  }
}

Retrying does not change the answer — the clearance has to be granted first. GET /customers/:id/virtual-account-approvals lists every currency available to this customer with the status of each, so read it before offering a choice.

Choosing the bank

Pass bank to say where the account is held. Omit it and a bank is chosen for you, which is what every account created before this field existed does — so this is opt-in and changes nothing for existing integrations.

GET /customers/:id/virtual-account-banks?currency=USD lists the banks this customer can pick, and bank takes one of the bank values it returns:

{
  "currency": "USD",
  "bank": "PORTAGE_BANK"
}

Read that list rather than hardcoding one — which banks a customer can be issued an account at depends on the clearances they hold. An empty list means no bank is cleared for them to choose, which usually just means there is no choice to make (omit bank). The exception is a currency with its own virtual-account review: where that review has not cleared the bank the account would open at, and no other bank is cleared for the customer, a create naming no bank is refused with VirtualAccountNotApproved. Where another bank is cleared, a create naming no bank opens there — the first bank that list offers — unless it replays an earlier request, duplicates an account the customer already holds, or restores one they deleted, which are answered first.

Where the virtual account has its own review, naming a bank that review has not cleared returns 409 VirtualAccountNotApproved, with the review's status (pending, rejected or not_started), rather than VirtualAccountBankNotAvailable:

{
  "ok": false,
  "error": {
    "code": "VirtualAccountNotApproved",
    "currency": "USD",
    "status": "pending",
    "message": "no USD virtual account is approved for this customer (approval status `pending`)"
  }
}

The one exception is a request that restores an account the customer deleted at that bank: a restore opens nothing new, so it is not held to the review.

Naming a bank the customer cannot be issued an account at returns 409 VirtualAccountBankNotAvailable:

{
  "ok": false,
  "error": {
    "code": "VirtualAccountBankNotAvailable",
    "bank": "LEAD",
    "currency": "USD",
    "message": "`LEAD` is not a bank this customer can be issued a USD virtual account at"
  }
}

Nothing is created when this is returned. Retrying with the same bank does not change the answer — pick one the list offers, or omit bank.

Auto-liquidation

Pass liquidation to have fiat credited to this virtual account automatically converted and settled to an existing crypto account. Reference the crypto account by its account_id. Each incoming deposit is converted into that account's currency and settled there. The crypto account's own currency determines the conversion target, so you supply only the account_id.

The destination must be the customer's own wallet: a crypto account registered as a third party's is rejected. Beyond that, not every crypto account of the customer qualifies, and not every currency can auto-liquidate at all. Eligibility is resolved server-side from where the new account and each candidate destination are held, so read it rather than inferring it: GET /customers/:id/liquidation-destinations?currency=USD returns supported for the currency plus the destinations accepted as liquidation.account_id. Any other account is rejected here. The answer can change as accounts finish provisioning, so resolve it at create time.

The target (and fee) can be changed later via PUT /accounts/:id/liquidation. Where the account supports it, the same endpoint can also enable auto-liquidation on an account created without a liquidation block, and DELETE /accounts/:id/liquidation turns it off.

Uniqueness

A customer can hold one virtual bank account per bank in a currency. So a customer already collecting EUR at one bank can be issued a second EUR account at another, and the payers already paying into the first are unaffected. It is the same bank however the account came to be opened, so routing a create elsewhere does not earn a second account at a bank the customer already collects at.

Where an account auto-liquidates to is not part of this. An account is a place a payer sends money, and liquidation.account_id is a setting on it that PUT /accounts/:id/liquidation can change at any time — so to collect into one bank and settle somewhere new, retarget the account you have rather than creating a second one.

Creating a second account at a bank the customer already holds one at returns a 400 naming the existing account — use that account, or delete it first to re-create it. Omitting bank asks for an account in the currency rather than one at a particular bank, so it is answered by whichever account the customer already holds; name the bank you want to collect at and only an account already held there counts as the same one.

Not every bank holds more than one account for a customer in a currency. Where it does not, a second is refused however it differs — read the 400 rather than retrying with another bank.

Re-creating an account after a delete gives you the same account back, not a second one: a create whose configuration exactly matches an account you deleted restores it — same id, same bank details, and an account.created webhook announcing it is usable again. Change anything the account stores (the bank, the liquidation destination, the developer fee) and it is a different account, so a new one is provisioned. Note the asymmetry: the liquidation destination does not make a live account a second account, but it does have to match for a deleted one to be restored, because a restore does not reconfigure the account it hands back. See getting a deleted account back.

Developer fee

Set liquidation.developer_fee_bps to charge your own fee on the funds auto-converted through this account, in basis points (100 = 1%, 50 = 0.5%; at most 10000). It is applied to each conversion. It's your own markup on top of the conversion. Seismic's own fees are separate. Omit it to charge nothing.

The fee is supported for USD auto-liquidating accounts only today. Setting it on any other currency returns a 400. It's echoed back on the account's liquidation block so you can confirm what was configured.

Request body

FieldTypeRequiredNotes
currencystring | numberyesCurrency the virtual account denominates in. By name ("USD", "EUR", …) or numeric id.
bankstringnoBank to hold the account, from GET /customers/:id/virtual-account-banks. Omit to have one chosen for you.
liquidationobjectnoOpt into auto-liquidation. Omit for a plain virtual account.
liquidation.account_idstringcond.Required when liquidation is present. id of the crypto account incoming fiat settles to.
liquidation.developer_fee_bpsnumbernoYour fee on the auto-converted funds, in basis points (100 = 1%). USD only, at most 10000. Omit for no fee.

Headers

HeaderValue
Idempotency-KeyOptional, up to 256 characters. Re-POSTing the same (org, key) returns the existing creation request unchanged.

Response 200

{
  "request": {
    "id": "req_…",
    "customer_id": "2f10…",
    "kind": "fiat",
    "shape": "us",
    "origin": "virtual",
    "currency": "USD",
    "rails": ["ach", "wire", "swift"],
    "status": "completed",
    "idempotency_key": "abc-123",
    "account_id": "5e3a…",
    "created_at": "2026-05-20T19:45:00Z",
    "updated_at": "2026-05-20T19:45:00Z"
  },
  "account": {
    "id": "5e3a…",
    "customer_id": "2f10…",
    "kind": "fiat",
    "shape": "us",
    "origin": "virtual",
    "currency": "USD",
    "rails": ["ach", "wire", "swift"],
    "status": "pending",
    "created_at": "2026-05-20T19:45:00Z",
    "updated_at": "2026-05-20T19:45:00Z"
  }
}

The account is usable as a reference immediately (attach its id to quotes), but it has no deposit instructions to show yet — details is absent until the bank details are issued. Watch for status: "active" (poll GET /accounts/:id or subscribe to the account.updated webhook), then read the coordinates from details.

Errors

  • 400 InvalidAccountFields currency: currency isn't a fiat kind, or its inferred shape / default rails aren't yet supported.
  • 400 InvalidAccountFields liquidation.account_id: no crypto account with that id exists for this customer.
  • 400 Validation: the referenced crypto account hasn't finished provisioning yet, so it can't be used as a liquidation target.
  • 400 Validation: the referenced crypto account is registered as a third party's; the destination must be the customer's own wallet (see First-party destinations).
  • 400 Validation liquidation.developer_fee_bps: the fee is above the 10000 bps cap.
  • 400 Validation: liquidation.developer_fee_bps was set on a non-USD account (the fee is USD-only today).
  • 400 Validation: the customer already has (or is being provisioned) a virtual bank account with this currency and liquidation destination — see Uniqueness. The message names the existing account or in-flight creation request.
  • 409 VirtualAccountNotApproved: the customer is verified, but isn't cleared for a virtual account in this currency — see Per-currency clearance.
  • 404: unknown currency.

Example

curl -X POST "$SEISMIC_ORCHESTRATION_URL/v0/customers/2f10…/accounts/fiat/virtual" \
  -H "Api-Key: $SEISMIC_ORCHESTRATION_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "content-type: application/json" \
  -d '{
    "currency": "USD"
  }'

With auto-liquidation

curl -X POST "$SEISMIC_ORCHESTRATION_URL/v0/customers/2f10…/accounts/fiat/virtual" \
  -H "Api-Key: $SEISMIC_ORCHESTRATION_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "content-type: application/json" \
  -d '{
    "currency": "USD",
    "liquidation": {
      "account_id": "5e3a…"
    }
  }'

With auto-liquidation and a developer fee

A 0.5% fee (50 bps) on everything auto-converted through the account:

curl -X POST "$SEISMIC_ORCHESTRATION_URL/v0/customers/2f10…/accounts/fiat/virtual" \
  -H "Api-Key: $SEISMIC_ORCHESTRATION_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "content-type: application/json" \
  -d '{
    "currency": "USD",
    "liquidation": {
      "account_id": "5e3a…",
      "developer_fee_bps": 50
    }
  }'