# Payments

URL: /docs/concepts/payments

Take card payments through the store's own Stripe account and PayPal, handle test and live mode, and turn declines into clear messages.

Elara One takes payments through the store's own Stripe and PayPal accounts. The money goes straight to the merchant, and card details are entered into Stripe's or PayPal's own form, so they never reach Elara One or your servers. This page covers how the two providers fit into checkout and how to handle a payment that does not go through.

## Card data never touches Elara One [#card-data-never-touches-elara-one]

- **Stripe.** The shopper types their card into Stripe's Payment Element, which runs in an iframe served by Stripe. Your page and the API only ever see a PaymentIntent id (`pi_…`).
- **PayPal.** The shopper pays in PayPal's own buttons and window. The API only sees the PayPal order id.

Your storefront server and the API never handle card numbers.

## Connect the providers [#connect-the-providers]

Merchants connect their accounts in the store admin under **Settings → Payments**. Each provider has a set of Test keys and a set of Live keys. The page also shows the webhook address on the store's own hooks host and the provider events to send to it.

Your storefront reads what it needs from `GET /v1/checkout/config`: which `gateways` are on, the Stripe publishable key and the PayPal client id. You never handle the merchant's secret keys.

## Test and live mode [#test-and-live-mode]

`gateway_mode` in `GET /v1/checkout/config` is `test` or `live`, and says which set of keys checkout is using. It is set for the store by Elara One rather than switched in the admin, and the top of the Payments page shows which mode is in use.

- In `test` mode, show a "test payments" banner, and use the provider's test cards and sandbox accounts.
- Load PayPal's JavaScript SDK with the client id for the same mode.
- Staging stores, where your plan includes them, always use test payments.

## Stripe [#stripe]

The Stripe flow uses a PaymentIntent and the Payment Element:

1. `POST /v1/checkout/payment-intent` returns a `client_secret` and the `publishable_key` for the cart's current total.
2. Mount the Payment Element with the client secret and let the shopper confirm.
3. Call `POST /v1/checkout/complete` with `{ "payment": { "gateway": "stripe", "payment_intent": "pi_…" } }`.

```ts title="In the browser, with Stripe.js"
const stripe = Stripe(publishableKey)
const elements = stripe.elements({ clientSecret })
elements.create('payment').mount('#payment-element')

// On "Pay":
const { error, paymentIntent } = await stripe.confirmPayment({
  elements,
  redirect: 'if_required',
  confirmParams: { return_url: 'https://hearthandoak.example/checkout/return' },
})
if (!error) {
  // Ask your server to call POST /v1/checkout/complete with paymentIntent.id.
}
```

### When the money is taken [#when-the-money-is-taken]

`stripe_capture_method` in `GET /v1/checkout/config` tells you how the PaymentIntent takes the money:

| Value | What happens |
|---|---|
| `manual` | Confirming the payment in the browser only authorises the card (the PaymentIntent ends in `requires_capture`). `checkout/complete` checks stock, shipping and promotions, and only then captures. If it refuses, the authorisation is released and nothing is charged. |
| `automatic` | Confirming the payment takes the money. If the order can then no longer be placed, the payment is refunded and checkout returns `409 PAYMENT_REFUNDED`. |

If you mount the Payment Element before creating the PaymentIntent (a deferred intent), pass `stripe_capture_method` as Elements' `captureMethod`. After any refusal from `checkout/complete`, call `POST /v1/checkout/payment-intent` again before letting the shopper pay again.

## PayPal [#paypal]

1. In the PayPal buttons' `createOrder`, call `POST /v1/checkout/paypal/order` and return its `paypal_order_id`.
2. In `onApprove`, call `POST /v1/checkout/paypal/capture` with `{ "paypal_order_id": "…" }` and an `Idempotency-Key`.

The capture is recorded before the order is placed, so a retry never captures twice. If PayPal refuses the shopper's funding source, nothing is taken: let them choose another in the PayPal buttons and call the capture again for the same PayPal order.

<Callout type="info">
The starter storefronts offer Stripe card checkout. PayPal is available in the API for storefronts you build yourself.
</Callout>

## Declined payments [#declined-payments]

When a payment does not go through, `POST /v1/checkout/complete` and `POST /v1/checkout/paypal/capture` answer `402` with `error.code` `PAYMENT_FAILED`. When the provider declined it, `details.decline_code` says why:

```json title="402 Payment Required"
{
  "error": {
    "code": "PAYMENT_FAILED",
    "message": "PayPal declined the payment method. Choose another way to pay and try again.",
    "details": { "decline_code": "try_another_method" }
  }
}
```

| `decline_code` | Meaning | What to show |
|---|---|---|
| `card_declined` | The card issuer or PayPal refused it without a reason the shopper can act on. Lost, stolen and fraud-filter declines are reported this way on purpose. | "Your card was declined. Try another card or way to pay." |
| `card_expired` | The card has expired. | Ask for another card. |
| `insufficient_funds` | Not enough funds or credit. | Ask for another card or way to pay. |
| `incorrect_card_details` | A wrong card number, security code (CVC) or postcode. | Ask the shopper to check and re-enter their details. |
| `authentication_required` | 3-D Secure (Stripe) or PayPal's own check was not completed. | Ask the shopper to complete their bank's check, then try again. |
| `try_another_method` | PayPal refused this funding source. | Reopen the PayPal buttons so the shopper can pick another, then capture the same PayPal order again. |
| `unknown` | The provider declined for a reason Elara One does not map, or gave none. | Show `error.message`. |

- **Treat any value you do not recognise as `unknown`.** The list may grow.
- `error.message` is Elara One's own wording, safe to show. The provider's raw text is never returned.
- `details` is only present on a provider decline. Other refusals, such as a released authorisation or a payment made for another store, have only `code` and `message`: show the message.
- After a `402`, the cart and stock are unchanged and the shopper can try again. Branch on `error.code` first: a `402` with `TENANT_PAYMENT_REQUIRED` means the store itself is unavailable, not that the payment failed.

```ts title="Handle a checkout response"
const body = await res.json()

if (res.status === 402 && body.error.code === 'PAYMENT_FAILED') {
  const code = body.error.details?.decline_code ?? 'unknown'
  if (code === 'try_another_method') reopenPayPalButtons()
  showPaymentError(body.error.message)
}
```

The other checkout outcomes, such as `SHIPPING_OPTION_CHANGED` and `PAYMENT_REFUNDED`, are covered in [checkout](/docs/concepts/checkout#what-can-go-wrong).

<Cards>
  <Card title="Checkout" href="/docs/concepts/checkout" description="The full checkout flow and every outcome." />
  <Card title="Idempotency keys" href="/docs/concepts/idempotency" description="Retry checkout without charging twice." />
</Cards>
