API v1 · Digital Identity Verification

Privy Digital ID — Integration GuidePanduan Integrasi

Privy's official identity verification service, embedded by merchants via an iframe (web) or WebView (native app). Merchants simply create a session and receive the verified result — the entire phone, liveness, and face-match flow runs inside Privy's widget. Layanan verifikasi identitas resmi Privy, di-embed merchant lewat iframe (web) atau WebView (aplikasi native). Merchant cukup membuat sesi dan menerima hasil terverifikasi — seluruh alur input nomor, liveness, dan pencocokan wajah dijalankan Privy di dalam widget.

Base URL: seeBase URL: lihat Environments Auth: X-API-Key Token: JWT EdDSA (Ed25519)

EnvironmentsEnvironment

Two environments, same API. Use Sandbox for integration and testing, Production for live traffic — just swap the base host. Dua environment, API sama. Pakai Sandbox untuk integrasi & pengujian, Production untuk trafik live — cukup ganti base host.

Environment API base URLBase URL API Widget base URLBase URL widget
Sandboxhttps://sandbox-api-digitalid.privy.idhttps://sandbox-digitalid.privy.id
Productionhttps://api-digitalid.privy.idhttps://digitalid.privy.id
Examples in this guide use the Sandbox host. For production, swap the host with the Production base URL above. API keys are issued per environment — contact the Privy team. Contoh di panduan ini memakai host Sandbox. Untuk production, ganti host dengan Base URL Production di atas. API key diterbitkan per environment — hubungi tim Privy.

OverviewRingkasan

Privy Digital ID is an identity verification widget. The merchant never handles the camera, biometric data, or the user's personal data — the merchant only: Privy Digital ID adalah widget verifikasi identitas. Merchant tidak menangani kamera, data biometrik, maupun data pribadi pengguna — merchant hanya:

  1. Creates a session from its backend (with an API key) → receives a session_url. Membuat sesi dari backend (dengan API key) → menerima session_url.
  2. Embeds the session_url in an iframe / WebView. Meng-embed session_url di iframe / WebView.
  3. Receives the result via a server-to-server callback (source of truth) and/or a postMessage event. Menerima hasil lewat callback server-to-server (sumber kebenaran) dan/atau event postMessage.
Integration model: custom session + postMessage (not full OIDC). The session is guarded by a server-side state machine, so steps can't be skipped and reloading the iframe is always safe. Model integrasi: custom session + postMessage (bukan OIDC penuh). Sesi dijaga state machine server-side sehingga langkah tidak bisa dilewati, dan reload iframe selalu aman.

How it worksBagaimana ia bekerja

There are two parties: the Merchant (who embeds) and Privy (who verifies identity). The whole process runs inside Privy's widget. Two steps below depend on your credential's verification type — the Identity markers show what changes for a PII credential. Ada dua pihak: Merchant (yang meng-embed) dan Privy (yang memverifikasi identitas). Seluruh proses berjalan di dalam widget Privy. Dua langkah di bawah bergantung pada jenis verifikasi credential Anda — penanda Identity menunjukkan yang berubah untuk credential PII.

StepLangkahWhat Privy doesYang dilakukan Privy
1 · InputInputThe user enters an identifier — phone, email, NIK, or Privy ID (which ones are offered comes from the credential's get/config input_type) — which Privy matches against a registered identity.Pengguna memasukkan identifier — phone, email, NIK, atau Privy ID (mana yang ditawarkan mengikuti input_type dari get/config credential) — yang dicocokkan Privy dengan identitas terdaftar.
2 · LivenessFace-liveness detection via camera (anti-spoof, gestures, light reflection).Deteksi keaslian wajah lewat kamera (anti-spoof, gerakan, refleksi cahaya).
3 · Face matchPencocokan wajahThe live selfie is matched against the face on the verified Privy identity.Selfie hidup dicocokkan dengan wajah pada identitas terverifikasi Privy.
4 · OTP IdentityIdentity credentials only. A Privy-hosted OTP (2FA) confirms the user right after the face match — before the consent step.Hanya credential Identity. OTP (2FA) hosted Privy mengonfirmasi pengguna tepat setelah pencocokan wajah — sebelum langkah persetujuan.
5 · ConsentPersetujuanThe user reviews the data to share, then agrees or declines.Pengguna meninjau data yang akan dibagikan lalu menyetujui / menolak.
6 · ResultHasilPrivy delivers the verified data to the merchant (callback + exchange code). Identity: the my_identity section is encrypted for your private key.Privy mengirim data terverifikasi ke merchant (callback + exchange code). Identity: section my_identity dienkripsi untuk private key Anda.

Integration flowDiagram integrasi

The full sequence between the four parties. Steps 1–3 set up the widget; step 4 all happens inside Privy; steps 5–8 deliver the verified data to the merchant. otp* runs only for Identity credentials. Urutan lengkap antara empat pihak. Langkah 1–3 menyiapkan widget; langkah 4 seluruhnya terjadi di dalam Privy; langkah 5–8 mengantar data terverifikasi ke merchant. otp* hanya untuk credential Identity.

Merchant BE Host Privy Widget Privy API 4 · input · liveness · face match · otp* · consent 1 · POST /v1/sessions → session_url 2 · session_url 3 · embed (iframe / WebView) 5 · privy:success { exchange_code } 6 · exchange_code 7 · POST /v1/sessions/exchange → token 8 · callback (JWT) · source of truth

Quickstart

1 · Create a session (merchant backend)Buat sesi (backend merchant)

# API key stays in the backend — never in the browserAPI key hanya di backend — jangan pernah di browser
curl -X POST https://sandbox-api-digitalid.privy.id/v1/sessions \
  -H "X-API-Key: <MERCHANT_API_KEY>"
{
  "session_id": "9f1c…",
  "session_url": "https://sandbox-digitalid.privy.id/?t=eyJ…",
  "expires_at": "2026-07-21T18:15:00Z"
}

2 · Embed the widget (merchant frontend)Embed widget (frontend merchant)

<iframe src="{{session_url}}" allow="camera"
        style="width:100%;height:100%;border:0"></iframe>

3 · Receive the resultTerima hasil

window.addEventListener('message', (e) => {
  if (e.origin !== 'https://sandbox-digitalid.privy.id') return; // requiredwajib
  const { type, payload } = e.data || {};
  if (type === 'privy:success') {
    // hand payload.exchange_code to your backend to swap for a tokenserahkan payload.exchange_code ke backend untuk ditukar token
  }
});
Source of truth = the server-to-server callback. postMessage is for UX; business decisions must be validated on the backend (verify the JWT / redeem the exchange code). Sumber kebenaran = callback server-to-server. postMessage untuk UX; keputusan bisnis harus divalidasi backend (verifikasi JWT / tukar exchange code).

Verification statesAlur verifikasi

The state machine is enforced server-side. Data-sharing consent happens AFTER the face match (Privy's regulation): State machine dijaga server-side. Consent berbagi data terjadi SETELAH wajah cocok (regulasi Privy):

created identity_found verified completed
StateScreenLayarMeaningArti
createdPhone inputInput nomor HPSession createdSesi dibuat
identity_foundLiveness (camera)Liveness (kamera)Phone matched → liveness + face match run right awayNomor cocok → liveness + face match langsung berjalan
verifiedConsent screenLayar consentFace matched → the user reviews & agrees/declinesWajah cocok → user meninjau & menyetujui/menolak
completedSuccessSuksesAgreed → privy:successDisetujui → privy:success
identity_not_foundPhone (error)Input nomor (error)Number not registered → retryNomor tidak terdaftar → coba lagi
consent_deniedCancelledDibatalkanUser declined → privy:cancelUser menolak → privy:cancel
blockedBlockedTerblokir3 liveness failures → privy:errorGagal liveness 3× → privy:error

Identity (PII) credentials add an OTP (2FA) step after the face match, before consent (see Verification types): Credential Identity (PII) menambah langkah OTP (2FA) setelah pencocokan wajah, sebelum consent (lihat Jenis verifikasi):

created identity_found awaiting_otp verified completed
awaiting_otpOTP inputInput OTPLiveness + face match passed → a Privy-hosted OTP (2FA) confirms the user; on success the session advances to the consent screen (verified)Liveness + pencocokan wajah lolos → OTP hosted Privy (2FA) mengonfirmasi pengguna; jika berhasil sesi lanjut ke layar consent (verified)

Verification typesJenis verifikasi

A credential is provisioned as one of two types. The type is fixed per credential (chosen when Privy creates your credential) — it decides the input, whether an OTP step runs, which data is shared, and how it is delivered. Your integration doesn't select it per request. Tiap credential disetel salah satu dari dua jenis. Jenis ini tetap per credential (dipilih saat Privy membuat credential Anda) — menentukan input, ada/tidaknya langkah OTP, data apa yang dibagikan, dan cara pengirimannya. Integrasi Anda tidak memilihnya per request.

Contact (no PII) Identity (PII)
InputInput Phone / Email / NIK / Privy ID Phone / Email / NIK / Privy ID
VerificationVerifikasi Selfie liveness → face match, or OTPSelfie liveness → face match, atau OTP Selfie liveness → face match → OTP (2FA)Selfie liveness → face match → OTP (2FA)
Shared dataData dibagikan my_contact_data
email, phoneemail, nomor HP
my_contact_data + my_identity
contact, plus PrivyID, full name, NIK, date of birth, ID card & selfie (image URLs)kontak, plus PrivyID, nama lengkap, NIK, tgl lahir, KTP & selfie (URL gambar)
DeliveryPengiriman Signed JWT, plaintext claimsJWT ditandatangani, klaim plaintext Encrypted — only your private key decryptsTerenkripsi — hanya private key Anda yang membuka
Contact is the default flow described throughout this guide. Identity adds the OTP step and encrypted delivery — see Encrypted PII & keys. Both accept the same inputs (phone / email / NIK / Privy ID); everything else (session creation, embedding, callback, JWT verification) is identical. Contact adalah flow default yang dijelaskan di sepanjang panduan ini. Identity menambah langkah OTP dan pengiriman terenkripsi — lihat PII terenkripsi & kunci. Keduanya menerima input yang sama (phone / email / NIK / Privy ID); selebihnya (buat sesi, embed, callback, verifikasi JWT) identik.

AuthenticationAutentikasi

Privy issues one API key per merchant. All back-channel requests (create session, exchange code) carry the X-API-Key header. Privy menerbitkan satu API key untuk tiap merchant. Semua permintaan back-channel (buat sesi, tukar exchange code) dikirim dengan header X-API-Key.

CredentialKredensialHeaderUsed forDipakai untuk
Merchant API KeyX-API-KeyCreate session & exchange code — from the merchant backend onlyBuat sesi & tukar exchange code — dari backend merchant saja
Never put X-API-Key in browser code. Sessions are always created from the merchant backend. Jangan pernah menaruh X-API-Key di kode browser. Sesi selalu dibuat dari backend merchant.

API Reference

POST/v1/sessionsX-API-Key

Creates a verification session and returns the URL the merchant embeds. No body. Membuat sesi verifikasi dan mengembalikan URL yang di-embed merchant. Tanpa body.

The requested data fields and the verification type (Contact or Identity) are determined by Privy per the merchant's credential at registration — not a per-session parameter. The widget adapts its input (phone / email / NIK / Privy ID) and flow accordingly. Field data yang diminta dan jenis verifikasi (Contact atau Identity) ditentukan Privy sesuai credential merchant saat registrasi — bukan parameter per-sesi. Widget menyesuaikan input (phone / email / NIK / Privy ID) dan flow-nya secara otomatis.

ResponseRespons 201

{ "session_id":"…", "session_url":"https://sandbox-digitalid.privy.id/?t=…", "expires_at":"…Z" }
POST/v1/sessions/exchangeX-API-Key

Swaps the exchange code (received on the frontend via privy:success) for the verified-data token. The code is single-use and short-lived (~5 min); it is worthless without the API key. Menukar exchange code (diterima frontend via privy:success) dengan token data terverifikasi. Kode bersifat sekali-pakai & kadaluarsa singkat (±5 menit); tak berguna tanpa API key.

Body

FieldTypeNotesKeterangan
code requiredstringThe code from privy:success.exchange_codeKode dari privy:success.exchange_code

ResponseRespons 200

{ "session_id":"…", "event":"identity.verified", "token":"eyJ… (JWT EdDSA)" }
Verify the token signature on your backend using the JWKS before trusting it.Verifikasi tanda tangan token di backend memakai JWKS sebelum dipercaya.
GET/.well-known/jwks.jsonpublic

Public keys to verify the token (RFC 7517). Algorithm EdDSA, curve Ed25519.Kunci publik untuk memverifikasi token (RFC 7517). Algoritma EdDSA, kurva Ed25519.

{ "keys":[{ "kty":"OKP", "crv":"Ed25519", "alg":"EdDSA", "use":"sig", "kid":"…", "x":"…" }]}
GET/healthzpublic

Service health check. Returns 200 when healthy.Health check layanan. Mengembalikan 200 bila sehat.

Endpoints inside the widget (phone input, liveness, consent) are handled entirely by Privy and need not be called by the merchant. Endpoint di dalam widget (input nomor, liveness, consent) ditangani sepenuhnya oleh Privy dan tidak perlu dipanggil merchant.

postMessage eventsEvent postMessage

The widget posts events to the parent (iframe) prefixed with privy:. Always validate event.origin against the widget origin. Widget mengirim event ke parent (iframe) dengan prefix privy:. Selalu validasi event.origin terhadap origin widget.

EventPayloadWhenKapan
privy:ready{ session_id, state }Widget finished bootstrappingWidget selesai bootstrap
privy:success{ session_id, exchange_code, token?, sections? }Verified & agreedVerifikasi sukses & disetujui
privy:cancel{ reason }User cancelled / declinedUser membatalkan / menolak
privy:error{ code }Blocked / expired / embed not allowedTerblokir / kadaluarsa / embed ditolak
Payload protection: data & token are sent only to a verified embedder (origin in the merchant allowlist). For an unknown host, the widget sends only { type } without payload. Perlindungan payload: data & token hanya dikirim ke embedder yang terverifikasi (origin ada di allowlist merchant). Untuk host tak dikenal, widget hanya mengirim { type } tanpa payload.

The sections fieldField sections optional previewpratinjau opsional

The data the user agreed to share, grouped by section (only populated fields). For display only — the authoritative data is always fetched by the backend via exchange → JWT: Data yang disetujui pengguna, dikelompokkan per bagian (hanya field berisi nilai). Untuk menampilkan ke pengguna saja — data resmi tetap diambil backend lewat exchange → JWT:

"sections": [
  { "section":"my_contact_data", "label":"My Contact Data", "fields":[
    { "key":"email", "label":"Email", "value":"user@mail.com", "is_verified":true }
  ]}
]

Native integration (iOS / Android)Integrasi native (iOS / Android)

Load the session_url directly in a WebView. The same events are delivered via a native bridge:Muat session_url langsung di WebView. Event yang sama dikirim lewat bridge native:

iOS · WKWebViewAndroid · WebView
The widget calls a ReactNativeWebView.postMessage(json) shim. Provide a handler that receives the JSON event.Widget memanggil shim ReactNativeWebView.postMessage(json). Sediakan handler yang menerima JSON event. Add a JS interface named PrivyAndroid; the widget calls PrivyAndroid.postMessage(json). For the liveness camera, see Camera (WebView SDK).Tambahkan JS interface bernama PrivyAndroid; widget memanggil PrivyAndroid.postMessage(json). Untuk kamera liveness, lihat Kamera (SDK WebView).
// JSON received by the native bridge — a native WebView is a TRUSTED host (not an iframe), so the payload comes in FULLJSON yang diterima bridge native — WebView native adalah host TERPERCAYA (bukan iframe), jadi payload dikirim PENUH
{
  "type": "privy:success",
  "payload": {
    "session_id":    "…",
    "exchange_code": "…",    // redeem server-to-server (recommended)redeem server-to-server (disarankan)
    "token":         "…",    // verified-data JWT (present on native)JWT verified-data (ada di native)
    "sections":      [  ]   // shared-data preview (Identity: my_identity stays encrypted)pratinjau data (Identity: my_identity tetap terenkripsi)
  }
}
A native WebView is not an iframe, so it is treated as a trusted host and receives the full payload (token + sections) — unlike an unverified iframe embedder, which gets only { type }. Use payload.token directly, or (recommended) redeem exchange_code server-to-server. Either way, verify the JWT and decrypt my_identity on your backend. WebView native bukan iframe, jadi diperlakukan sebagai host terpercaya dan menerima payload penuh (token + sections) — berbeda dari iframe embedder tak terverifikasi yang cuma dapat { type }. Pakai payload.token langsung, atau (disarankan) redeem exchange_code server-to-server. Apa pun caranya, verifikasi JWT dan dekripsi my_identity di backend Anda.

Camera & permissions (WebView SDK)Kamera & izin (SDK WebView)

On mobile the widget is wrapped by the DigitalID WebView SDK (iOS & Android). The liveness step opens the camera with the standard Web APInavigator.mediaDevices.getUserMedia(). There is no custom bridge message to request the camera: the SDK observes the getUserMedia call, decides grant/deny, and drives the native OS permission prompt. The web contract is identical on both platforms; only the native integrator setup differs. Di mobile, widget dibungkus SDK WebView DigitalID (iOS & Android). Langkah liveness membuka kamera dengan Web API standarnavigator.mediaDevices.getUserMedia(). Tidak ada pesan bridge khusus untuk meminta kamera: SDK mengamati panggilan getUserMedia, memutuskan izinkan/tolak, dan menggerakkan prompt izin OS. Kontrak web sama di kedua platform; hanya setup integrator native yang berbeda.

Web contract (frontend — required)Kontrak web (frontend — wajib)

  • Request video onlygetUserMedia({ video: true }). Do not add audio: true: both SDKs grant the camera only when the capture type is exactly camera; adding audio makes it camera+microphone, which is denied outright (the camera never opens). Minta video sajagetUserMedia({ video: true }). Jangan tambahkan audio: true: kedua SDK hanya mengizinkan kamera bila tipe capture tepat camera; menambahkan audio menjadikannya camera+mikrofon yang langsung ditolak (kamera tak pernah terbuka).
  • Handle the rejected promise — always .catch(…) and show a fallback/retry UI; never assume a stream was returned. Tangani promise yang gagal — selalu .catch(…) dan tampilkan UI fallback/coba-lagi; jangan berasumsi stream selalu ada.
  • HTTPS onlygetUserMedia needs a secure context and the SDK transport policy rejects non-HTTPS pages. All widget/redirect origins must be https://. Hanya HTTPSgetUserMedia butuh secure context dan kebijakan transport SDK menolak halaman non-HTTPS. Semua origin widget/redirect harus https://.
  • Call lazily — request the camera only at the liveness step (the prompt appears on the first getUserMedia call); don't call it eagerly on load. Popups/new windows are rejected by both SDKs. Panggil saat dibutuhkan — minta kamera hanya di langkah liveness (prompt muncul pada panggilan getUserMedia pertama); jangan panggil saat halaman dimuat. Popup/jendela baru ditolak kedua SDK.

Capture-type behaviorPerilaku tipe capture

getUserMedia constraintsconstraint Native capture typeTipe capture native SDK decisionKeputusan SDK
{ video: true }camera✅ granted (origin + OS permission)✅ diizinkan (origin + izin OS)
{ video: true, audio: true }camera + microphonedenied — whole request failsditolak — seluruh request gagal
{ audio: true }microphonedeniedditolak
screen / display capturescreen / display capturedisplaydeniedditolak

Rejection contract (error handling)Kontrak penolakan (penanganan error)

navigator.mediaDevices.getUserMedia({ video: true })
  .then(stream => { /* proceed with livenesslanjut liveness */ })
  .catch(err => {
    switch (err.message) {
      case "camera_unsupported":        // OS/WebView too old → "update device" fallbackOS/WebView terlalu lama → fallback "perbarui perangkat"
      case "camera_missing_permission": // host app not configured → "camera unavailable"host app tak dikonfigurasi → "kamera tak tersedia"
      default:                          // DOMException: NotAllowedError / NotFoundError / NotReadableError…DOMException: NotAllowedError / NotFoundError / NotReadableError…
    }
  });
camera_unsupported and camera_missing_permission are injected by the SDK guard, which rejects before touching the camera (prevents a native crash) — treat them as terminal "camera not available on this host" states. User-denied and hardware errors surface as standard DOMExceptions. camera_unsupported dan camera_missing_permission disuntikkan oleh guard SDK yang menolak sebelum menyentuh kamera (mencegah crash native) — perlakukan sebagai keadaan terminal "kamera tak tersedia di host ini". Error karena user menolak / perangkat keras muncul sebagai DOMException standar.

Native integrator requirementsKebutuhan integrator native

iOS · WKWebViewAndroid · WebView
getUserMedia routes to WKUIDelegate.requestMediaCapturePermissionFor, granting camera only (.camera) when origin/frame satisfy the HTTPS policy. getUserMedia diarahkan ke WKUIDelegate.requestMediaCapturePermissionFor, memberi kamera saja (.camera) bila origin/frame memenuhi kebijakan HTTPS.
  • In-WebView capture needs iOS 14.3+ (else camera_unsupported).Capture in-WebView butuh iOS 14.3+ (jika tidak → camera_unsupported).
  • Host app must declare NSCameraUsageDescription in Info.plist — else the SDK short-circuits with camera_missing_permission (never crashes).Host app wajib mendeklarasikan NSCameraUsageDescription di Info.plist — jika tidak, SDK menolak dengan camera_missing_permission (tak pernah crash).
  • The native permission alert appears automatically on first use.Alert izin native muncul otomatis saat pertama dipakai.
getUserMedia fires WebChromeClient.onPermissionRequest for RESOURCE_VIDEO_CAPTURE; the SDK grants video only and denies audio/other resources. getUserMedia memicu WebChromeClient.onPermissionRequest untuk RESOURCE_VIDEO_CAPTURE; SDK memberi video saja dan menolak audio/resource lain.
  • The SDK ensures the runtime CAMERA permission (API 23+); "don't ask again" is handled as permanently-denied with a deep link to App Settings.SDK memastikan izin runtime CAMERA (API 23+); "jangan tanya lagi" ditangani sebagai ditolak-permanen dengan deep link ke Pengaturan Aplikasi.
  • Host app manifest must declare android.permission.CAMERA — else it is reported as camera_missing_permission (Android does not crash for a missing manifest permission).Manifest host app wajib mendeklarasikan android.permission.CAMERA — jika tidak, dilaporkan sebagai camera_missing_permission (Android tak crash untuk izin manifest yang hilang).
  • Needs a WebRTC-capable System WebView over HTTPS (set minSdk ≈ API 24+); else camera_unsupported.Butuh System WebView yang WebRTC-capable di HTTPS (set minSdk ≈ API 24+); jika tidak → camera_unsupported.
// iOS · Info.plist
<key>NSCameraUsageDescription</key>
<string>Digital ID uses the camera to capture identity verification images.</string>

// Android · AndroidManifest.xml
<uses-permission android:name="android.permission.CAMERA" />
Frontend must NOT: post to any native camera bridge (the iOS SDK's internal __digitalIDCamera is SDK→native only; Android exposes none) · bundle audio into the liveness capture · rely on the camera over non-HTTPS or in a popup/new window. Frontend TIDAK boleh: mem-post ke bridge kamera native mana pun (__digitalIDCamera internal SDK iOS hanya SDK→native; Android tak menyediakannya) · menyertakan audio pada capture liveness · mengandalkan kamera di non-HTTPS atau popup/jendela baru.

Data deliveryPengiriman data

Verified data is always delivered as a Privy-signed JWTregardless of the identity source — through two complementary channels: Data terverifikasi selalu diantar sebagai JWT yang ditandatangani Privyapapun sumber identitasnya — lewat dua jalur yang saling melengkapi:

  1. Exchange code → JWT — the frontend receives a single-use exchange_code via privy:success; the merchant backend swaps it at /v1/sessions/exchange for a JWT with the data. Avoids the token travelling through the front channel. The code & verified data are held briefly in a fast store (in-memory / Redis) with a short TTL, never in the database. Exchange code → JWT — frontend menerima exchange_code sekali-pakai via privy:success; backend merchant menukarnya di /v1/sessions/exchange untuk memperoleh JWT berisi data. Menghindari token melintas front-channel. Kode & data terverifikasi ditahan singkat di penyimpanan cepat (in-memory / Redis) dengan TTL pendek, bukan di basis data.
  2. Server-to-server callback (source of truth) — Privy POSTs the same JWT to the merchant callback_url: { session_id, event:"identity.verified", token }. Callback server-to-server (sumber kebenaran) — Privy mem-POST JWT yang sama ke callback_url merchant: { session_id, event:"identity.verified", token }.
The sections field on privy:success is only an optional front-channel preview to show the user — the authoritative data is always fetched by the backend via exchange → JWT (or the callback). Field sections pada privy:success hanya pratinjau front-channel opsional untuk menampilkan data ke pengguna — data resmi selalu diambil backend lewat exchange → JWT (atau callback).
Contact vs Identity payload. For Contact credentials the JWT data claim is fully plaintext (my_contact_data). For Identity credentials the same channels are used and data carries both sections: my_contact_data plaintext + my_identity as an RSA-4096 encrypted value only your private key can open — see Encrypted PII & keys. Payload Contact vs Identity. Untuk credential Contact, klaim data pada JWT sepenuhnya plaintext (my_contact_data). Untuk credential Identity, jalur pengirimannya sama dan data membawa dua section: my_contact_data plaintext + my_identity berupa nilai terenkripsi RSA-4096 yang hanya bisa dibuka private key Anda — lihat PII terenkripsi & kunci.

Encrypted PII & keysPII terenkripsi & kunci

Identity credentials only. The user's personal data (my_identity) is never delivered in the clear — it arrives encrypted for your key inside the verified-data JWT, only on a successful (verified) event. Your backend decrypts it with your private key; decryption happens on your side, never in the browser. Hanya credential Identity. Data pribadi pengguna (my_identity) tidak pernah dikirim terbuka — data tiba terenkripsi untuk kunci Anda di dalam JWT verified-data, hanya pada event sukses (terverifikasi). Backend Anda yang mendekripsi dengan private key Anda; dekripsi terjadi di sisi Anda, bukan di browser.

Key provisioningPenyediaan kunci

Each merchant credential has its own RSA key pair (unique per merchant). At credential creation you receive the private key once — keep it in your backend secret manager. Privy holds the matching public key and encrypts the PII with it. You are the party that decrypts the delivered payload — keep the private key server-side and never ship it to a browser or mobile app. Setiap credential merchant punya pasangan kunci RSA sendiri (unik per merchant). Saat credential dibuat, Anda menerima private key sekali — simpan di secret manager backend Anda. Privy memegang public key pasangannya dan memakainya untuk mengenkripsi PII. Andalah pihak yang mendekripsi payload yang dikirim — simpan private key di sisi server dan jangan pernah dikirim ke browser atau aplikasi mobile.

Encryption schemeSkema enkripsi

Per-section, direct RSA. Only the sensitive my_identity is encrypted; my_contact_data is delivered plaintext alongside it. The identity payload is compact JSON that fits in a single RSA-4096 block, so it is encrypted directly with RSA-4096 (PKCS#1 v1.5) using your public key — no hybrid/AES. Inside the JWT data claim, my_identity is a base64 ciphertext string: Per-section, RSA langsung. Hanya my_identity sensitif yang dienkripsi; my_contact_data dikirim plaintext di sampingnya. Payload identitas berupa JSON ringkas yang muat dalam satu blok RSA-4096, jadi dienkripsi langsung dengan RSA-4096 (PKCS#1 v1.5) memakai public key Anda — tanpa hybrid/AES. Di dalam klaim data JWT, my_identity berupa string ciphertext base64:

// JWT data claim (Identity credential)
{
  "my_contact_data": { "email": "…", "phone": "…" },   // plaintext
  "my_identity": "<base64 RSA-4096 ciphertext>"          // decrypt → JSON below
}

Decrypting my_identity yields: Mendekripsi my_identity menghasilkan:

{
  "privy_id":        "…",
  "name":            "…",
  "identity_number": "…",                 // NIK
  "dob":             "1998-09-04",
  "email":           "…",
  "phone":           "+62…",
  "identity_card":   "https://…/ktp?token=…",      // KTP image — fetch this URL
  "selfie":          "https://…/selfie?token=…"    // selfie image — fetch this URL
}
identity_card and selfie are URLs, not inline images — fetch each URL to download the KTP / selfie image. identity_card dan selfie berupa URL, bukan gambar inline — ambil tiap URL untuk mengunduh gambar KTP / selfie.

Decrypt on your backendDekripsi di backend Anda

import forge from 'node-forge';

// my_identity is a base64 RSA-4096 / PKCS#1 v1.5 ciphertext string
function decryptIdentity(myIdentityB64, rsaPrivateKeyPem) {
  const priv = forge.pki.privateKeyFromPem(rsaPrivateKeyPem);
  const plain = priv.decrypt(forge.util.decode64(myIdentityB64), 'RSAES-PKCS1-V1_5');
  return JSON.parse(plain); // { name, identity_number, dob, … }
}
Why node-forge? Node's built-in crypto.privateDecrypt rejects RSA_PKCS1_PADDING for private decryption since the CVE-2023-46809 fix (Node 18.19/20/22+), so the native path throws. A pure-JS lib (node-forge) does the PKCS#1 v1.5 unpadding; other languages (Go rsa.DecryptPKCS1v15, PHP openssl_private_decrypt, Python PKCS1v15) work directly. Kenapa node-forge? crypto.privateDecrypt bawaan Node menolak RSA_PKCS1_PADDING untuk dekripsi privat sejak perbaikan CVE-2023-46809 (Node 18.19/20/22+), jadi jalur native akan error. Library pure-JS (node-forge) menangani unpadding PKCS#1 v1.5; bahasa lain (Go rsa.DecryptPKCS1v15, PHP openssl_private_decrypt, Python PKCS1v15) bekerja langsung.
Decrypt only on your backend. Never ship the private key to a browser or mobile app. Verify the JWT signature first (see Verify the JWT), then decrypt data.my_identity. Dekripsi hanya di backend Anda. Jangan pernah mengirim private key ke browser atau aplikasi mobile. Verifikasi tanda tangan JWT dulu (lihat Verifikasi JWT), baru dekripsi data.my_identity.

Verify the JWTVerifikasi JWT

The token is an EdDSA / Ed25519 JWT. Verify its signature on your backend with the JWKS, then read the claims: Token adalah JWT EdDSA / Ed25519. Verifikasi tanda tangannya di backend memakai JWKS, lalu baca klaim:

{
  "iss": "https://id.privy.id",   // issuer
  "sub": "<identity ref>",
  "aud": "<merchant_id>",
  "session_id": "…",
  "merchant": "…",
  // shared data, grouped by section (fields determined by Privy)data yang dibagikan, dikelompokkan per section (field ditentukan Privy)
  "data": {
    "my_contact_data": { "email":"…", "phone":"…" },   // plaintext (both types)
    "my_identity": "<base64 RSA-4096 ciphertext>"          // Identity only — decrypt (see #encrypted)
  },
  "verification": { "method":"phone_lookup+liveness_face_match" },
  "iat": , "exp": 
}
Always check aud = your merchant id, exp not passed, and iss matches.Selalu cek aud = merchant id kamu, exp belum lewat, dan iss sesuai.

Security notesCatatan keamanan

  • API key stays in the backend. Sessions are created server-side; never expose X-API-Key to the browser.API key hanya di backend. Sesi dibuat server-side; jangan expose X-API-Key ke browser.
  • Validate event.origin on the postMessage listener — accept only the widget origin.Validasi event.origin pada listener postMessage — hanya terima dari origin widget.
  • Verify the JWT (signature via JWKS + aud/exp/iss) before trusting the data.Verifikasi JWT (signature via JWKS + aud/exp/iss) sebelum mempercayai data.
  • The exchange code is useless without the API key — safe to travel the front channel; still single-use & short-lived.Exchange code tak berguna tanpa API key — aman melintas front-channel; tetap single-use & short-lived.
  • The iframe must set allow="camera"; register the merchant origin in the allowlist.iframe wajib allow="camera"; daftarkan origin merchant di allowlist.
  • Data/token only reach a verified embedder; an unknown host receives the event signal without payload.Data/token hanya sampai ke embedder terverifikasi; host tak dikenal hanya menerima sinyal event tanpa payload.

Error codesKode error

HTTPCodeKodeMeaningArti
400invalid_inputBad body/parameterBody/parameter tidak valid
401unauthorizedWrong API key / tokenAPI key / token salah
404not_foundSession/merchant/code not foundSesi/merchant/kode tidak ditemukan
409invalid_stateTransition not allowedTransisi state tidak diizinkan
410session_expiredSession expiredSesi kadaluarsa
423session_blocked3 liveness failuresGagal liveness 3×
429rate_limitedToo many requestsTerlalu banyak permintaan

Error shapeFormat error: { "error": { "code": "…", "message": "…" } }

Changelog

v1.0Jul 2026
  • Initial public release of the Privy Digital ID integration API.Rilis publik pertama API integrasi Privy Digital ID.
  • Session flow: POST /v1/sessions → embed → privy:successPOST /v1/sessions/exchange.Alur sesi: POST /v1/sessions → embed → privy:successPOST /v1/sessions/exchange.
  • Data-sharing consent moved to after the face match; consent screen rendered from the shared-data sections.Consent berbagi data pindah ke setelah pencocokan wajah; layar consent dirender dari section data yang dibagikan.
  • Unified delivery: verified data as a JWT via exchange code + callback; exchange held in a fast store (in-memory / Redis).Delivery terpadu: data terverifikasi sebagai JWT via exchange code + callback; exchange ditahan di penyimpanan cepat (in-memory / Redis).
  • Optional sections preview on privy:success for verified embedders.Pratinjau sections opsional pada privy:success untuk embedder terverifikasi.
  • Added Camera (WebView SDK): the liveness camera uses getUserMedia({ video: true }) — no native bridge — with the iOS/Android integrator setup and the camera_unsupported / camera_missing_permission rejection contract.Menambahkan Kamera (SDK WebView): kamera liveness memakai getUserMedia({ video: true }) — tanpa bridge native — beserta setup integrator iOS/Android dan kontrak penolakan camera_unsupported / camera_missing_permission.
  • Documented two credential verification types: Contact (contact data) and Identity (OTP 2FA → encrypted PII). Both accept phone / email / NIK / Privy ID input; type is fixed per credential.Mendokumentasikan dua jenis verifikasi credential: Contact (data kontak) dan Identity (OTP 2FA → PII terenkripsi). Keduanya menerima input phone / email / NIK / Privy ID; jenis tetap per credential.
  • Identity flow implemented: OTP (2FA) runs after the face match, before consent (awaiting_otp state), ordered by the credential's authentication_method. The my_identity PII is delivered encrypted (per-merchant RSA-4096, PKCS#1 v1.5) in the JWT data claim; the merchant decrypts it with their private key. Decrypt example switched to node-forge (Node's native privateDecrypt blocks PKCS#1 v1.5, CVE-2023-46809).Flow Identity diimplementasikan: OTP (2FA) berjalan setelah pencocokan wajah, sebelum consent (state awaiting_otp), urutannya mengikuti authentication_method credential. PII my_identity dikirim terenkripsi (RSA-4096 per-merchant, PKCS#1 v1.5) di klaim data JWT; merchant mendekripsi dengan private key-nya. Contoh dekripsi beralih ke node-forge (native privateDecrypt Node memblokir PKCS#1 v1.5, CVE-2023-46809).
Future versions and their changes will be listed here, selectable from the version dropdown at the top. Versi berikutnya beserta perubahannya akan dicantumkan di sini, dapat dipilih dari dropdown versi di atas.