# User Journey

This page shows what the user sees inside the widget. It maps each screen to
the widget event that it sends. Privy operates each screen. The merchant builds
only the host page around the widget.

Both data packages use the same journey. The package changes only the data
that the merchant receives. The screens show the mobile size.

<figure>
  <img
    src="/digital-id/images/figure-03.png"
    alt="User journey: identifier input, liveness and face match, OTP, consent, and success, with the face-mismatch and rejected-or-timed-out branches"
    style={{ borderRadius: "0.5rem" }}
  />
  <figcaption>User journey overview: main path, failure branches, and widget events.</figcaption>
</figure>

| Step | Screen | Widget event |
| --- | --- | --- |
| 1 | Identifier input | `privy:ready` on startup |
| 2 | Liveness, then automatic face match | None |
| 2a | Face verification failed | `privy:error` after the third failure |
| 3 | OTP | None |
| 4 | Consent | None |
| 4a | No data shared: rejected or timed out | `privy:cancel` if rejected. `privy:error` with `session_expired` if timed out. |
| 5 | You're all set | `privy:success` |

## 1. Identifier input

The widget opens on **Login to your account**. The screen shows the merchant
display name. The user enters one identifier. Each identifier works for every
merchant. The identifier does not change the data package.

| Field | Accepts |
| --- | --- |
| PrivyID, email, or NIK | A PrivyID, the email address registered to the Privy account, or a 16-digit NIK. |
| Mobile number | The mobile number registered to the Privy account. The country-code selector defaults to Indonesia (+62) and supports international numbers. |

**Continue** becomes active after the user enters an identifier. If Privy finds
the identity, the widget starts two-factor authentication: a face match first,
then an OTP. If no registered identity matches, the widget shows an error on
this screen.

The user can change the widget language from the selector at the bottom of
each screen.

<div style={{ display: "flex", flexWrap: "wrap", gap: "1rem", alignItems: "flex-start" }}>
  <figure style={{ margin: 0, width: "220px" }}>
    <img src="/digital-id/images/s1_input.png" alt="Login to your account screen with the PrivyID, email, or NIK field and the mobile number field" />
    <figcaption>1. Login to your account</figcaption>
  </figure>
</div>

## 2. Liveness and face match

Privy first confirms that a live person is in front of the camera. Then Privy
compares that face with the face recorded for that identity. The widget moves
through four stages:

| Stage | What happens |
| --- | --- |
| 2.1 Preparation | The widget explains the step and shows tips: good lighting, the whole face visible, no other person behind, and neat dress. When the user taps **I'm ready!**, the user agrees to the Terms of Use and Privacy Notice. |
| 2.2 Preparing the camera | The camera opens with a blurred preview and a face guide. The widget guides the user into position. |
| 2.3 Face capture | The widget gives live prompts and captures the face. **Face matched!** confirms that the face is correctly positioned and captured. |
| 2.4 Comparing | Privy compares the selfie from liveness with the face recorded for that identity. |

During stages 2.2 and 2.3, the user can tap **Cancel** to leave the capture. A
countdown shows at the top right of each screen.

<div style={{ display: "flex", flexWrap: "wrap", gap: "1rem", alignItems: "flex-start" }}>
  <figure style={{ margin: 0, width: "180px" }}>
    <img src="/digital-id/images/l1_prep.png" alt="Liveness detection preparation screen with four tips and the I'm ready button" />
    <figcaption>2.1 Preparation</figcaption>
  </figure>
  <figure style={{ margin: 0, width: "180px" }}>
    <img src="/digital-id/images/l2_camera.png" alt="Camera preview with a blurred face and the prompt Move your face closer" />
    <figcaption>2.2 Preparing the camera</figcaption>
  </figure>
  <figure style={{ margin: 0, width: "180px" }}>
    <img src="/digital-id/images/l3a_raise.png" alt="Face capture prompt: raise the phone to eye level and look at the camera" />
    <figcaption>2.3 Face capture prompt</figcaption>
  </figure>
  <figure style={{ margin: 0, width: "180px" }}>
    <img src="/digital-id/images/l3b_look.png" alt="Face capture prompt: look at the camera with the face in the circle" />
    <figcaption>2.3 Face capture prompt</figcaption>
  </figure>
  <figure style={{ margin: 0, width: "180px" }}>
    <img src="/digital-id/images/l3c_matched.png" alt="Face capture complete with the message Face matched" />
    <figcaption>2.3 Face captured</figcaption>
  </figure>
  <figure style={{ margin: 0, width: "180px" }}>
    <img src="/digital-id/images/l4_compare.png" alt="Comparing your face screen with a progress indicator" />
    <figcaption>2.4 Comparing your face</figcaption>
  </figure>
</div>

The faces on these screens are illustrations. If the faces match, the widget
goes to the OTP step.

> The widget requests the camera when the liveness capture starts. The host
> must permit it. Add `allow="camera"` to the iframe, and declare the camera
> permission in a native application. See
> [Camera and Permissions](/digital-id/guides/camera-and-permissions). A
> missing permission stops the journey at step 2.

### 2a. Face does not match

If the face match fails, the widget shows **Face verification failed**. The
screen tells the user to move to a well-lit area and keep the face clearly
visible. **Try again** starts liveness again.

| Attempt | Result |
| --- | --- |
| First or second failure | The user can try again in the same session. |
| Third failure | The session is blocked and cannot continue. The widget sends `privy:error`. Privy policy decides whether the user can start a new session. See the [recovery guide](/digital-id/api-reference/errors#recovery-guide). |

<div style={{ display: "flex", flexWrap: "wrap", gap: "1rem", alignItems: "flex-start" }}>
  <figure style={{ margin: 0, width: "220px" }}>
    <img src="/digital-id/images/s2x_facefail.png" alt="Face verification failed screen with the Try again button" />
    <figcaption>2a. Face verification failed</figcaption>
  </figure>
</div>

## 3. OTP

After a successful face match, Privy sends a 6-digit OTP. WhatsApp is the
default channel. The screen shows where Privy sent the code, a countdown before
**Resend code** becomes available, and **Confirm**.

**Or use other methods** opens a channel picker. The user can receive the code
by email, text message, or WhatsApp. The destinations are masked. They are the
contact details registered to the Privy account of the user. After OTP
confirmation, the widget shows the consent screen.

<div style={{ display: "flex", flexWrap: "wrap", gap: "1rem", alignItems: "flex-start" }}>
  <figure style={{ margin: 0, width: "220px" }}>
    <img src="/digital-id/images/s3a_otp.png" alt="Verify your mobile number screen with six OTP digit fields and a masked destination number" />
    <figcaption>3.1 Verify your mobile number</figcaption>
  </figure>
  <figure style={{ margin: 0, width: "220px" }}>
    <img src="/digital-id/images/s3b_methods.png" alt="Channel picker with email address, mobile number, and WhatsApp, each with a masked destination" />
    <figcaption>3.2 Choose another method</figcaption>
  </figure>
</div>

> Privy sets the default OTP channel for each merchant during provisioning. If
> the merchant gives no preference, the default is WhatsApp.

## 4. Consent

After OTP confirmation, the widget shows **Confirmation**:

| Element | Description |
| --- | --- |
| Countdown | The time left to respond, at the top right. The default is 5 minutes. Privy can configure it for each merchant. |
| Requested data | The data sections that the merchant receives. **My Contact Data** is `my_contact_data`: email and mobile number. **My Personal Identity** is `my_identity`: PrivyID, full name, date of birth, identity number, identity image, and selfie image. It appears only with the Identity package. |
| Merchant data policy | "Check how merchant handle your data" links to the merchant terms of service and privacy policy. |
| Consent statement | The user consents, under the Privy Privacy Notice, to Privy sharing the data. |
| Actions | **Continue** shares the data. **Reject this request** shares nothing. |

<div style={{ display: "flex", flexWrap: "wrap", gap: "1rem", alignItems: "flex-start" }}>
  <figure style={{ margin: 0, width: "240px" }}>
    <img src="/digital-id/images/s4_consent.png" alt="Confirmation screen listing My Personal Identity and My Contact Data, the merchant data policy, the consent statement, and the Continue and Reject this request buttons" />
    <figcaption>4. Confirmation. The sample contact data is fictitious.</figcaption>
  </figure>
</div>

> Give your terms-of-service and privacy-policy URLs to Privy, so that the
> consent screen can link to them. Users are more likely to continue when they
> can see how the merchant uses their data.

### 4a. Request rejected or timed out

If the user taps **Reject this request**, or does not respond before the
confirmation window ends, no data is shared. The widget shows **No data shared**
and gives control back to the merchant.

| Case | Widget event | Retry |
| --- | --- | --- |
| The user rejects the request. | The widget sends `privy:cancel`. | New session. The user starts again from step 1. |
| The confirmation window ends. | The session expires. The widget sends `privy:error` with `session_expired`. | New session. The user starts again from step 1. |

<div style={{ display: "flex", flexWrap: "wrap", gap: "1rem", alignItems: "flex-start" }}>
  <figure style={{ margin: 0, width: "220px" }}>
    <img src="/digital-id/images/s4x_rejected.png" alt="No data shared screen that takes the user back to the merchant" />
    <figcaption>4a. No data shared</figcaption>
  </figure>
</div>

## 5. Success

When the user taps **Continue** on the consent screen, the widget shows
**You're all set**. At the same time, the widget sends `privy:success` with the
`exchange_code`. The event goes to the host page, or through the native bridge
in a WebView.

The widget does not redirect the browser. The host page listens for
`privy:success` and closes or hides the widget. Then it sends the exchange code
to the merchant backend and moves the user to the next merchant screen.

"Tap here to continue" on this screen and "Tap here to go back" on step 4a
operate the same way. They give control to the host through the widget event.

<div style={{ display: "flex", flexWrap: "wrap", gap: "1rem", alignItems: "flex-start" }}>
  <figure style={{ margin: 0, width: "220px" }}>
    <img src="/digital-id/images/s5_success.png" alt="You're all set screen that takes the user back to the merchant" />
    <figcaption>5. You're all set</figcaption>
  </figure>
</div>

## What the merchant builds

Privy owns each screen on this page. The merchant owns the content around them.
Widget events control only the user interface. Base business decisions on the
verified JWT.

| Moment | Merchant responsibility |
| --- | --- |
| Before the widget | An entry point that asks the merchant backend to create a session. Use a button such as **Login with PrivyID** that follows the [Privy button guideline](/digital-id/guides/privy-button). |
| While the widget is open | A full-height iframe or WebView with camera permission. Keep `session_id` and `expires_at` on the backend. |
| On `privy:success` | Close the widget. Send `exchange_code` to the backend, verify the JWT, and then show the next merchant screen. |
| On `privy:cancel` | Close the widget. Offer a way to start again with a new session. |
| On `privy:error` | Close the widget. For `session_expired`, offer to start again with a new session. For a blocked session, follow the [recovery guide](/digital-id/api-reference/errors#recovery-guide). |
