# Google Pay

Fynex supports three ways to accept Google Pay:

- **Hosted Checkout** — Fynex renders the Google Pay button on its own checkout page. Your servers never see the token. Lowest PCI scope and the recommended path for most integrations.
- **Encrypted Direct API** — you render the Google Pay button on your own page, capture the signed token from Google's JS SDK, and submit it **as-is** (still encrypted) to `POST /payments-api/v1/initialize-payment`. Fynex hands the token to the upstream processor for server-side decryption. You don't hold private keys.
- **Decrypted Direct API** — you decrypt the Google Pay token on your server using your own DIRECT-mode key pair, then send Fynex the decrypted card data. Use this only if you already operate ECv2 keys.

> [!CAUTION]
> **Availability today (2026-05-13)**
>
> All three flows are enabled on `staging-api.fynex.ai`. **Production** (`api.fynex.ai`) is not yet running for Direct API; contact Fynex support before you cut over.

## Choosing an integration model

#### Hosted Checkout (recommended)

- You call `POST /payments-api/v1/checkout`, redirect the customer, receive the outcome by webhook or polling.
- Fynex handles `isReadyToPay`, the GP button, the encrypted token, decryption, and the 3DS step-up.
- Lowest PCI scope.
- No Google Pay Console registration required on your side — Fynex's platform merchant ID is used.

#### Encrypted Direct API

- You render the Google Pay button using the Google Pay JS SDK on your own page.
- You forward `paymentData.paymentMethodData.tokenizationData.token` (a signed string) verbatim to `POST /initialize-payment`.
- Fynex hands the signed token to the upstream processor, which decrypts it server-side.
- You don't run ECv2 decryption and don't hold private keys.
- Recommended when Hosted Checkout doesn't fit your UX.

#### Decrypted Direct API

- You register your own Google Pay merchant in the Google Pay Business Console and own the DIRECT-mode key pair.
- Your frontend calls `paymentsClient.loadPaymentData(...)` with `tokenizationSpecification.type = 'DIRECT'`; your backend decrypts the JWE.
- You send the **decrypted** payload (PAN, cryptogram, expiry, …) to `POST /initialize-payment`.
- For integrators who already run ECv2 decryption pipelines.

If you're not sure which to pick, use Hosted Checkout. If you need a non-hosted UI without running decryption yourself, use Encrypted Direct API.

### Why Encrypted is the default Direct API recommendation

Both Direct API flows authorize the same card payment. The split between Encrypted and Decrypted is really a split between *who decrypts the Google Pay wallet token*, and that one decision changes the integration cost dramatically:

| Concern | Encrypted | Decrypted |
|---|---|---|
| Card-data PCI scope | No PAN exposure — opaque ciphertext only (typically SAQ A-EP) | PAN/cryptogram pass through your process memory (SAQ D-Merchant) |
| Private-key management | None | You generate, store, rotate an ECv2 EC P-256 key pair |
| Server-side crypto code | None — forward the signed string verbatim | You implement ECv2 decryption (signed-then-encrypted, AES-CTR + HMAC-SHA256) |
| Google Pay Console setup | Use Fynex's registered gateway (`fondyeu`) — fast | Register your own merchant + public key — slower |
| Breach blast radius | Attacker can't decrypt without the gateway's key | Attacker can read decrypted PANs from memory / heap dumps |
| Time to integrate | Forward one string, get a payment handle | Build + audit a crypto pipeline |

The Decrypted flow only earns its overhead when you already carry a full PCI DSS compliance assessment (ROC) with on-premise key management and want explicit control over the PAN — typically a large merchant doing per-network routing or a payment orchestrator. Anyone smaller should prefer Encrypted.

## Hosted Checkout

The seller-facing API is just `POST /payments-api/v1/checkout` — exactly the same call you would make for a card-only hosted checkout. Google Pay availability is a property of the deploy, not the request.

```bash
curl -sS -X POST "$FYNEX_API/payments-api/v1/checkout" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "externalOrderRef": "ORDER-1042",
    "amount": 49.99,
    "currencyCode": "GBP",
    "countryCode": "GB",
    "description": "Order #1042",
    "returnUrls": {
      "success": "https://merchant.example/orders/1042/success",
      "failure": "https://merchant.example/orders/1042/failure"
    }
  }'
```

Response (truncated to the field you care about):

```json
{
  "sessionId": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b",
  "checkoutUrl": "https://pay.fynex.ai/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b",
  "expiresAt": "..."
}
```

Redirect the customer to `checkoutUrl`. The page that loads will display:

- A card-entry form (always).
- A **Google Pay** button — only if all of the following are true:
  - Fynex has Google Pay enabled for the deploy your checkout session was created on.
  - The customer's browser supports Google Pay (`PaymentsClient.isReadyToPay()` returns true).
  - The customer has at least one saved card in their Google account that Google Pay can tokenize.
- An **Apple Pay** button — only if Apple Pay is enabled on the deploy. Apple Pay availability requires Apple Developer certificates plus an upstream-processor-issued payment processing certificate to be wired in. Until that's done the button stays hidden.

See [Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout) for the full request reference, return-URL behavior, and webhook delivery.

### Customer-side flow

1. **Customer arrives at `https://pay.fynex.ai/checkout/{session_id}`.** Fynex serves an HTML page rendered server-side; the page includes the Google Pay JS SDK and a hidden GP button.

2. **The page runs `isReadyToPay()` in the customer's browser.** If the result is positive *and* the deploy has Google Pay configured, the button becomes visible. Otherwise the page hides it and shows only the card form — there's no "broken button" state.

3. **Customer clicks Google Pay.** The native Google Pay sheet opens; the customer picks a card and authorizes.

4. **The page POSTs the encrypted GP token to `/api/v1/checkout/google-pay-submit`** along with the `sessionId`. The handler:
   - Resolves the **authoritative `amount`, `currencyCode`, and upstream-provider `accountId`** server-side from the CheckoutSession + GenericPayment that back the session. Body-supplied amount/currency are **ignored** to defeat a class of browser-extension / XSS attacks that would otherwise pay $0.01 for a $19.99 order.
   - Sends the encrypted JWE token to the upstream card processor via its Single-Use Token endpoint, using credentials that the processor has enrolled the seller's Google Pay merchant against. The token never leaves the Fynex backend in plaintext.
   - Receives back a `paymentHandleToken` and `merchantRefNum`.

5. **The page completes the payment through the shared checkout contract** — the same initialize/finalize path a card payment takes, using the `paymentHandleToken` and `merchantRefNum` from step 4. There is no separate `complete-google-pay-payment` call: that legacy route bypassed the platform's antifraud and compliance gates and has been removed. The outcome is mirrored onto the payment (`provider_completed` on success, with `providerPaymentId` set).

6. **Customer is redirected** to your `returnUrls.success` or `.failure` URL, exactly as for a card payment.

> [!NOTE]
> Steps 4 and 5 are internal to the Fynex-hosted page. As a seller integration you never call those endpoints; you only see the final state via webhooks, `GET /payments-api/v1/payments/{payment_id}`, or the `returnUrls` redirect.

## Encrypted Direct API

This is the recommended Direct API path. You take ownership of the Google Pay button but **not** of the token decryption — Fynex passes the signed token to the upstream processor, which decrypts it server-side. You don't need private keys, ECv2 libraries, or PCI scope on the card data.

### Prerequisites

1. Your seller account has `google_pay` in `allowedPaymentMethods` (check via `GET /payments-api/v1/payment-methods`).
2. A Google Pay merchant ID in the [Google Pay Business Console](https://pay.google.com/business/console), with domains whitelisted where you render the button.
3. Checkout currency supported (`USD`, `EUR`, `GBP`).

### Step 1: Get a token from Google

In your frontend, load the Google Pay JS SDK and request a payment data with `PAYMENT_GATEWAY` tokenization (Fynex's registered gateway is `fondyeu` — that exact string is what Google's tokenization spec requires):

```javascript
const paymentsClient = new google.payments.api.PaymentsClient({ environment: 'PRODUCTION' });

const paymentDataRequest = {
  apiVersion: 2,
  apiVersionMinor: 0,
  allowedPaymentMethods: [{
    type: 'CARD',
    parameters: {
      allowedAuthMethods: ['PAN_ONLY', 'CRYPTOGRAM_3DS'],
      allowedCardNetworks: ['VISA', 'MASTERCARD', 'AMEX', 'DISCOVER'],
    },
    tokenizationSpecification: {
      type: 'PAYMENT_GATEWAY',
      parameters: {
        gateway: 'fondyeu',
        gatewayMerchantId: '<your-fynex-merchant-id>',
      },
    },
  }],
  transactionInfo: {
    totalPriceStatus: 'FINAL',
    totalPrice: '49.99',
    currencyCode: 'GBP',
  },
  merchantInfo: {
    merchantId: '<your-google-pay-merchant-id>',
    merchantName: 'Your Store',
  },
};

const paymentData = await paymentsClient.loadPaymentData(paymentDataRequest);
// paymentData.paymentMethodData.tokenizationData.token is the JSON-encoded
// signed-and-encrypted payload — pass it through verbatim.
const signedToken = paymentData.paymentMethodData.tokenizationData.token;
```

### Step 2: Call `/initialize-payment` with the signed token

`POST /payments-api/v1/initialize-payment` (headers: `Authorization: Bearer <seller_token>`, `Idempotency-Key: <UUID v4>`):

```json
{
  "externalOrderRef": "ORDER-100106",
  "amount": 49.99,
  "paymentType": "card",
  "paymentMethod": "google_pay",
  "currencyCode": "GBP",
  "countryCode": "GB",
  "autoSettlement": true,
  "googlePayData": {
    "paymentToken": {
      "apiVersion": 2,
      "apiVersionMinor": 0,
      "paymentMethodData": {
        "type": "CARD",
        "description": "Visa •••• 1234",
        "info": {
          "cardNetwork": "VISA",
          "cardDetails": "1234"
        },
        "tokenizationData": {
          "type": "PAYMENT_GATEWAY",
          "token": "<paymentData.paymentMethodData.tokenizationData.token verbatim>"
        }
      }
    }
  },
  "billingDetails": {
    "firstName": "John",
    "lastName": "Doe",
    "email": "john.doe@example.com",
    "addressLine1": "1 Example Street",
    "city": "London",
    "countryCode": "GB",
    "postalCode": "SW1A1AA"
  },
  "returnLinks": [
    { "rel": "default", "href": "https://merchant.example/payments/return", "method": "GET" }
  ]
}
```

Validation rules:

- Exactly one of `tokenizationData.token` (encrypted) or `tokenizationData.decryptedToken` (decrypted, see below) must be present. Providing both returns `400`.
- `tokenizationData.type` is required and must match the payload kind: `"PAYMENT_GATEWAY"` when `token` is set, `"DIRECT"` when `decryptedToken` is set. Empty or mismatched values return `400`.
- Top-level `billingDetails` is required for AVS scoring. Minimum: `countryCode` and `postalCode`. You may use the aliases `country` / `zip`. Omitting them returns a `502` with body `{"error":"upstream card processor returned 400"}`.

Response and capture flow are the same as the decrypted variant — see [Common response shape](#common-response-shape) below.

## Decrypted Direct API

Use this only if you already operate your own Google Pay DIRECT-mode key pair and prefer to decrypt server-side. Fynex receives the decrypted card data and forwards it to the upstream processor's *decrypted-handle* endpoint.

### Prerequisites (in addition to Encrypted)

- Your own Google Pay merchant in the Google Pay Business Console.
- A DIRECT-mode key pair you registered with Google. Fynex never holds your private key.
- A server-side ECv2 decryption library.

### Step 1: Get and decrypt a token from Google

Same JS SDK call as the Encrypted variant, but with `tokenizationSpecification.type = 'DIRECT'` and your `publicKey`:

```javascript
tokenizationSpecification: {
  type: 'DIRECT',
  parameters: {
    protocolVersion: 'ECv2',
    publicKey: '<your-base64-encoded-public-key>',
  },
}
```

Decrypt `paymentData.paymentMethodData.tokenizationData.token` on your server using the [Google Pay payment-data cryptography spec](https://developers.google.com/pay/api/web/guides/resources/payment-data-cryptography). The decrypted payload looks like:

```json
{
  "messageId": "AH2EjtfX...",
  "messageExpiration": "1715620000000",
  "paymentMethod": "CARD",
  "paymentMethodDetails": {
    "authMethod": "CRYPTOGRAM_3DS",
    "pan": "4111111111111111",
    "expirationMonth": 12,
    "expirationYear": 2028,
    "cryptogram": "AgAAAAAABk4...",
    "eciIndicator": "05"
  }
}
```

### Step 2: Call `/initialize-payment` with the decrypted payload

```json
{
  "externalOrderRef": "ORDER-100106",
  "amount": 49.99,
  "paymentType": "card",
  "paymentMethod": "google_pay",
  "currencyCode": "GBP",
  "countryCode": "GB",
  "autoSettlement": true,
  "googlePayData": {
    "paymentToken": {
      "apiVersion": 2,
      "apiVersionMinor": 0,
      "paymentMethodData": {
        "type": "CARD",
        "description": "Visa •••• 1234",
        "info": { "cardNetwork": "VISA", "cardDetails": "1234" },
        "tokenizationData": {
          "type": "DIRECT",
          "decryptedToken": {
            "messageId": "AH2EjtfX...",
            "messageExpiration": "1715620000000",
            "paymentMethod": "CARD",
            "paymentMethodDetails": {
              "authMethod": "CRYPTOGRAM_3DS",
              "pan": "4111111111111111",
              "expirationMonth": 12,
              "expirationYear": 2028,
              "cryptogram": "AgAAAAAABk4...",
              "eciIndicator": "05"
            }
          }
        }
      }
    }
  },
  "billingDetails": { "...": "same as Encrypted" },
  "returnLinks": [ { "rel": "default", "href": "https://merchant.example/payments/return", "method": "GET" } ]
}
```

Server-side validation for the decrypted variant:

- `pan` is **required**.
- `cryptogram` is **required when `authMethod` is `CRYPTOGRAM_3DS`**. `PAN_ONLY` tokens carry no network cryptogram and are accepted on this flow without one.
- `expirationMonth` and `expirationYear` are **required**.
- `eciIndicator` is recommended for `CRYPTOGRAM_3DS` but not enforced by Fynex.

## Common response shape

Both Direct API models share the same response. `POST /initialize-payment` returns `202 Accepted`:

```json
{
  "paymentId": "ORDER-100106",
  "status": "provider_pending",
  "amount": 49.99,
  "currencyCode": "GBP",
  "requiresAction": true,
  "actionUrl": "https://example-acs.test/challenge"
}
```

- When `requiresAction = true`, redirect the customer to `actionUrl` to complete the 3DS challenge. After the challenge, the payment moves through `provider_pending` to `provider_completed`.
- When `requiresAction = false`, the handle is ready for capture.

Then `POST /finalize-payment` (reuse the same `Idempotency-Key` or generate a fresh UUID — both work):

```json
{ "paymentId": "ORDER-100106" }
```

`amount` is optional. Omitting it captures the full authorized amount; passing a value must be **less than or equal to** the originally authorized amount (partial capture).

Response `200 OK`:

```json
{
  "paymentId": "ORDER-100106",
  "status": "provider_completed",
  "providerPaymentId": "pay_01J2EXAMPLE",
  "providerCode": "pp_01",
  "capturedAmount": 49.99,
  "currencyCode": "GBP",
  "failureCode": 0,
  "failureDescription": ""
}
```

## `googlePayData` field reference

### `tokenizationData` — Encrypted (model B)

| Field   | Type   | Required | Description                                                              |
|---------|--------|----------|--------------------------------------------------------------------------|
| `type`  | string | yes      | Must be `"PAYMENT_GATEWAY"` for the encrypted flow.                       |
| `token` | string | yes      | Raw signed token from `paymentData.paymentMethodData.tokenizationData.token`. JSON-encoded string. |

### `tokenizationData` — Decrypted (model C)

| Field             | Type   | Required | Description                                |
|-------------------|--------|----------|--------------------------------------------|
| `type`            | string | yes      | Must be `"DIRECT"` for the decrypted flow.  |
| `decryptedToken`  | object | yes      | Decrypted Google Pay payload — see below.   |

Send **exactly one** of `token` or `decryptedToken` per request. `type` is required and must match the payload kind — mismatches return `400`.

### `decryptedToken`

| Field                      | Type   | Required | Description                                                  |
|----------------------------|--------|----------|--------------------------------------------------------------|
| `gatewayMerchantId`        | string | no       | Echoed verbatim to the upstream processor.                   |
| `messageId`                | string | yes (processor contract) | `messageId` from the decrypted payload. Forwarded, but not enforced by the Fynex validator. |
| `messageExpiration`        | string | yes (processor contract) | Epoch milliseconds as a string. Not checked by the request validator, but it must parse as an integer later — see caveat 2. |
| `paymentMethod`            | string | yes (processor contract) | Always `"CARD"`. Forwarded, but not enforced by the Fynex validator. |
| `paymentMethodDetails`     | object | yes      | See below. Missing returns `400`.                             |

### `paymentMethodDetails`

| Field             | Type   | Required | Description                                                       |
|-------------------|--------|----------|-------------------------------------------------------------------|
| `authMethod`      | string | yes (processor contract) | `"PAN_ONLY"` or `"CRYPTOGRAM_3DS"`. Matched case-insensitively and forwarded upstream in the spec spelling; a value that is neither is passed through unchanged, and the Fynex validator does NOT enforce the enum today. |
| `pan`             | string | yes      | Decrypted card PAN. Missing returns `400`.                        |
| `expirationMonth` | int    | yes (> 0) | 1–12. Zero or missing returns `400`.                             |
| `expirationYear`  | int    | yes (> 0) | Full year (e.g. `2028`). Zero or missing returns `400`.          |
| `cryptogram`      | string | yes when `authMethod` is `CRYPTOGRAM_3DS` | The network cryptogram. Omitting it on a `CRYPTOGRAM_3DS` token returns `400`. `PAN_ONLY` tokens carry no cryptogram and are accepted without one. |
| `eciIndicator`    | string | no       | Recommended for `CRYPTOGRAM_3DS` (usually `"05"`/`"06"`). Not enforced by Fynex; gated to CRYPTOGRAM_3DS at the outbound builder so PAN_ONLY tokens don't get rejected with "Invalid field when authMethod is not CRYPTOGRAM_3DS". |

### `billingDetails` (top level of `InitiatePaymentRequest`)

Required for AVS. Minimum fields: `countryCode` (alias `country`) and `postalCode` (alias `zip`).

### Parent-object presence checks

Before the field-level checks above, the validator walks the request shape and rejects any missing parent. Each `nil` returns `400` with a clear path-rooted message:

- `googlePayData`
- `googlePayData.paymentToken`
- `googlePayData.paymentToken.paymentMethodData`
- `googlePayData.paymentToken.paymentMethodData.tokenizationData`
- `tokenizationData.token` xor `tokenizationData.decryptedToken` — send exactly one; neither or both returns `400`
- `tokenizationData.type == "PAYMENT_GATEWAY"` when `token` is set
- `tokenizationData.type == "DIRECT"` when `decryptedToken` is set

### Header + common body (shared with every paymentMethod)

| Field | Format |
|-------|--------|
| `Idempotency-Key` (HTTP header) | UUID, non-zero |
| `externalOrderRef` | non-empty string |
| `amount` | number > 0 |
| `paymentType` | `"card"` |
| `paymentMethod` | `"google_pay"` |
| `currencyCode` | 3-letter ISO, must pass `IsValidCurrencyCode` |
| `countryCode` | 2-letter ISO |

`paymentType` and `paymentMethod` are additionally checked against each other — an incompatible pair returns `400`.

## Upstream outbound mapping (for debugging processor rejections)

Unlike Apple Pay, the Google Pay DTO mirrors the upstream processor's shape closely — the only nesting difference is at the root: Fynex's `googlePayData` becomes the processor's `googlePay.googlePayPaymentToken`.

| Fynex DTO path | Upstream outbound path |
|----------------|-----------------------|
| `googlePayData.paymentToken.apiVersion` | `googlePay.googlePayPaymentToken.apiVersion` |
| `googlePayData.paymentToken.apiVersionMinor` | `googlePay.googlePayPaymentToken.apiVersionMinor` |
| `googlePayData.paymentToken.paymentMethodData.description` | `googlePay.googlePayPaymentToken.paymentMethodData.description` |
| `…tokenizationData.type` | `…tokenizationData.type` |
| `…tokenizationData.decryptedToken.paymentMethodDetails.pan` | `…tokenizationData.decryptedToken.paymentMethodDetails.pan` |
| `…paymentMethodDetails.expirationMonth` | `…paymentMethodDetails.expirationMonth` |
| `…paymentMethodDetails.expirationYear` | `…paymentMethodDetails.expirationYear` |
| `…paymentMethodDetails.cryptogram` | `…paymentMethodDetails.cryptogram` (sent only when `authMethod` is `CRYPTOGRAM_3DS`, matched case-insensitively) |
| `…paymentMethodDetails.eciIndicator` | `…paymentMethodDetails.eciIndicator` (sent only when `authMethod` is `CRYPTOGRAM_3DS`, matched case-insensitively) |

### Caveats

1. **`authMethod` enum is not enforced.** The validator does not check that `authMethod` is one of `PAN_ONLY` / `CRYPTOGRAM_3DS`. An empty or arbitrary string passes through to the processor, which will reject it with its own (less helpful) error.
2. **`paymentMethod`, `messageId`, `messageExpiration` are required by the processor's sample** but the Fynex validator does not enforce them, and each behaves differently when omitted. `messageId` round-trips exactly as sent, including empty. `paymentMethod` defaults to `"CARD"`. `messageExpiration` must parse as an epoch-milliseconds integer — a missing or malformed value fails the payment **during processing, after the `202`**, with a Fynex error rather than an upstream one. Send all three.
3. **`cryptogram` gating on `authMethod`.** Fynex requires `cryptogram` only when `authMethod` is `CRYPTOGRAM_3DS`, so a `PAN_ONLY` token validates without one — do **not** send a placeholder value. The outbound `cryptogram` + `eciIndicator` are additionally gated on `authMethod` being `CRYPTOGRAM_3DS` (matched case-insensitively) before the request leaves Fynex, which avoids the processor's "Invalid field when authMethod is not CRYPTOGRAM_3DS" error. A value that is **neither** enum neither requires a `cryptogram` nor forwards one, so nothing is demanded of you that Fynex would discard — but the payment will still fail upstream on the unrecognised value. Send one of the two documented values.
4. **`gatewayMerchantId` is forwarded, not ignored.** It is not part of the processor's decrypted-token request sample, but Fynex passes it through verbatim when set. If you send it, send the same value your Google Pay client used — the processor matches this field against the merchant behind the tokenization key, and an unrelated value can be rejected upstream.

## 3DS behavior

Google Pay tokens carry one of two auth methods:

| Auth method | Source | What you'll see |
|---|---|---|
| `CRYPTOGRAM_3DS` | Tokenized device-bound card (Android phone, Wear OS) | The upstream processor **may** waive the customer-facing 3DS challenge since the GP cryptogram already proves cardholder presence. Fynex always sends the `threeDs` block, so you should still handle `requiresAction: true` in your client code in case it doesn't waive. |
| `PAN_ONLY` | Server-tokenized card stored in the Google account (no device cryptogram) | May trigger a 3DS step-up. Direct API: redirect the customer to `actionUrl` and resume on return. Hosted Checkout handles the redirect inside the page. |

> [!CAUTION]
> Do not set `skip3DS` for Google Pay in live traffic. SCA exemptions are tied to MIT/MOTO flows, which Google Pay does not match.

## Payment lifecycle (simplified)

```
draft -> provider_pending -> provider_completed -> settled
                          -> failed
                          -> cancelled
```

| Status                | Meaning                                                                  |
|-----------------------|--------------------------------------------------------------------------|
| `draft`               | Payment created, not yet submitted to the provider.                      |
| `provider_pending`    | Submitted to the provider; awaiting result, 3DS, or capture.             |
| `provider_completed`  | Authorized and captured (or auth+capture in auto-settlement mode).       |
| `settled`             | Funds reached settlement.                                                |
| `failed`              | Declined by the provider, the bank, or the 3DS issuer.                   |
| `cancelled`           | Cancelled by the customer (e.g. abandoned 3DS).                          |

Direct API payments start at `provider_pending`; `draft` is used internally by Hosted Checkout before submission. Intermediate statuses like `new`, `routed`, `authorized`, `provider_risk_review`, `funds_in_flight`, and `deposit_confirmed` may also surface on polling. Post-settlement refund flow has its own statuses (`refund_pending`, `refunded`, `refund_failed`, `refund_cancelled`) — see [Captures & Refunds](https://api.fynex.ai/payments-api/v2/docs#tag/captures-refunds).

## Verifying the outcome

Same as any payment — three options, in decreasing order of recommendation:

1. **Webhook** (`POST /payments-api/v1/webhooks` to register a URL once; Fynex will post `payment.*` events when state changes). See [Webhooks](https://api.fynex.ai/payments-api/v2/docs#tag/webhooks).
2. **REST poll** — `GET /payments-api/v1/payments/{payment_id}` with your bearer token. The `payment_id` path parameter is your `externalOrderRef`. Terminal statuses: `provider_completed`, `settled`, `deposit_confirmed`, `refunded`, `failed`, `cancelled`. See [Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse).
3. **GraphQL** — `genericPayment(id: Int!)` on `/dashboard/graphql` (cookie-auth only — for dashboard integrations, not server-to-server).

The `paymentMethod` field on the response will be `card` regardless of whether the customer used Google Pay or a typed card; the underlying card is always charged via the upstream card processor. To distinguish Google Pay payments at the data level, check the `providerCode` field on the payment response together with any internal markers your dashboard surfaces.

## Testing

- **Sandbox**: set `environment: 'TEST'` on the Google Pay JS side. On Fynex's side the seller account must be in `Demo` operational mode (capital D — that's the canonical value, though the routing layer is case-insensitive).
- **3DS**: the upstream sandbox lets you replay both success and failure challenges.

### Test cards

Google Pay's TEST environment returns a sandbox PAN that is then authorized on the upstream
card processor's sandbox. Google Pay tokens are network-tokenized, so the upstream handle is
created with 3DS skipped — Google Pay payments authorize without a separate 3DS redirect.

Tokenize any PAN the upstream sandbox recognises; for example the universal Visa test PAN
`4111 1111 1111 1111`, or any of the PANs listed in [Test cards & sandbox](https://api.fynex.ai/payments-api/v2/docs#tag/test-cards).
Expiry: any future month/year (4-digit year recommended, e.g. `2028`). CVV: any 3-digit value
(4-digit for AmEx).

> [!CAUTION]
> The card number does not select the authorization outcome on this sandbox. To simulate a
> decline, use the processor's sandbox simulation rules — ask your Fynex contact for the
> current values. Do not assume a particular PAN maps to "frictionless" vs "challenge" vs
> "decline".

## Troubleshooting

### The Google Pay button doesn't appear on the hosted checkout page

In order of frequency:

1. **Google Pay isn't enabled on this deploy yet.** The platform's upstream-processor credentials for Google Pay haven't been provisioned, or the per-deploy GP merchant ID is missing. Sellers can't fix this — contact Fynex support.
2. **The customer's browser or device doesn't support Google Pay.** `isReadyToPay()` returns `false` and the page (correctly) leaves the button hidden. Nothing to fix — the customer can use the card form.
3. **The customer has no saved cards in their Google account.** `isReadyToPay()` may still return `true`, but the GP sheet shows an empty list when the customer opens it. Not a configuration bug.
4. **Environment mismatch.** The deploy points at Google Pay's `PRODUCTION` environment but the merchant ID is only enrolled in `TEST`, or vice versa. Google's SDK silently treats this as "GP unavailable." Contact Fynex support to confirm the configured environment.

### `/initialize-payment` returns 400 "...either token or decryptedToken, not both"

You supplied both `tokenizationData.token` and `tokenizationData.decryptedToken`. Pick one path and remove the other.

### `/initialize-payment` returns 400 "...token or .decryptedToken is required"

You supplied a `tokenizationData` block but neither `token` nor `decryptedToken`. Add the matching field for the model you're using.

### `/initialize-payment` returns 400 "cryptogram is required"

You're on the Decrypted Direct API flow, `authMethod` is `CRYPTOGRAM_3DS`, and the token carries no cryptogram. Send the cryptogram, or — if the token really is a server-tokenized card — set `authMethod` to `PAN_ONLY` (matched case-insensitively), which is accepted without one.

### `/initialize-payment` returns 502 "upstream card processor returned 400"

Most often: missing `countryCode` or `postalCode` in the top-level `billingDetails`. AVS scoring is performed against `billingDetails`, not the address embedded inside `googlePayData`.

### `/initialize-payment` returns 409

`Idempotency-Key` reused with different business intent (`amount`, `currencyCode`, `externalOrderRef`, …). Use a fresh UUID v4.

### The customer authorized Google Pay, but the order page says "failed"

Look at the payment's `failureStage`:

- `failureStage: "authorization"` — the encrypted token reached the upstream processor but the issuer declined the card. Normal card decline; the customer should try a different card or method.
- `failureStage: "settlement"` — the handle was authorized but the settlement call failed. Usually a transient upstream issue; retry the order with a fresh `externalOrderRef`.
- Empty `failureStage` with `status: "cancelled"` — the customer dismissed the GP sheet. The page treats this as a soft exit and does not create a payment.

## Production checklist

For the Encrypted Direct API (recommended for non-hosted UIs):

1. Google Pay merchant ID registered in the Google Pay Business Console.
2. Domains that render the Google Pay button whitelisted in the Google Pay Console.
3. Production seller API token issued (`Authorization: Bearer ...`).
4. `google_pay` enabled for the seller account in the Fynex dashboard.
5. `returnLinks` configured (success / failure URLs).
6. Webhook registered for final status delivery, or polling implemented via `GET /payments/{payment_id}`.

For the Decrypted Direct API, additionally:

7. Server-side DIRECT-mode decryption library in place that can handle ECv2 payloads, and your own DIRECT-mode key pair registered with Google.

## See also

- **[Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — The seller-facing API that creates the checkout session Google Pay runs inside.
- **[Apple Pay](https://api.fynex.ai/payments-api/v2/docs#tag/apple-pay)** — Apple Pay is decrypted-flow today — Fynex receives the decrypted token. See the Apple Pay guide for the payload shape.
- **[3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds)** — How 3DS step-up works inside Hosted Checkout and Direct API flows.
- **[Polling & SSE](https://api.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Verify the payment outcome from your backend.
- **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — HTTP status codes and the per-seller rate-limit headers.
