KYC flow
Most flows need the user's KYC level checked before you create an order. Two endpoints do the work: Get user KYC state tells you what is needed, Submit user KYC satisfies it.
You have a choice about who collects the documents. Either you collect them and post them yourself, or you send the user a link and we collect them. The check, the rules and the result are the same; only step 5 differs.
The short version
Call Get user KYC state with the user's email, the order's country, and the amounts of the order you are about to create.
If
isNgBvnBlockedistrueand you are creating a Nigerian on-ramp, stop — handle the BVN gate first (below). It is a separate check fromrequiredKycType.If
requiredKycTypeisnull, go to quoting.Otherwise compare
requiredKycTypewithpassedKycType. Already at that tier or above? Proceed.Not yet? Either collect the documents yourself, or send the user a hosted link:
Collect them yourself. Pick the document from
kycDocumentswhosetypematchesrequiredKycType, collect itsrequiredFields, and post it to Submit user KYC.Let us collect them. Send the user
kycUrls[requiredKycType]from the same response. See Letting Fonbnk collect it.
Wait until
passedKycTypereaches the tier — by polling, or with thekycwebhook. This is the same either way.
Letting Fonbnk collect it
kycUrls on the Get user KYC state response holds one link per tier. Send the user the one that matches requiredKycType. The link opens our widget, asks for the email we already hold for them, sends a one-time code to it, and then shows the document form for that tier.
You do not build a form, you never handle identity documents, and you read the result exactly as before: poll passedKycType, or take the kyc webhook.
Send the user back to your site with callbackUrl, and relabel the button with callbackBtnText. Both are query params on Get user KYC state, and they are baked into the links. The button appears once the user has submitted a check.
What can come back with no link
A tier with no enabled document in that country.
kycUrls.advancedis absent in Nigeria today, because the basic BVN is the only enabled Nigerian document. An absent field means the tier cannot be satisfied there, not that the user has passed it.A user at
reachedKycLimit. The page could show them nothing but a support message, so no link is issued.A project with no active URL signature secret.
messagenames the fix.
Two things the user may see that you did not ask for
In Nigeria, an
advancedlink shows the BVN form first. The Nigerian BVN gate is separate from the tier, so a user with no BVN on file is asked for it before anything else. They then do the advanced check. That is two records and two webhooks, withpassedKycType: "basic"in between. A user who isisNgBvnSupportLockedcannot self-serve the BVN at all, so send them to support rather than a link.The user can change the country they verify in. The page offers our supported KYC countries. Verifying in a country other than the order country forces advanced, which is the same rule the API applies.
Watch for the traps
reachedKycLimit does not clear by waiting. It counts every verification attempt on file that has not been voided — approved and rejected ones included, not just in-flight ones. Three attempts and the user can never submit again until support voids one. When it is true, route the user to support rather than telling them to wait.
Poll passedKycType, not currentKycStatus. Some historical records carry a status value outside the documented set, so a loop that waits for currentKycStatus === "approved" can hang. Waiting for passedKycType to reach the tier you need is the reliable test.
An advanced-verified user can be BVN-blocked with nothing to submit. requiredKycType is null for anyone who has passed advanced, but the Nigerian BVN gate is separate and can still be set. Such a user cannot self-serve — submitting any document is refused with "User already passed advanced KYC". Send them to support.
Reading kycSettings yourself
You only need this if you cannot send the amounts — for a pricing page with no amount yet, say.
Each entry carries an operationType, a currencyType and the type (tier) it demands. {deposit, fiat} is the on-ramp (the user pays fiat in); {deposit, crypto} is the off-ramp (the user sends crypto in). Match the entry against the leg you care about, then:
Per-order rule —
minandmaxare set. It fires when that leg's USD value is in[min, max).maxmay be the string"Infinity".Aggregate rule —
maxAmountUsdand/ormaxOrdersCountare set. It fires when the user's lifetime successful volume or count in that bucket, plus the order you are pre-checking, exceeds the threshold. The order counts before it exists.An entry can be both at once. Both branches are evaluated.
When several rules fire, the highest tier wins.
Two rules that surprise people
Off-ramp KYC is a per-country switch, and mostly off. When a country does not require KYC to sell crypto, its {deposit, crypto} rules are dropped from kycSettings before you see them. That is the only filtering applied — the on-ramp rules always come back and you still have to match them against your leg yourself. offrampKycRequired tells you which case you are in. Today South Africa is the only country where it is on, but read the flag rather than assuming.
Basic does not cross borders. A basic pass counts only in the country it was earned in — passedKycCountryIsoCode says where. A user with passedKycType: "basic" ordering elsewhere gets requiredKycType: "advanced". Advanced is global.
The Nigerian BVN gate
A Nigerian order needs a BVN on file, and this is checked separately from requiredKycType.
isNgBvnBlockedis computed without a direction. Honour it on an on-ramp. On an off-ramp, honour it only whenofframpKycRequiredistruefor that country — and for Nigeria it isfalsetoday, so a Nigerian off-ramp is not gated even though the flag is set.isNgBvnSupportLockedmeans the user has a basic pass that is not an approved Nigerian BVN. That includes a basic earned in Nigeria with a NIN or voter ID, not only one earned abroad. They cannot self-serve the BVN — send them to support.
Response fields
requiredKycType("basic" | "advanced" | null) — the tier this order needs.nullmeans nothing to do. Branch on this.passedKycType("basic" | "advanced" | undefined) — the highest tier the user has passed.passedKycCountryIsoCode(string | undefined) — where that pass was earned.reachedKycLimit(boolean) — three un-voided attempts on file. Support only.currentKycType/currentKycStatus/currentKycStatusDescription— the latest submission's tier, status and reason. Treat the status set as open-ended.currentKycPhase— reserved for a Nigerian two-phase advanced flow that is currently switched off. Not emitted today.kycDocuments— the enabled documents for the selected country, each with_id,type,title,valueandrequiredFields.kycUrls({ basic?: string; advanced?: string } | undefined) — hosted KYC links, one per tier the country can satisfy.kycSettings— the country's rules, with the off-ramp rules dropped when the switch is off.offrampKycRequired(boolean) — whether this country requires KYC to sell crypto.isNgBvnBlocked/isNgBvnSupportLocked(boolean) — the Nigerian BVN gate.message(string | undefined) — present when KYC is switched off for your account, and when a hosted KYC link could not be signed.
Sample response
A brand-new Nigerian user, pre-checked for a $120 on-ramp. Optional fields with no value are omitted rather than returned as null.
One document, because BVN is the only one enabled for Nigeria. One link, for the same reason. One rule, because Nigeria's crypto-deposit rule was dropped — offrampKycRequired is false. And isNgBvnBlocked is true because this user has no BVN on file.
Submitting
Basic documents take a name, a date of birth and an ID number. Advanced documents take base64 images instead of the number — image_type_id 2 for the selfie, 3 for the document front, 7 for the back — with the whole body under 10 MB. Full request examples are on Submit user KYC.
Last updated

