KYC
Build your own onboarding UI, send the applicant as JSON, and get
VERIFIED,REVIEWorDECLINEDwith the reasons, each with a message you can show the applicant and a detail for your compliance team.
Type: kyc · Endpoint: POST /api/v1/kyc/verify
The flow
- Your UI collects the applicant's data, email and phone.
POST /kyc/otp/sendfor the email and for the phone; Ward sends a 6-digit code.- The applicant types the codes;
POST /kyc/otp/verifyreturns achallenge_idfor each. POST /kyc/verifywith the applicant and bothchallenge_ids.
For US mobile numbers, the phone can instead be proven silently by the operator (network verification): no code to type.
# 2. send a code
curl https://api.mandatelabs.ai/api/v1/kyc/otp/send -H "X-API-Key: $WARD_API_KEY" -H "Content-Type: application/json" \
-d '{"channel": "sms", "destination": "+12025550143", "locale": "en"}'
# → {"challenge_id": "otp_...", "channel": "sms", "expires_in": 600, "destination": "+1202*******43"}
# 3. confirm it
curl https://api.mandatelabs.ai/api/v1/kyc/otp/verify -H "X-API-Key: $WARD_API_KEY" -H "Content-Type: application/json" \
-d '{"challenge_id": "otp_...", "code": "482913"}'
# → {"challenge_id": "otp_...", "verified": true, "attempts_left": null}
# 4. verify the applicant
curl https://api.mandatelabs.ai/api/v1/kyc/verify -H "X-API-Key: $WARD_API_KEY" -H "Content-Type: application/json" \
-d '{"applicant": {"first_name": "Ana", "last_name": "Perez", "date_of_birth": "1990-05-01", "country": "USA",
"email": "[email protected]", "phone": "+12025550143",
"address_line1": "12 Main St", "city": "Austin", "state": "TX", "postal_code": "78701"},
"email_challenge_id": "otp_e...", "phone_challenge_id": "otp_p...", "locale": "en", "external_ref": "app_5531"}'
e = client.kyc.send_code("email", "[email protected]", locale="en")
p = client.kyc.send_code("sms", "+12025550143", locale="en")
# ... the applicant types the codes in your UI ...
client.kyc.confirm_code(e.challenge_id, email_code)
client.kyc.confirm_code(p.challenge_id, sms_code)
r = client.kyc.verify(applicant, email_challenge_id=e.challenge_id, phone_challenge_id=p.challenge_id, locale="en")
if r.status == "DECLINED":
show(r.reason) # safe to show the applicant
const e = await client.kyc.sendCode("email", "[email protected]", { locale: "en" });
const p = await client.kyc.sendCode("sms", "+12025550143", { locale: "en" });
await client.kyc.confirmCode(e.challenge_id, emailCode);
await client.kyc.confirmCode(p.challenge_id, smsCode);
const r = await client.kyc.verify(applicant, { email_challenge_id: e.challenge_id, phone_challenge_id: p.challenge_id, locale: "en" });
if (r.status === "DECLINED") show(r.reason);
Applicant
| Field | Description |
|---|---|
full_name, or first_name + last_name |
Required |
date_of_birth |
YYYY-MM-DD |
country, nationality |
Residence (alpha-2 or alpha-3), nationality (alpha-3) |
email, phone |
Phone in E.164, or national digits with country |
address_line1, city, state, postal_code |
Compared with the phone line's subscriber where the carrier supports it |
document |
mrz, file_base64, file_name, document_number, expiry: runs the document check |
session |
device_id, ip: link applications that share a device |
Policy
All optional, per request:
| Field | Default | |
|---|---|---|
require_email, require_phone |
true |
|
require_email_otp, require_phone_otp |
true |
Ownership must be proven |
require_document |
false |
|
screen |
true |
Sanctions, PEP, enforcement, adverse media |
phone_owner_check |
true |
Compare the line's subscriber with the applicant |
voip |
review |
allow, review or decline VoIP numbers |
allow_role_email, allow_phone_country_mismatch |
false |
|
review_as |
REVIEW |
DECLINED if you have no review queue |
What is checked
| Check | Signals |
|---|---|
| Syntax, mail servers (MX), disposable inboxes (including new ones on known disposable mail hosts), role mailboxes, provider typos (gmial.com), domain age, mail authentication | |
| Phone | Numbering plan, line type, carrier (current, through the carrier lookup), country vs declared, line status, SIM swap, reassigned number, subscriber name match |
| Ownership | The code (or operator network verification) proves the applicant controls the email and phone; a proof is bound to your account and to that exact destination |
| Contact history | Ward's contact graph: an email or phone seen in fraud cases, shared by several identities, or used at high velocity |
| Identity | Name screening, and the document check when a document is sent |
Result
{
"verification_id": "kyc_3f9a...",
"status": "DECLINED",
"reason": "Temporary or disposable email addresses are not accepted. Use your personal email.",
"reasons": [
{"code": "EMAIL_DISPOSABLE", "severity": "decline", "field": "email",
"user_message": "Temporary or disposable email addresses are not accepted. Use your personal email.",
"detail": "'yopmail.com' is a disposable-inbox service."}
],
"contact_risk": {"score": 0.97, "model": "default-weights", "mode": "shadow"},
"checks": {"email": {}, "email_ownership": {}, "phone": {}, "phone_ownership": {}, "graph": {}, "screening": {}, "document": {}}
}
Status: any decline reason → DECLINED; any review reason → REVIEW (or DECLINED with review_as); otherwise VERIFIED. A check that could not run is a review reason, never a pass. Through /decide, VERIFIED / REVIEW / DECLINED map to APPROVE / STEP_UP / DECLINE.
Show user_message, never detail
Screening and contact-history reasons carry a generic user_message on purpose: telling an applicant they matched a list or a fraud record is tipping-off. detail is for your compliance team only.
Main codes: EMAIL_SYNTAX_INVALID, EMAIL_DOMAIN_TYPO, EMAIL_NO_MAIL_SERVER, EMAIL_DISPOSABLE, EMAIL_DOMAIN_NEW, EMAIL_NOT_CONFIRMED, PHONE_NOT_ASSIGNED, PHONE_NOT_PERSONAL, PHONE_VOIP, PHONE_NOT_SMS_CAPABLE, PHONE_INACTIVE, PHONE_RECENT_SIM_SWAP, PHONE_OWNER_MISMATCH, PHONE_REASSIGNED, PHONE_NOT_CONFIRMED, CONTACT_RISK, CONTACT_FRAUD_HISTORY, CONTACT_SHARED_IDENTITIES, SANCTIONS_MATCH, PEP_MATCH, SCREENING_UNAVAILABLE, DOCUMENT_ALTERED, DOCUMENT_EXPIRED.
Outcomes
When you learn what an applicant turned out to be, report it. It feeds Ward's contact graph and your contact-risk model:
curl https://api.mandatelabs.ai/api/v1/kyc/outcomes -H "X-API-Key: $WARD_API_KEY" -H "Content-Type: application/json" \
-d '{"verification_id": "kyc_3f9a...", "outcome": "synthetic_identity"}'
Outcomes: fraud, synthetic_identity, account_takeover, chargeback, money_mule, legitimate, false_positive.
Network verification
For US numbers, POST /kyc/network-auth/start with the phone and your redirect_uri returns an operator authorization_url. Open it in the applicant's app on mobile data; the operator redirects back with a code. POST /kyc/network-auth/complete with state and code returns a challenge_id to pass as phone_challenge_id. If the operator cannot verify, the answer says "fallback": "otp": send a code instead.
Email and phone alone
POST /api/v1/email/check ({"email": ...}) and POST /api/v1/phone/check ({"phone": ..., "country": ...}) run the contact checks without a full verification.