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

# Stripe payments & subscriptions

> Accept one-time payments and subscriptions with Stripe's embedded PaymentElement — a backend workflow mints a client secret, the frontend confirms.

Stripe's **PaymentElement** renders inline inside iframes — including the editor preview — so the whole flow stays in your app: a backend [workflow](/framework/workflows) mints a `PaymentIntent` (or subscription) `client_secret`, and the frontend confirms it with `stripe.confirmPayment()`. Reach for redirect Checkout only when you need a hosted page.

<Note>
  Connect the **Stripe** [integration](/concepts/integrations-secrets) first. The token then arrives as
  `process.env.ZITE_STRIPE_ACCESS_TOKEN` in workflows, and the publishable key as
  `import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY` on the frontend.
</Note>

## Backend — create a PaymentIntent

```typescript theme={null}
// apps/<app>/src/api/createPaymentIntent.ts
import { z } from 'zod';
import { createEndpoint } from 'zitejs/backend';
import Stripe from 'stripe';

export default createEndpoint({
  description: 'Create a PaymentIntent for embedded checkout',
  inputSchema: z.object({
    email: z.string().email(),
    amount: z.number(), // in cents — $20.00 = 2000
    currency: z.string().default('usd'),
  }),
  outputSchema: z.object({ clientSecret: z.string() }),
  execute: async ({ input }) => {
    const stripe = new Stripe(process.env.ZITE_STRIPE_ACCESS_TOKEN!, {
      httpClient: Stripe.createFetchHttpClient(),
    });

    // Stripe rejects payment flows in Connect mode without a Customer — find or create one.
    const existing = await stripe.customers.list({ email: input.email, limit: 1 });
    const customer = existing.data[0] ?? (await stripe.customers.create({ email: input.email }));

    const pi = await stripe.paymentIntents.create({
      customer: customer.id,
      amount: input.amount,
      currency: input.currency,
    });

    return { clientSecret: pi.client_secret! };
  },
});
```

## Frontend — confirm the payment

```tsx theme={null}
import { useEffect, useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { createPaymentIntent } from 'zitejs/api';
import { Button } from '@project/components/ui/button';

// Guard: VITE_STRIPE_PUBLISHABLE_KEY is briefly missing while Vite restarts right after
// Stripe is first connected.
const pk = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
const stripePromise = pk ? loadStripe(pk) : null;

function CheckoutForm({ onSuccess }: { onSuccess?: () => void }) {
  const stripe = useStripe();
  const elements = useElements();
  const [error, setError] = useState<string>();
  const [processing, setProcessing] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;
    setProcessing(true);
    const { error } = await stripe.confirmPayment({ elements, redirect: 'if_required' });
    if (error) { setError(error.message); setProcessing(false); }
    else onSuccess?.();
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      <Button type="submit" disabled={!stripe || processing} className="mt-4">
        {processing ? 'Processing…' : 'Pay'}
      </Button>
      {error && <p className="text-destructive mt-2">{error}</p>}
    </form>
  );
}

export default function PaymentPage() {
  const [clientSecret, setClientSecret] = useState<string>();
  useEffect(() => {
    createPaymentIntent({ email: 'buyer@example.com', amount: 2000 })
      .then(r => setClientSecret(r.clientSecret));
  }, []);

  if (!stripePromise) return <div>Connecting to Stripe…</div>;
  if (!clientSecret) return <div>Loading…</div>;

  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <CheckoutForm onSuccess={() => { /* toast, navigate, refresh */ }} />
    </Elements>
  );
}
```

## Subscriptions

Frontend is identical; only the backend changes. Use `payment_behavior: 'default_incomplete'` so it waits for the frontend to confirm, and expand `latest_invoice.payment_intent` to pull the `client_secret` in one call:

```typescript theme={null}
const sub = await stripe.subscriptions.create({
  customer: customer.id,
  items: [{ price: input.priceId }],
  payment_behavior: 'default_incomplete',
  expand: ['latest_invoice.payment_intent'],
});
const invoice = sub.latest_invoice as Stripe.Invoice;
const pi = invoice.payment_intent as Stripe.PaymentIntent;
return { subscriptionId: sub.id, clientSecret: pi.client_secret! };
```

## Gotchas

* **Amounts are in cents.** `$20.00` is `2000`.
* **Always attach a Customer** before `paymentIntents.create` / `subscriptions.create` — Stripe rejects Connect-mode payment flows without one. `customers.list({ email })` is exact; use `customers.search({ query: 'email~"john"' })` for fuzzy lookup.
* **PaymentElement over redirect Checkout** — redirect Checkout is blocked by `X-Frame-Options` in the iframed editor preview. If you must use it, branch on `window.top !== window.self` (new tab when iframed).
* **Subscription shape moved in SDK v18:** billing period lives on the item (`subscription.items.data[0].current_period_end`), discounts are an array (`subscription.discounts[0]`). Read the installed `stripe` `.d.ts` when a call fails typecheck.
* **`expand` maxes at 4 levels**, 20 paths per request; list endpoints need the `data.` prefix (`expand: ['data.customer']`).
* **For push updates** (renewal, charge succeeded), add an [inbound webhook](/recipes/inbound-webhooks) and point Stripe's webhook config at its URL. Otherwise read on demand with `stripe.subscriptions.retrieve(id)`.
