# Integration Guide: Stripe Payments

This guide explains how to accept online card payments through Innovorder's Stripe integration: how the platform model works, exactly what `POST /orders` expects in the payment `metadata` field, how to save cards for reuse, and how to handle 3-D Secure (SCA) challenges.

#### How Stripe works at Innovorder

-   We use **Stripe Connect with direct charges**: there is a single Innovorder **platform** Stripe account, and each restaurant is a **connected account** (`acct_…`).
-   Card PaymentMethods are always created and stored **on the platform account**.
-   When an order is placed, the Innovorder backend does the bridging for you: it clones the PaymentMethod to the restaurant's connected account, then creates and confirms the PaymentIntent there.
-   Client-side, you only ever use the **Innovorder platform publishable key**. You never use a restaurant's own Stripe keys. The single exception is the 3-D Secure step (step 5 below).

#### API-driven only - Stripe Checkout is not supported

Stripe's hosted payment page (`checkout.stripe.com`) and Payment Links cannot be used with Innovorder. The payment is created by our backend on the restaurant's connected account as an integral part of the order transaction (order validation, ticket creation, automatic refund on failure, 3-D Secure orchestration). A Checkout Session would create a payment outside that lifecycle, and no order would exist. Stripe is only used client-side for card collection and tokenization; everything payment-related goes through the Innovorder API.

### 1\. Get the Platform Publishable Key

Tokenizing cards requires the **Innovorder platform publishable key**. This key is provided as part of an **integration project**: contact [support@innovorder.fr](mailto:support@innovorder.fr) to set one up and receive the key for the target environment.

Important: never create Stripe objects with a restaurant's own keys, and never pass a `stripeAccount` option when tokenizing. A PaymentMethod created on the wrong account cannot be used by our backend and the order will fail with `resource_missing`.

### 2\. Tokenize the Card

Initialize the Stripe SDK (Stripe.js on the web, or the Stripe mobile SDK) with the platform publishable key, **without** any `stripeAccount` parameter, and create a PaymentMethod from the card fields. The resulting `pm_…` id lives on the platform account, which is exactly where our backend expects it.

```javascript
const stripe = await loadStripe(INNOVORDER_PLATFORM_PUBLISHABLE_KEY); // no stripeAccount option

const { paymentMethod } = await stripe.createPaymentMethod({
    type: 'card',
    card: cardElement,
});
// paymentMethod.id → "pm_..." - send it in the order payment metadata (step 3)
```

### 3\. Create the Order - the Metadata Contract

A Stripe payment is one entry of the `payments` array of `POST /orders` (see [Create Orders](https://developers.innovorder.io/docs/orders/orders-create.md) for the full request body). The Stripe reference goes in the payment's `metadata` field, which must be a **JSON-encoded string** (a string _containing_ JSON - note the escaping below) holding at least one of these two keys:

| Key | Type | Use case |
| --- | --- | --- |
| stripePaymentMethodId | string (`pm_…`) | One-shot payment with a freshly tokenized card (step 2). Also works for guest / anonymous checkout. |
| cardId | integer | A card previously saved through `POST /cards/v2` (step 4). Requires the end customer to be authenticated and to own the card. |

### `POST /orders` - Create an order paid with Stripe

Standard order creation with a Stripe payment entry. The metadata field carries the Stripe reference as a JSON-encoded string. We recommend sending an idempotency-key header to protect against double submissions.

#### Parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| idempotency-key | string (header) | No | Recommended. Unique key protecting against double order submission. |

#### Request Body

```json
{
  "restaurantId": 2285,
  "channelId": 2,
  "consumptionMode": "MODE_TAKE_AWAY",
  "menuId": 24194,
  "cart": [
    {
      "productId": 1670891,
      "quantity": 1,
      "steps": []
    }
  ],
  "expectedAt": "2026-07-23T12:30:00.000Z",
  "payments": [
    {
      "paymentMethodId": 169092,
      "amount": 3150,
      "quantity": 1,
      "metadata": "{\"stripePaymentMethodId\":\"pm_1AbCdEf...\"}"
    }
  ]
}
```

##### Request Body Properties

Every field in the example is listed below. Explicit requiredness is shown when the endpoint contract defines it.

| Property | Type | Example | Description |
| --- | --- | --- | --- |
| restaurantId | integer | 2285 | Identifier of the restaurant. |
| channelId | integer | 2 | Identifier of the associated channel. |
| consumptionMode | string | "MODE\_TAKE\_AWAY" | The consumption mode value. |
| menuId | integer | 24194 | Identifier of the menu. |
| cart | array | \[…\] | List of cart entries. |
| cart\[\] | object | {…} | Object containing cart fields. |
| cart\[\].productId | integer | 1670891 | Identifier of the product. |
| cart\[\].quantity | integer | 1 | The quantity value. |
| cart\[\].steps | array | \[\] | List of steps entries. |
| expectedAt | string | "2026-07-23T12:30:00.000Z" | Date or timestamp for expected. |
| payments | array | \[…\] | List of payments entries. |
| payments\[\] | object | {…} | Object containing payments fields. |
| payments\[\].paymentMethodId | integer | 169092 | Identifier of the associated payment method. |
| payments\[\].amount | integer | 3150 | The amount value. |
| payments\[\].quantity | integer | 1 | The quantity value. |
| payments\[\].metadata | string | "{\\"stripePaymentMethodId\\":\\"pm\_1AbCdEf...\\"}" | Additional metadata supplied with the response. |

#### Response

```json
{
  "status": 200,
  "code": "order_created",
  "message": "Your order was successfully created.",
  "data": {
    "orderId": 1927055,
    "ticketNumber": "1747_2285_2026-07-23_2",
    "mainStatus": "VALIDATED",
    "paymentStatus": "PAID",
    "requiredAction": null
  }
}
```

##### Response Properties

Every field in the example is listed below. Explicit requiredness is shown when the endpoint contract defines it.

| Property | Type | Example | Description |
| --- | --- | --- | --- |
| status | integer | 200 | HTTP status code returned by the API. |
| code | string | "order\_created" | Machine-readable application code for the result. |
| message | string | "Your order was successfully created." | Human-readable result message. Do not use this value for program logic. |
| data | object | {…} | Endpoint-specific response payload. |
| data.orderId | integer | 1927055 | Identifier of the order. |
| data.ticketNumber | string | "1747\_2285\_2026-07-23\_2" | The ticket number value. |
| data.mainStatus | string | "VALIDATED" | The main status value. |
| data.paymentStatus | string | "PAID" | The payment status value. |
| data.requiredAction | null | null | The required action value. |

Note: `paymentMethodId` (integer) is Innovorder's internal id of the "Stripe" payment method configured for the restaurant - it is _not_ a Stripe object. The Stripe reference only ever travels inside `metadata`.

#### When is `stripe_payment_metadata_invalid` (400) returned?

-   `metadata` is missing or an empty string (it is optional in the general order schema, but **required** for Stripe payments);
-   `metadata` is not parseable JSON;
-   the parsed object contains neither key (e.g. `"{}"`);
-   `cardId` is not a JSON integer - `"{\"cardId\":\"42\"}"` (string) fails, `"{\"cardId\":42}"` passes;
-   `stripePaymentMethodId` is not a string, or is an empty string;
-   `cardId` is sent on an unauthenticated request - guest checkout can only use `stripePaymentMethodId`.

Classic trap: sending `metadata` as a JSON _object_ instead of a string fails even earlier with a generic `invalid_parameters` error. It really must be `"metadata": "{\"stripePaymentMethodId\":\"pm_xxx\"}"` - not `"metadata": {"stripePaymentMethodId": "pm_xxx"}`. Extra keys inside the JSON string are tolerated and ignored; if both keys are present, `cardId` takes precedence.

### 4\. Save a Card for Reuse (Optional)

To let an authenticated customer reuse a card, register it through the [Cards (Stripe)](https://developers.innovorder.io/docs/payments/cards-stripe.md) SetupIntent flow - the SDK stays initialized with the platform key (no `stripeAccount`):

```javascript
// 1. Request a SetupIntent (no cardToken) - POST /cards/v2
//    { "name": "My Visa" }  →  { "client_secret": "seti_..._secret_..." }

// 2. Confirm it client-side with the card fields
const { setupIntent } = await stripe.confirmCardSetup(clientSecret, {
    payment_method: { card: cardElement },
});

// 3. Persist the card - POST /cards/v2
//    { "name": "My Visa", "cardToken": setupIntent.payment_method }
//    →  { "cardId": 4521, "brand": "visa", "last4": "4242", "name": "My Visa" }

// 4. Pay with the saved card - POST /orders
//    "metadata": "{\"cardId\":4521}"
```

The Stripe Customer is created automatically on our platform account at the first card save - you never create or fetch it. There are no Ephemeral Keys and no "PaymentSheet-style" Customer endpoints in the Innovorder API: our flow does not use them.

### 5\. Handle 3-D Secure (SCA)

The PaymentIntent is confirmed server-side. If the cardholder's bank requires authentication, `POST /orders` still returns **HTTP 200**, but with code `required_action`:

```json
{
  "status": 200,
  "code": "required_action",
  "message": "Additional action required.",
  "data": {
    "orderId": 1927055,
    "requiredAction": {
      "type": "challengeStripe",
      "intentId": "pi_xxx",
      "requires_action": true,
      "payment_intent_client_secret": "pi_xxx_secret_yyy"
    }
  }
}
```

1.  **Re-initialize the Stripe SDK on the restaurant's connected account**: platform publishable key + `stripeAccount: <stripeUserId>` (`acct_…`). The `stripeUserId` is exposed in the restaurant's Stripe module configuration returned by [Get Web Ordering Configuration](https://developers.innovorder.io/docs/brands/brand-config.md) (`restaurant.moduleStripe.stripeUserId`). This is the _only_ place the connected account ever appears client-side - the PaymentIntent lives on the restaurant's account, so the challenge must run there.
2.  Run the challenge: `handleNextAction` / `handleCardAction(payment_intent_client_secret)`.
3.  Confirm the order with `POST /orders/confirm` (see [Confirm Payment](https://developers.innovorder.io/docs/orders/orders-create.md)), sending the `intentId` from `requiredAction` as `paymentIntentId`.

```javascript
const stripeConnected = await loadStripe(INNOVORDER_PLATFORM_PUBLISHABLE_KEY, {
    stripeAccount: restaurant.moduleStripe.stripeUserId, // acct_...
});

const { paymentIntent, error } = await stripeConnected.handleCardAction(clientSecret);
if (!error) {
    // POST /orders/confirm  { "orderId": 1927055, "paymentIntentId": paymentIntent.id }
}
```

If the intent still requires action at confirm time, `POST /orders/confirm` returns a 400 `stripe_still_requires_action` with the `client_secret` again in `extraData`, so you can retry the challenge.

### Error Reference

| Code | HTTP | Meaning |
| --- | --- | --- |
| stripe\_payment\_metadata\_invalid | 400 | The payment `metadata` does not match the contract of step 3. |
| invalid\_parameters | 400 | Schema violation - e.g. `metadata` sent as an object instead of a string. |
| stripe\_not\_activated | 400 | Stripe is not configured/activated for that restaurant. |
| resource\_missing | 500 | The `pm_…` was not found on the platform account - almost always a PaymentMethod created with the wrong key or with a `stripeAccount` option set. |
| card\_not\_found / card\_forbidden | 400 / 403 | `cardId` unknown, or not owned by the authenticated customer. |
| stripe\_card\_declined / stripe\_card\_expired | 403 / 400 | Bank refusal. |
| required\_action | 200 | Not an error - a 3-D Secure challenge is needed (step 5). |
| stripe\_still\_requires\_action | 400 | Challenge not completed before `POST /orders/confirm`. |
