Who This Guide Is For #
Teams onboarding B2C cardholders who need to verify a person's identity (KYC)
before a card can be issued. You should already have client credentials
(x-client-id, x-client-secret) and an existing user (userId) under your
tenant. See Getting Started and
API Authentication first.
The Short Version #
- Uploading documents does not approve KYC. Upload only moves the user
fromnot_startedtostarted. You then submit for review
(→ pending), and a review decision moves it tocompleted(approved) or
rejected. - Approval is a deliberate review step, never automatic on upload — in
sandbox and in production alike. - A user must reach KYC
completedbefore you can create a B2C cardholder.
Creating one earlier returns412.
Two Verifications — Don't Confuse Them #
There are two independent checks in the onboarding path:
- User KYC (this guide) — verifying the person's identity from the
documents they upload (passport/ID + selfie). This is what you drive through
the endpoints below. - Cardholder verification — the card provider's own audit, triggered later
when you create a cardholder. It has a separate status machine and approval.
See Provider W Cardholder Integration.
┌──────────────────────────────────────────────┐
upload docs ──▶ │ 1. USER KYC │
│ not_started → started → pending → completed │
│ └▶ rejected │
│ └▶ request_more_info
└──────────────────┬───────────────────────────┘
│ requires status = completed (B2C)
▼
┌──────────────────────────────────────────────┐
create │ 2. CARDHOLDER (provider audit) │
cardholder │ pending_review → wait_audit → pass_audit ... │
└──────────────────────────────────────────────┘
Is Approval Automatic? — No #
Uploading documents never approves KYC. The only automatic transition is
not_started → started on the first upload. Everything after that is explicit:
- You upload the required documents.
- You call submit → status becomes
pending. - A reviewer sets the final status →
completed(approved) orrejected.
User KYC Status Machine #
| Status | Meaning | How you get here |
|---|---|---|
not_started |
No documents yet | Initial state |
started |
At least one document uploaded | Automatic on first upload |
pending |
Submitted, awaiting review | You call POST .../kyc/submit |
completed |
Approved | Reviewer sets completed |
rejected |
Rejected | Reviewer sets rejected (+ rejectLabels) |
request_more_info |
More/better documents needed | Reviewer sets request_more_info |
Documents also carry their own status (pending → approved | rejected).
Approving or rejecting an individual document is optional and does not change
the user-level status — the user-level decision is what gates cardholder
creation.
Allowed user-level transitions are enforced by the API; an invalid one (e.g.
completed → started) returns 400 ErrInvalidArg.
Required Documents to Submit #
Submitting for review requires:
- at least one identity document — one of
passport,id_card,
driving_licence,residence_permit, and - one
selfie_with_document.
proof_of_address is accepted and stored but not required. Missing a required
class returns 400.
Endpoints #
All calls require x-client-id and x-client-secret headers.
| Method & Path | Purpose |
|---|---|
GET /v1/users/{id}/kyc |
Status + document counts |
POST /v1/users/{id}/kyc/documents |
Upload one document (base64) |
GET /v1/users/{id}/kyc/documents |
List documents |
GET /v1/users/{id}/kyc/documents/{docId} |
Document metadata |
GET /v1/users/{id}/kyc/documents/{docId}/content |
Download the raw file |
DELETE /v1/users/{id}/kyc/documents/{docId} |
Delete a document |
POST /v1/users/{id}/kyc/submit |
Submit for review → pending |
PATCH /v1/users/{id}/kyc/status |
Set the review decision (approve/reject) |
PATCH /v1/users/{id}/kyc/documents/{docId}/status |
Approve/reject one document |
PATCH /v1/users/{id}/kyc/notes |
Attach internal review notes |
Upload Payload #
POST /v1/users/{id}/kyc/documents
{
"documentType": "passport", // passport | id_card | driving_licence |
// residence_permit | proof_of_address |
// selfie_with_document
"fileName": "passport.png",
"mimeType": "image/png", // image/jpeg | image/jpg | image/png | application/pdf
"fileContent": "<base64>" // base64-encoded file bytes
}
Keep uploads under 10 MB to stay within portable limits. Returns 201 with
the created document (status pending).
Review Decision Payload #
PATCH /v1/users/{id}/kyc/status
{
"status": "completed", // completed | rejected | request_more_info
"reviewedBy": "you@yourco.com", // optional, echoed into the KYC webhook
"rejectLabels": "blurry_document" // optional, comma-separated, for rejected
}
Step-by-Step Walkthrough #
Set your environment first (never bake credentials into scripts):
export PAYCA_BASE_URL="https://api.actisas.ee" # use your sandbox URL when testing
export PAYCA_CLIENT_ID="..."
export PAYCA_CLIENT_SECRET="..."
export PAYCA_USER_ID="..." # an existing user under your tenant
auth=(-H "x-client-id: $PAYCA_CLIENT_ID" -H "x-client-secret: $PAYCA_CLIENT_SECRET" \
-H 'Content-Type: application/json')
# a tiny valid PNG, base64-encoded (stand-in for a real scan)
PNG='iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
# 1. status before anything → not_started
curl -sS "$PAYCA_BASE_URL/v1/users/$PAYCA_USER_ID/kyc" "${auth[@]}"
# 2. upload an identity document (passport) → 201, document status "pending"
curl -sS -X POST "$PAYCA_BASE_URL/v1/users/$PAYCA_USER_ID/kyc/documents" "${auth[@]}" -d "{
\"documentType\":\"passport\",\"fileName\":\"passport.png\",
\"mimeType\":\"image/png\",\"fileContent\":\"$PNG\"}"
# 3. upload the required selfie → 201
curl -sS -X POST "$PAYCA_BASE_URL/v1/users/$PAYCA_USER_ID/kyc/documents" "${auth[@]}" -d "{
\"documentType\":\"selfie_with_document\",\"fileName\":\"selfie.png\",
\"mimeType\":\"image/png\",\"fileContent\":\"$PNG\"}"
# 4. status now → "started" (upload auto-advanced it)
curl -sS "$PAYCA_BASE_URL/v1/users/$PAYCA_USER_ID/kyc" "${auth[@]}"
# 5. submit for review → "pending"
curl -sS -X POST "$PAYCA_BASE_URL/v1/users/$PAYCA_USER_ID/kyc/submit" "${auth[@]}"
# 6. review decision → "completed" (see "Approving in Sandbox" below)
curl -sS -X PATCH "$PAYCA_BASE_URL/v1/users/$PAYCA_USER_ID/kyc/status" "${auth[@]}" \
-d '{"status":"completed","reviewedBy":"you@yourco.com"}'
# 7. confirm → status is "completed"
curl -sS "$PAYCA_BASE_URL/v1/users/$PAYCA_USER_ID/kyc" "${auth[@]}"
Representative responses:
// GET .../kyc (step 1)
{ "userId": "…", "status": "not_started",
"documentsSummary": { "total": 0, "pending": 0, "approved": 0, "rejected": 0 } }
// POST .../kyc/documents (step 2)
{ "id": "63b3cab7-…", "userId": "…", "documentType": "passport",
"fileName": "passport.png", "mimeType": "image/png", "fileSize": 70,
"status": "pending", "uploadedAt": "2026-07-01T07:23:48Z" }
// GET .../kyc (step 4)
{ "userId": "…", "status": "started",
"documentsSummary": { "total": 2, "pending": 2, "approved": 0, "rejected": 0 } }
// POST .../kyc/submit (step 5)
{ "userId": "…", "status": "pending",
"documentsSummary": { "total": 2, "pending": 2, "approved": 0, "rejected": 0 } }
// PATCH .../kyc/status (step 6) → approved
{ "userId": "…", "status": "completed",
"documentsSummary": { "total": 2, "pending": 2, "approved": 0, "rejected": 0 } }
Example Script #
A self-contained runnable script — reads everything from the environment, with
no credentials baked in. Save as kyc_flow.sh, chmod +x kyc_flow.sh, then
./kyc_flow.sh.
#!/usr/bin/env bash
# End-to-end PayCA KYC verification flow.
# Requires the following environment variables (no credentials are stored here):
# PAYCA_BASE_URL e.g. https://api.actisas.ee (use your sandbox URL to test)
# PAYCA_CLIENT_ID your tenant client id
# PAYCA_CLIENT_SECRET your tenant client secret
# PAYCA_USER_ID an existing user id under your tenant
# Optional:
# ID_DOC_FILE path to an identity document (default: generated PNG)
# ID_DOC_TYPE passport|id_card|driving_licence|residence_permit (default: passport)
# SELFIE_FILE path to a selfie-with-document image (default: generated PNG)
# REVIEWED_BY reviewer identifier recorded on approval (default: $PAYCA_CLIENT_ID)
# AUTO_APPROVE "1" to also submit the review decision (sandbox self-approval)
set -euo pipefail
: "${PAYCA_BASE_URL:?set PAYCA_BASE_URL}"
: "${PAYCA_CLIENT_ID:?set PAYCA_CLIENT_ID}"
: "${PAYCA_CLIENT_SECRET:?set PAYCA_CLIENT_SECRET}"
: "${PAYCA_USER_ID:?set PAYCA_USER_ID}"
ID_DOC_TYPE="${ID_DOC_TYPE:-passport}"
REVIEWED_BY="${REVIEWED_BY:-$PAYCA_CLIENT_ID}"
BASE="$PAYCA_BASE_URL/v1/users/$PAYCA_USER_ID/kyc"
AUTH=(-H "x-client-id: $PAYCA_CLIENT_ID"
-H "x-client-secret: $PAYCA_CLIENT_SECRET"
-H "Content-Type: application/json")
# base64 helper (portable across GNU/BSD)
b64() { base64 < "$1" | tr -d '\n'; }
# A 1x1 PNG used when no real file is supplied — replace with real scans in practice.
TINY_PNG='iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
if [[ -n "${ID_DOC_FILE:-}" ]]; then ID_B64="$(b64 "$ID_DOC_FILE")"; else ID_B64="$TINY_PNG"; fi
if [[ -n "${SELFIE_FILE:-}" ]]; then SELFIE_B64="$(b64 "$SELFIE_FILE")"; else SELFIE_B64="$TINY_PNG"; fi
upload() { # $1 documentType $2 fileName $3 base64
curl -sS -X POST "$BASE/documents" "${AUTH[@]}" \
-d "{\"documentType\":\"$1\",\"fileName\":\"$2\",\"mimeType\":\"image/png\",\"fileContent\":\"$3\"}"
echo
}
echo "== initial status =="
curl -sS "$BASE" "${AUTH[@]}"; echo
echo "== upload identity document ($ID_DOC_TYPE) =="
upload "$ID_DOC_TYPE" "identity.png" "$ID_B64"
echo "== upload selfie_with_document =="
upload "selfie_with_document" "selfie.png" "$SELFIE_B64"
echo "== submit for review =="
curl -sS -X POST "$BASE/submit" "${AUTH[@]}"; echo
if [[ "${AUTO_APPROVE:-0}" == "1" ]]; then
echo "== review decision -> completed =="
curl -sS -X PATCH "$BASE/status" "${AUTH[@]}" \
-d "{\"status\":\"completed\",\"reviewedBy\":\"$REVIEWED_BY\"}"; echo
fi
echo "== final status =="
curl -sS "$BASE" "${AUTH[@]}"; echo
Run it in review-only mode (leaves the user in pending for a reviewer):
./kyc_flow.sh
Run it with self-approval in sandbox:
AUTO_APPROVE=1 ./kyc_flow.sh
With real files:
ID_DOC_TYPE=passport ID_DOC_FILE=./passport.jpg SELFIE_FILE=./selfie.jpg \
AUTO_APPROVE=1 ./kyc_flow.sh
Checking Status #
GET /v1/users/{id}/kyc returns everything you need to poll or gate on:
{
"userId": "…",
"status": "completed", // ← gate on this
"rejectLabels": null, // populated when rejected
"notes": null, // internal review notes
"documentsSummary": { "total": 2, "pending": 2, "approved": 0, "rejected": 0 }
}
A user is approved when status == "completed".
Webhook #
Every user-level status change (submit and review decision) fires a kyc
webhook to your configured endpoint, so you can react without polling:
{
"userId": "…",
"kycStatus": "completed",
"previousStatus": "pending",
"rejectLabels": null,
"reviewedBy": "you@yourco.com",
"timestamp": "2026-07-01T07:24:00Z"
}
See PayCA Cardholder Webhooks for delivery,
signing, and retry behavior.
Approving in Sandbox #
The review-decision endpoint (PATCH /v1/users/{id}/kyc/status) is authorized by
your normal x-client-id / x-client-secret — there is no separate reviewer
credential. This means that in sandbox you can drive the whole loop yourself
and self-approve a test user:
curl -sS -X PATCH "$PAYCA_BASE_URL/v1/users/$PAYCA_USER_ID/kyc/status" \
-H "x-client-id: $PAYCA_CLIENT_ID" -H "x-client-secret: $PAYCA_CLIENT_SECRET" \
-H 'Content-Type: application/json' \
-d '{"status":"completed","reviewedBy":"you@yourco.com"}'
There is no hidden auto-approve flag for User KYC — you approve by making this
call. The User-KYC status machine behaves identically in sandbox and production;
only the base URL (and the fact that card providers run against their sandbox)
differs. See the Sandbox Playbook for the rest of the test
environment.
Next Step: Create the Cardholder #
Once the user is KYC completed, create a B2C cardholder with
POST /v1/users/{id}/cardholder. That call returns 412 if KYC is not yet
completed, and it kicks off the separate provider-side cardholder audit
(pending_review → wait_audit → pass_audit). Follow
Provider W Cardholder Integration from
there.