Onboarding your end users
For per-user customer rows that move money on behalf of your end users.
To onboard your own organization (one that holds the API key), see Onboarding your organization.
Prerequisites
- KYB-approved org.
- API key.
Four-step onboarding
- Create the customer with
api.customers.create(...). Polymorphic ontype: individuals carry aKycProfile, businesses carry aKybProfile. The customer lands indraft. - Submit with
api.customers.submit(id). Nothing leavesdraftwithout it — creating a customer files a profile, it doesn't file a verification. If the customer accepted the terms of service in your own UI, pass their IP address, user agent and device fingerprint asendUser— see Device fingerprinting. - Verify: read submission state from
api.customers.state(id). For hosted flows, redirect to the returnedkyc_link.url(orkyb_link.url). For inline flows, verification runs against any documents you've uploaded, or over email. Poll until the status reachesapproved. - Create an account with one of the four
api.customers.create*Account(id, ...)methods, fiat/crypto × virtual/external.
The create response includes the canonical customer id (UUID). Use that for all subsequent calls.
Create a customer
import { } from "./client";
const = await ..({
: "individual",
: {
: {
: "Alice",
: "Liddell",
: "1990-04-12",
},
: {
: "alice@example.com",
},
: {
: {
: "123 Main St",
: "Brooklyn",
: "NY",
: "11201",
: "US",
},
},
},
});
const = .;
// File the verification, then drive it.
await ..();
const = await ..();
if (. === "individual" && ..) {
.. = ...;
}api.customers.state(id) returns a tagged-union envelope. Narrow on env.type before reading env.state.kyc_link (individuals) or env.state.kyb_link (businesses).
Poll verification status
import { } from "./client";
const = await ..();
if (. === "individual") {
.(..); // e.g. "approved"
if (.. === "approved") {
// Safe to create an account + start quoting.
}
}Status values, in order of forward motion:
draft → submitted → under_review → approved
↘ rejected (returned by review — correct and resubmit)
↘ terminally_rejected (declined by review — final)
↘ rejected (correct and resubmit)
↘ terminally_rejected (final)
↘ paused (compliance hold, reversible)
submitted is Seismic's own review of the submission, which runs before verification begins — the hosted link is null until the status moves to under_review. A submission rejected by that review reads rejected with the reason in rejection_reason; correct the profile and submit again. One the review declines reads terminally_rejected and cannot be resubmitted.
If you need a fresh URL (the user closed the tab, the link expired, etc.), re-read it from api.customers.state(id). The latest kyc_link.url / kyb_link.url is always on the state envelope.
Create an account
A customer needs at least one account to move money in or out. All four create endpoints require the customer to be approved first — an earlier status fails with CustomerNotVerified (409), so gate the call on the poll above rather than firing it optimistically. See Verification is required first.
import { api } from "./client";
const { request, account } = await api.customers.createExternalFiatAccount(customerId, {
currency: "USD",
account_holder: {
type: "individual",
first_name: "Alice",
last_name: "Liddell",
address: { country: "US" },
},
details: {
routing_number: "021000021",
account_number: "0123456789",
bank_address: {
line1: "1 Main St",
city: "San Francisco",
state: "CA",
postal_code: "94104",
country: "US",
},
},
});
// For external attaches, `account` is populated synchronously.import { api } from "./client";
const { request, account } = await api.customers.createVirtualFiatAccount(customerId, {
currency: "USD",
});
// For virtual provisioning, `account` is null until provisioning completes.
// Poll api.customers.getAccountRequest(customerId, request.id) until
// request.status === "completed" and request.account_id is set.import { api } from "./client";
const { request, account } = await api.customers.createExternalCryptoAccount(customerId, {
address: "0x742d35Cc6634C0532925a3b844Bc9e7595f8b840",
rail: "base",
currency: "USDC",
account_holder: {
type: "individual",
first_name: "Alice",
last_name: "Liddell",
address: { country: "US" },
},
});import { api } from "./client";
// INR is external-only: register the customer's existing Indian bank account.
const { request, account } = await api.customers.createExternalFiatAccount(customerId, {
currency: "INR",
account_holder: {
type: "individual",
first_name: "Aarav",
last_name: "Sharma",
date_of_birth: "1990-04-12",
address: { country: "IN" },
},
details: {
ifsc: "HDFC0001234",
account_number: "12345678901",
bank_address: {
line1: "1 MG Road",
city: "Mumbai",
state: "MH",
postal_code: "400001",
country: "IN",
},
},
});Currencies / rails accept either the human-readable name ("USDC", "base") or the numeric id from the seed JSON. The currency drives the inferred shape on fiat creates.
Tying it all together
import { } from "./client";
async function (: string) {
// 1. Create the customer.
const = await ..({
: "individual",
: {
: {
: "Alice",
: "Liddell",
},
: {
: ,
},
},
});
// 2. File the verification, then drive it if there's a hosted URL.
await ..(.);
const = await ..(.);
if (. === "individual" && ..) {
.. = ...;
}
// 3. Once approved, attach a USDC wallet. Earlier statuses fail with
// CustomerNotVerified (409).
if (. === "individual" && .. === "approved") {
await ..(., {
: "0x742d35Cc6634C0532925a3b844Bc9e7595f8b840",
: "base",
: "USDC",
: {
: "individual",
: "Alice",
: "Liddell",
: { : "US" },
},
});
}
return .;
}