Obtain a link for the end user to fill in fifteen fields and upload ID photos on our page—the data never passes through your system.
L1 KYC: Hosted Page and Required Information
You Only Need One Step
POST /v1/kyc/sessions
x-on-behalf-of: <external_member_id>
x-idempotency-key: <UUID>
Receive 201:
{
"hosted_url": "https://api.zinfra.dev/hosted/kyc/kyc_352ffcf7efe94531b3da9f902f2e4348",
"expires_at": "2026-08-14T15:47:53.000Z"
}
⚠
x-idempotency-keyis required (as it is for all write endpoints). Omitting it immediately returns 400
idempotency_key_required—the first error you may encounter in this flow.
⚠
expires_atis an ISO8601 string, not Unix seconds.
Pass this link to the end user through an in-app WebView, SMS, or email. Everything else takes place on our page.
The Ticket Is Bound to Three Things
| Binding | What happens without it |
|---|---|
| Member | You could use A's link to create a profile for B |
| Merchant | Another merchant could consume the ticket |
| 24 hours + single use | A link in chat history could be forwarded and reused |
⚠ Validity is 24 hours, not 5 minutes. You must pass it to the end user,
who then needs to find their ID and take photos. Do not expect longer—anyone holding this URL
can submit a KYC profile in that member's name. Issue a new one if it expires.
What the End User Enters on That Page
Fifteen Text / Selection Fields (All Required)
| Field | Type | Notes |
|---|---|---|
first_name / last_name | Text ≤60 | |
sex | Selection | male | female |
birth_date | Date | An age range applies, see below |
nationality | Text, 2 characters | Country code |
occupation | Text ≤60 | |
phone_zone_number | Text ≤6 | Calling code |
phone | Phone number ≤20 | |
identity_type | Selection | id_card | passport | resident_card | drivers_license |
identity_number | Text ≤60 | We do not validate the number format—national rules differ, and false rejection costs more than accepting an unusual format |
cardholder_residence | Selection | chinese-mainland | chinese-mainland-residents-living-overseas | overseas-countries—see the warning below |
address_province / address_city | Text ≤60 | |
address | Text ≤160 | |
street_number | Text ≤40 |
Email is prefilled and cannot be changed—review results are sent to the address used when the member was created.
⚠ ID numbers and email addresses are both checked for duplicates across people. A conflict returns 400 at submission
(
kyc_identity_taken/kyc_email_taken), with no manual override,and no signal is sent to your side. For causes and prevention, see the relevant section
in the KYC Overview.
⚠ The default age range is 18–65 (configurable in the admin console). Applicants outside it are rejected immediately,
and this upper limit is not included in any response—you cannot determine eligibility in advance.
A user aged 66 or older may complete all 15 fields and upload three ID photos before being blocked at submission.
Products serving older users should explain this before starting the flow.
⚠
chinese-mainlandis currently rejected (“Registration is not currently supported for mainland China residents”),and mainland China phone numbers are also rejected. This is an admin configuration switch
(
allow_mainland), not a permanent rule—which is why the option remains in the table.Explain this early in your user guidance instead of blocking users after all fifteen fields are completed.
Three Photos
| Item | Form field | Required |
|---|---|---|
| Front of ID | photo_front | Yes |
| Back of ID | photo_back | No—a passport only has an information page |
| Selfie holding ID | photo_handheld | Yes |
⚠
backis not marked required on the page, but non-passport documents do require a back image—the server makes this determination; the page does not duplicate it and create a second source of truth.
Each image must be ≤ 8 MB. We detect the actual type from magic bytes, rather than trusting the client's declared Content-Type. Images are uploaded directly to our private storage and can only be retrieved by the owner and reviewers.
⚠ Depending on residence type, reviewers may additionally request a residence permit—
this uses the supplementary documents flow, not the initial form.
How Is the Completed Form Submitted?
It does not pass through your system. You have nothing to do at this step.
We render the page, and its <form> points to hosted_url itself:
<form method="post"
action="/hosted/kyc/kyc_352ffcf7…"
enctype="multipart/form-data">
The end user selects Submit → the browser sends the 15 text fields and photos as multipart/form-data in a same-origin POST directly to us (the photo form fields are photo_front / photo_back / photo_handheld)。
⚠⚠ This is the entire reason for the hosted page: ID images and numbers never pass through
your server, logs, CDN, or error reporting.
You only ever hold a URL and the subsequent review result.
Do not look for a “submit information” API—there is no such endpoint, and there should not be one.
What Happens After Submission
| Result | Page | Ticket |
|---|---|---|
| Validation passes | Final “Submitted” page | Only consumed at this point |
| Validation fails | Text is prefilled, errors appear, and the user can correct it immediately | Remains valid |
⚠ Failed validation does not consume the ticket—otherwise a single typo would force users to ask you for a new link.
However, photos must be selected again: browsers do not allow prefilling
filecontrols. That is what the page's reminder explains.
Calling POST /v1/kyc/sessions again after successful submission returns 400 state_invalid (the member already has a pending or approved profile).
The Member's kyc_level Does Not Change Immediately
Submission only hands over the information; review is asynchronous. Until a decision is made, the kyc_level from GET /v1/members/{id} remains 0. Do not treat “Submitted” as “KYC approved” when enabling services—wait for kyc.result.updated。
What You Can Actually See
⚠⚠ Read this first, because it determines how to design your state machine.
GET /v1/kyc returns only four fields, and status has only three states:
status | When it applies |
|---|---|
approved | level ≥ 1 |
pending | A profile is under review |
rejected | Rejected, with no profile currently under review |
none | Never submitted |
⚠
pendingtakes precedence overrejected. A user who resubmits after rejection has two records,but their current state is “under review”—do not prompt them again.
⚠ On
rejected, guide the user through the process again: callPOST /v1/kyc/sessionsto obtain a new link (there is no ability to edit a few fields and resubmit; see below).
⚠
fail_reason(the rejection reason) is never exposed through the Open API. Event payloadsare strictly limited to IDs and states. Do not look for this field or promise users a reason in your interface.
Our Five Internal States Are Not Exposed
UNREVIEWED / APPROVED / REJECTED / SUPPLEMENT_REQUIRED / SUPPLEMENT_SUBMITTED is an internal transition, included here only to explain why several events may arrive.
⚠ If you write
switch (status) { case "APPROVED": … }based on those states,no branch will ever match, with no error—events arrive normally, signatures are valid, and you return 2xx.
The symptom is “the user's KYC was approved, but our system still shows pending.”
Events Are Not Sent for Every Transition
kyc.result.updated is sent only at two points:
- A review decision is made (approved or rejected)
- We request supplementary documents
Two transitions do not emit events:
| Transition | Consequence |
|---|---|
| User finishes L1 submission (→ pending review) | There is no “submitted” event—poll GET /v1/kyc for pending if you need to know |
| User submits supplementary documents (→ awaiting another review) | Same: no event |
⚠ If incoming events are the only trigger for advancing your state machine, these two states will remain stuck forever,
and your webhook endpoint will be the first thing you suspect during troubleshooting.
What to Do After Rejection
There is no option to return to the previous profile and edit a few fields (that path requires the member's own login session, while members created through POST /v1/members receive a random placeholder password and cannot log in to our App).
The only Open API path is to call POST /v1/kyc/sessions again for a new link.
⚠ The new link opens a blank form—the previous information is not prefilled.
Tell users in advance that they need to enter everything again.
This Is Not Quick KYC
Exclusively binding a profile from the platform pool (Quick KYC) does not change level to 1 and cannot replace the hosted page described here. Do not use either flow as a fallback for the other.