> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gnosispay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# In-App 3DS Push Provisioning

> Allow cardholders approve or decline a 3D Secure challenge without leaving the app

## Overview

In-app 3DS push provisioning means the cardholder approves or declines a card transaction's 3D Secure (3DS) challenge **directly inside the app**, in real time instead of being redirected to a bank page, sent an SMS one-time passcode, or otherwise taken out of the app for verification.

While a transaction is in flight, the pending challenge is delivered to the app (by webhook or by polling the API), the cardholder reviews the merchant, amount, and expiry on screen, and approves or declines by signing with their connected wallet. There's no redirect, no context switch, and no separate 3DS page.

## End-to-end flow

```mermaid theme={null}
sequenceDiagram
    participant User
    participant App as Partner App
    participant API as Gnosis Pay user-api
    participant Wallet

    App->>API: GET /3ds/challenges
    API-->>App: Pending challenges
    App->>User: Show payment verification prompt
    User->>App: Approve or decline
    App->>API: POST /3ds/challenges/{id}/decision-challenge
    API-->>App: EIP-712 typed data
    App->>Wallet: signTypedData
    Wallet-->>App: Signature
    App->>API: POST /3ds/challenges/{id}/decision<br/>+ signature and nonce headers
    API-->>App: Processing or terminal status
    loop While non-terminal
        App->>API: GET /3ds/challenges/{id}
        API-->>App: Current status
    end
```

## Discovering a pending challenge

A challenge can be discovered one of two ways. Both produce the same `challengeId`, which drives the identical decision flow described below.

<Tabs>
  <Tab title="Webhook (push)">
    Subscribe to the `card.3ds.challenge.created` event. It fires as soon as a challenge is created for a card no polling required.

    ```json theme={null}
    {
      "id": "evt_46c230d62603b2dd9f759f2e937a50cb",
      "type": "card.3ds.challenge.created",
      "createdAt": "2026-09-15T10:00:00Z",
      "data": {
        "id": "72544c02-5f8d-46cb-9659-402de138341a",
        "cardId": "3866f756-18c8-40dd-8d06-a5b368fcac4f",
        "accountId": "76304219-e900-4396-a7bd-60271fe621d6",
        "amount": "2599",
        "currency": "USD",
        "decimals": 2,
        "merchant": {
          "id": "merchant-123",
          "displayName": "Example Store",
          "country": "USA",
          "categoryCode": "5732"
        },
        "expiresAt": "2026-09-15T10:05:00Z",
        "notificationAttempt": 1
      }
    }
    ```

    Use `data.id` directly as `challengeId`. `data.notificationAttempt` is a redelivery counter webhooks can be delivered more than once, so handle deliveries idempotently, keyed on `data.id`.
  </Tab>

  <Tab title="Polling the API (pull)">
    Call GET [/user-api/3ds/challenges](/api-reference/3ds/get-3dschallenges)

    Response :

    ```json theme={null}
    {
      "serverTime": "2023-11-07T05:31:56Z",
      "data": [
        {
          "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
          "cardId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
          "accountId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
          "amount": "2599",
          "currency": "USD",
          "decimals": 2,
          "merchant": {
            "id": "merchant-123",
            "displayName": "Example Store",
            "country": "USA",
            "categoryCode": "5732"
          },
          "status": "pending",
          "startedAt": "2023-11-07T05:26:56Z",
          "expiresAt": "2023-11-07T05:31:56Z",
          "decisionAvailable": true
        }
      ]
    }
    ```

    Use each item's `id` as `challengeId`.

    * Check for pending challenges as soon as an authenticated, onboarded cardholder enters the app, then refresh every 5 seconds and whenever the app regains visibility or focus.
    * Use the response's top-level `serverTime` to clock-adjust the `expiresAt` countdown, rather than trusting the device clock.
    * Only show controls when `decisionAvailable` is `true`; otherwise display the challenge's existing `status` / `decision`.
  </Tab>
</Tabs>

`amount`, `currency`, `decimals`, and the merchant display name are always present. `amount` is a non-negative minor-unit integer encoded as a decimal string; `"0"` is valid. The merchant ID remains optional and is never used as a fallback for the display name or signed decision.

## Deciding a challenge

Approve and decline go through the identical signed flow, there is no unsigned decline shortcut.

<Steps>
  <Step title="Show the verification prompt">
    Display the merchant, the amount (convert the minor-unit `amount` using the challenge's own `decimals` value, never assume 2 decimal places), currency, and optional country / MCC. Count down to `expiresAt`, adjusted for clock skew using `serverTime`. If the countdown reaches zero, treat the challenge as expired and move to the next pending one, if any.
  </Step>

  <Step title="Request decision-bound typed data">
    ```
    POST /user-api/3ds/challenges/{challengeId}/decision-challenge
    ```

    Send the selected `decision` (`approve` or `decline`). The response contains the complete structured EIP-712 typed data:

    ```json theme={null}
    {
      "domain": {
        "name": "Gnosis Pay",
        "version": "1",
        "chainId": 42220
      },
      "primaryType": "ThreeDSDecision",
      "types": {
        "ThreeDSDecision": [
          { "name": "decision", "type": "string" },
          { "name": "challengeId", "type": "string" },
          { "name": "amount", "type": "uint256" },
          { "name": "currency", "type": "string" },
          { "name": "decimals", "type": "uint8" },
          { "name": "merchantName", "type": "string" },
          { "name": "nonce", "type": "uint256" }
        ]
      },
      "message": {
        "decision": "approve",
        "challengeId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
        "amount": "2599",
        "currency": "USD",
        "decimals": 2,
        "merchantName": "Example Store",
        "nonce": "93485723094587230945872309458723094587"
      }
    }
    ```

    Before signing, verify the fixed domain name (`Gnosis Pay`), version (`1`), expected chain ID, primary type, and exact field names, types, and order shown above. Then compare `message.decision`, `challengeId`, `amount`, `currency`, `decimals`, and `merchantName` exactly with the selected decision and the challenge response.

    Do not format `message.amount` as a major-unit decimal. It is the unchanged minor-unit integer string from the challenge (`"2599"`, or `"0"` for a zero amount), which avoids locale and rounding ambiguity. Use `decimals` only to format the amount in your user interface. Do not request a wallet signature if any value is unexpected.
  </Step>

  <Step title="Sign with the connected wallet">
    Pass the typed data to the wallet's `signTypedData` method and have the cardholder confirm in their wallet.
  </Step>

  <Step title="Submit the signed decision">
    ```
    POST /user-api/3ds/challenges/{challengeId}/decision
    ```

    Send the resulting signature and nonce as headers, no key material or challenge secrets are ever transmitted, only the signature and nonce:

    | Header | Value |
    | - | - |
    | `x-eip712-signature` | The wallet's EIP-712 signature |
    | `x-eip712-nonce` | The nonce returned alongside the typed data |
  </Step>

  <Step title="Poll for the outcome">
    ```
    GET /user-api/3ds/challenges/{challengeId}
    ```

    While the status is `pending` or `processing`, keep polling. Stop as soon as a terminal status is returned and show it to the cardholder. If the poll itself errors, retry rather than declaring failure the decision may already be recorded server-side.
  </Step>
</Steps>

## Dismissal and multiple challenges

A cardholder can dismiss a prompt without deciding it. Dismissal is local to the current foreground session only, it doesn't cancel the challenge server-side, and the same challenge reappears on a fresh session. If more than one challenge is pending, present one at a time; after a challenge is dismissed, decided, or expires, immediately show the next pending one.

## Testing in sandbox

Use the [Card Transaction Simulator](/guides/cards/card-simulator#test-in-app-3ds) to exercise the full integrator-facing flow without creating a transaction through the live card network. The simulator replaces Apata for the test challenge, but it deliberately keeps the real partner webhook, polling, wallet-signature, and decision APIs in the path.

<Steps>
  <Step title="Start a 3DS authorization">
    Send an authorization to the Simulator API with `3ds` set to `true`:

    ```json theme={null}
    {
      "transaction_type": "authorization",
      "card_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "amount": "1.00",
      "currency": "USD",
      "merchant_name": "SANDBOX 3DS TEST",
      "merchant_code": "5732",
      "3ds": true
    }
    ```

    The request returns `202 Accepted` with a `simulation_id`. The challenge expires after five minutes.
  </Step>

  <Step title="Discover and decide the challenge">
    Receive `card.3ds.challenge.created` through your configured partner webhook or discover the challenge through `GET /user-api/3ds/challenges`. Then use the same signed approve or decline flow described above; there is no simulator-only decision endpoint.
  </Step>

  <Step title="Check the simulation result">
    Poll the simulation separately from the 3DS challenge:

    ```
    GET /simulator-api/transactions/3ds-simulations/{simulationId}
    ```

    Approval continues to the Pismo simulator and returns its result under `transaction`. Decline and timeout finish without calling Pismo.
  </Step>
</Steps>

<Note>
  You do not need funds to test challenge delivery, polling, signing, decline, or timeout. You need sufficient available balance only if you want the Pismo authorization after an approved 3DS decision to succeed. A successful 3DS approval can therefore produce a completed simulation whose nested transaction is denied for insufficient balance.
</Note>

## Endpoints

| Method | Path | Purpose |
| - | - | - |
| `GET` | [`/user-api/3ds/challenges`](/api-reference/3ds/get-3dschallenges) | List pending challenges (polling path) |
| `GET` | [`/user-api/3ds/challenges/{challengeId}`](/api-reference/3ds/get-3dschallenges-1) | Poll one challenge's current status |
| `POST` | [`/user-api/3ds/challenges/{challengeId}/decision-challenge`](/api-reference/3ds/post-3dschallenges-decision-challenge) | Request the EIP-712 typed data to sign |
| `POST` | [`/user-api/3ds/challenges/{challengeId}/decision`](/api-reference/3ds/post-3dschallenges-decision) | Submit the signed decision |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.