Warddocs
Capabilities

KYC

Build your own onboarding UI, send the applicant as JSON, and get VERIFIED, REVIEW or DECLINED with 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

  1. Your UI collects the applicant's data, email and phone.
  2. POST /kyc/otp/send for the email and for the phone; Ward sends a 6-digit code.
  3. The applicant types the codes; POST /kyc/otp/verify returns a challenge_id for each.
  4. POST /kyc/verify with the applicant and both challenge_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
Email 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.