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
    from not_started to started. You then submit for review
    (→ pending), and a review decision moves it to completed (approved) or
    rejected.
  • Approval is a deliberate review step, never automatic on upload — in
    sandbox and in production alike.
  • A user must reach KYC completed before you can create a B2C cardholder.
    Creating one earlier returns 412.

Two Verifications — Don't Confuse Them #

There are two independent checks in the onboarding path:

  1. 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.
  2. 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:

  1. You upload the required documents.
  2. You call submit → status becomes pending.
  3. A reviewer sets the final status → completed (approved) or rejected.

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.