Skip to content

Customers and verification

A ramp moves money through a regulated provider, so the provider needs to know who it is dealing with. Textile keeps one customer record per wallet per provider. You register it once, and every later transfer from that wallet is booked against it.

A buy cannot run without a registered, verified person: the provider's default tier refuses ordinary deposit sizes. A sell works without one, on the provider's business profile, so the details form is optional there.

Proof of control

Every endpoint on this page names a wallet, and every one of them requires proof: an EIP-712 signature by that wallet. It is the same challenge the RFQ API uses, documented under Proof of control. Sign TakerControl with taker set to the wallet and chainId set to the chain you pass in the body, and send { nonce, issuedAt, signature } as proof.

json
{
  "provider": "busha",
  "wallet": "0xYourWallet",
  "chainId": 56,
  "proof": { "nonce": "0x…", "issuedAt": 1756400000000, "signature": "0x…" }
}

The reason is that wallets are public. Without a signature anyone could read a stranger's contact details off their address, or repoint that wallet's provider profile at one they control. So there is no unauthenticated read of a customer at all, and there is no GET. A missing or bad proof is a 401 with details.reason set to one of the proof_of_control_* reasons. One signature is valid for 60 seconds, so sign once and reuse it across the details, KYC and transfer calls in a session.

Register or update a person

POST /v2/ramp/customers
json
{
  "provider": "busha",
  "wallet": "0xYourWallet",
  "chainId": 56,
  "proof": { "…": "…" },
  "acceptedTerms": true,
  "firstName": "Ada",
  "lastName": "Obi",
  "email": "ada@example.com",
  "phone": "+2348012345678",
  "birthDate": "14-02-1990",
  "address": {
    "line1": "12 Marina Road",
    "line2": "Flat 3",
    "city": "Lagos",
    "state": "Lagos",
    "postalCode": "101233"
  }
}
FieldNotes
providerA provider id from GET /v2/ramp/providers.
acceptedTermsMust be true. The provider is told the person consented to its terms, so collect that consent in your UI first. Without it the provider is never asked to register them.
firstName, lastName, email, phoneAll four are needed to register an individual.
birthDatedd-mm-yyyy.
addressResidential. line1, city, state and postalCode are required when an address is sent; line2 is optional. Country is the corridor's.

The provider needs every one of those to open an individual profile. Send fewer and the call still succeeds, but the response says the profile was not created and why:

json
{
  "customer": {
    "id": "cm…",
    "kycStatus": null,
    "hasProviderCustomer": false,
    "rejectionReason": "Busha needs a date of birth and a residential address to register you."
  }
}

hasProviderCustomer: true means the provider holds a profile for this wallet and a buy can proceed to verification. false means the wallet can still sell, on the business profile, but a buy will be refused with ramp_customer_required until you register them properly. Show rejectionReason to the user; it is the provider's own wording, or ours naming the missing field.

Calling this again for the same wallet updates the profile. Textile stores the names, email and phone. The date of birth and address are forwarded to the provider and not kept, so an update that changes them has to send them again. Registration is serialised per wallet: two concurrent calls for the same wallet produce one profile.

Read the stored details

POST /v2/ramp/customers/details

Body: provider, wallet, chainId, proof. Returns what we hold, so an edit form can open filled in.

json
{
  "details": {
    "firstName": "Ada",
    "lastName": "Obi",
    "email": "ada@example.com",
    "phone": "+2348012345678",
    "registered": true
  }
}

details is null when nobody has registered this wallet. Date of birth and address are never returned because they are never stored.

Submit identity documents

POST /v2/ramp/customers/kyc

Hands the documents to the provider and asks it to review them. Textile forwards them and keeps nothing, not even in logs.

json
{
  "provider": "busha",
  "wallet": "0xYourWallet",
  "chainId": 56,
  "proof": { "…": "…" },
  "document": {
    "type": "national-id",
    "number": "12345678901",
    "expiryDate": "2030-01-31",
    "imageFront": "<base64>",
    "imageBack": "<base64>"
  },
  "selfieImage": "<base64>"
}
FieldNotes
document.typepassport, national-id or drivers-license.
document.numberThe document number.
document.expiryDateyyyy-mm-dd. Passports and licences have one; a national ID slip does not, so it is optional.
document.imageFront, document.imageBackBase64, at most 4MB each. A data: URL prefix is stripped. imageBack is optional.
selfieImageBase64 selfie photo, at most 4MB.

The wallet must already have a provider profile. The response is the review state as it stands right after submission, normally pending:

json
{
  "kyc": {
    "state": "pending",
    "providerStatus": "awaiting_review",
    "rejectionReasons": [],
    "requirementsDue": [],
    "level": "level_1",
    "canDeposit": false,
    "maxBuy": { "amount": "5000000", "currency": "NGN" }
  }
}

An image over the cap is a 400 naming the field, not a bare 413. Check size and type in the browser before encoding; three 4MB files are a 16MB request once base64 has expanded them.

Read the review state

POST /v2/ramp/customers/kyc/status

Body: provider, wallet, chainId, proof, and optionally fiat (default NGN, used to look up the corridor's deposit cap). Returns { "kyc": … } in the shape above, or { "kyc": null } when the wallet has no customer record at all.

Reviews take minutes to days, and the provider sends no webhook when one finishes, so this is a poll. The app asks every 10 seconds while a review is pending. Textile re-reads the provider at most once every 15 seconds per person and stops re-reading once the state is verified, so polling faster than that returns the same answer. A background job also re-reads pending reviews nobody is watching, so a person who closed the tab still gets the outcome by email if they left an address.

stateMeaningWhat the user sees
unverifiedRegistered, nothing submitted yetThe document form
pendingDocuments handed over, under reviewA waiting screen. Keep polling
verifiedCleared. A buy can runThe transfer form. Show maxBuy as "deposits up to", never as "your limit"
rejectedThe provider declinedThe form again, with rejectionReasons verbatim
expiredWas verified; the provider wants fresh documentsThe document form

maxBuy is the corridor's published single-deposit cap, not a figure for this person. The provider does not expose a per-person limit, so do not present it as one. canDeposit is the provider's own yes/no on whether it will take a deposit from this profile right now. level is the provider's tier label, when it gives one.

Hosted verification

POST /v2/ramp/customers/kyc/link

Body: provider, wallet, chainId, proof. Returns { "url": "https://…" }, a verification page hosted by the provider. This is the fallback when your own capture cannot be used, for example a browser that blocks the camera. The wallet must already have a provider profile; without one this is a 400.

Buying before verification

If you skip the review and go straight to POST /v2/ramp/transfers on a buy, the transfer is refused with 422 kyc_required and the current review state in error.kyc, so you can route to the right screen without a second call. See Ramp errors.