# Take Stripe payments with an integration | Creght

> Take Stripe payments on Creght: ctx.payment.stripe.checkoutSession opens a hosted checkout, retrieveSession reconciles the return, verifyWebhook verifies webhook signatures, call reaches refunds and the rest of the API, and channel tags separate multiple accounts.

Overview

- [Creght API for AI](/api.md)

Discoverability

- [How to optimize llms.txt](/api/optimize-llms-txt.md)

Site configuration

- [Configure talizen.config.ts](/api/talizen-config.md)
- [Implement domain-based locale routing](/api/domain-locale-routing.md)

Backend

- [Calling external APIs on the server and managing the cache](/api/ssr-external-api-cache.md)
- [Build site backend workflows with Func](/api/func-backend.md)
- [JSON tables: definition, reads, and queries](/api/func-json-tables.md)
- [Uploads: signed direct upload and Func-generated files](/api/func-assets-upload.md)
- [Timeouts and streaming responses](/api/func-timeout-streaming.md)
- [Integrate Alipay PC Web Payment with Func](/api/func-alipay-payment.md)

Integrations

- [Send Email and Verification Codes with Integrations](/api/func-email-integration.md)
- [Take Alipay payments with an integration](/api/func-alipay-integration.md)
- [Take Stripe payments with an integration](/api/func-stripe-integration.md)

Auth

- [Require a Verified Email to Sign Up](/api/auth-verified-registration.md)
- [Reset and Change Passwords](/api/auth-password-reset.md)
- [Sign In From a Func](/api/auth-func-login.md)
- [Query users from a Func](/api/func-user-directory.md)

On this page

- [payment is a namespace, not a unified interface](#namespace)
- [Connect Stripe](#connect)
- [What the save-time check does](#verify)
- [Order and event tables](#order-table)
- [Start a payment](#create)
- [When the buyer comes back](#confirm)
- [Receiving webhooks](#webhook)
- [Refunds and the rest of the API](#call)
- [Two Stripe accounts in one project](#channels)
- [What the platform does not do](#yours)
- [Boundaries](#errors)

Integrations/Take Stripe payments with an integration

# Take Stripe payments with an integration

Connect the Stripe integration, then use ctx.payment.stripe in Func to create Checkout Sessions, reconcile on return and verify webhooks: the secret key and webhook signing secret stay on the server, while orders, amount checks and idempotent fulfilment stay in your own code.

Copy Markdown link

Stripe is a managed integration: connect it once in the editor, then use `ctx.payment.stripe` in Func to create Checkout Sessions, reconcile on return and verify webhooks. The secret key and the webhook signing secret stay on the server, so no secret ever appears in your code.

**Scope**

Stripe Checkout for **one-off payments** (inline price\_data). Subscriptions, dashboard-defined `priceId` s and Stripe Connect have no dedicated methods yet; reach them through `call()`, where the key still never enters the sandbox — see [Refunds and the rest of the API](#call).

## payment is a namespace, not a unified interface

Method names follow Stripe's own product shape and **share nothing with `ctx.payment.alipay.*`**. That is the intended result, not a gap: Alipay signs a form, redirects, and waits for an async notification; Stripe creates a Session, redirects, and reconciles on return plus webhook. A forced `createCheckout()` would only be a harder-to-read forwarder.

The split: the platform owns **cryptography and credentials**, your code owns **money and goods**.

| The platform guarantees | You still write |
| --- | --- |
| HMAC-SHA256 webhook verification over the raw body, with a 5-minute tolerance | Amounts from a server-side product table |
| Reading the `Stripe-Signature` header | The order table and its state |
| `livemode` on events and sessions matching the integration | The event de-duplication table |
| The secret key stays server-side and never enters the sandbox | Idempotent fulfilment |
| `paid` computed from `status` and `paymentStatus` together | Checking amount and currency against your order |
| A real credential check when you save | Checking the order belongs to this user |

## Connect Stripe

First collect the values in the Stripe dashboard:

1. Open [Developers → API keys](https://dashboard.stripe.com/apikeys) and copy the **Secret key** — `sk_test_` for testing, `sk_live_` for production. Do **not** copy the publishable key.
2. (Optional) Note your account id from Settings → Business, shaped like `acct_xxx`. Filling it adds one more check that the key really belongs to that account.
3. Leave the webhook signing secret for later — it can only be created once your Func has a public address, see [Receiving webhooks](#webhook).

Then open **Backend → Integrations** in the editor, pick **Stripe**, and fill in the fields below. Saving calls Stripe for real, so a wrong value fails right there instead of when somebody tries to pay.

| Field | What goes in | Required |
| --- | --- | --- |
| Secret key | The key starting with `sk_test_` or `sk_live_` | Yes |
| Live mode | Checked means this config takes real money. It must agree with the key prefix, or saving fails | Yes |
| Success URL | Where the buyer lands after paying. May contain `{CHECKOUT_SESSION_ID}`, which Stripe replaces with the real session id | Recommended |
| Cancel URL | Where the buyer lands when they back out of checkout | Recommended |
| Account id | Starts with `acct_`; adds an account check when filled | No |
| Default currency | A 3-letter code such as `usd`, used when `checkoutSession` omits `currency` | No |
| Webhook signing secret | Starts with `whsec_`; come back for it when you wire up webhooks | Only for webhooks |

**Why the live-mode checkbox is not redundant**

The key prefix already decides the mode. Declaring it again gives two later checks something trustworthy to compare against: `livemode` on sessions and on webhook events. Stripe's test and live modes **each have their own webhook endpoints, and both can point at the same URL** — if they do, one click on 'send test event' runs your production fulfilment. Without the declaration there is nothing to compare.

## What the save-time check does

Saving calls `GET /v1/account` once. That single call verifies three things: the key is valid and has not been rolled; the key's mode agrees with the live-mode checkbox; and the account is the one you named (when you filled it in).

| Error | What to fix |
| --- | --- |
| `Invalid API Key provided` | Wrong key, or it was rolled in the Stripe dashboard |
| marked as live mode but the secret key is a test key | Live mode is on but the key is `sk_test_` |
| marked as test mode but the secret key is a live key | The key is `sk_live_` but live mode is off |
| belongs to account `acct_…`, not `acct_…` | The key comes from a different Stripe account |
| expected it to start with `sk_test_` or `sk_live_` | You pasted a publishable key ( `pk_`) or a webhook secret ( `whsec_`) |

## Order and event tables

Payment state lives in your own tables. Create `/platform/table/payment_orders.json`:

```json
{
  "name": "Payment orders",
  "desc": "Stripe order state",
  "json_schema": {
    "type": "object",
    "properties": {
      "userId": { "type": "string" },
      "productId": { "type": "string" },
      "amount": { "type": "integer" },
      "currency": { "type": "string" },
      "orderNo": { "type": "string" },
      "status": { "type": "string", "enum": ["pending", "paid", "closed", "refunded"] },
      "stripeSessionId": { "type": "string" },
      "stripePaymentIntentId": { "type": "string" },
      "paidAt": { "type": "string" }
    },
    "required": ["userId", "productId", "amount", "currency", "orderNo", "status"]
  }
}
```

And `/platform/table/payment_events.json` for webhook de-duplication:

```json
{
  "name": "Payment events",
  "desc": "Processed Stripe webhook events",
  "json_schema": {
    "type": "object",
    "properties": {
      "eventId": { "type": "string" },
      "orderNo": { "type": "string" },
      "type": { "type": "string" }
    },
    "required": ["eventId", "type"]
  }
}
```

## Start a payment

Create `/backend/func/stripe.ts`:

```typescript
import type { TalizenFuncContext } from 'talizen/func-runtime'

// Amounts come from a server-side product table; the browser only sends productId.
// The unit is the currency's smallest unit: 500 means $5.00.
const PRODUCTS = {
  pro: { name: 'Pro plan', amount: 500, currency: 'usd' },
} as const

export function create(input: { productId?: string }, ctx: TalizenFuncContext) {
  const user = ctx.auth.requireUser()
  const productId = String(input.productId || '') as keyof typeof PRODUCTS
  const product = PRODUCTS[productId]
  if (!product) throw new Error('invalid product')

  // Generate the order id yourself and store it first: it is your table's key,
  // the session's client_reference_id and the idempotency key.
  const orderNo = 'C' + crypto.randomUUID().replace(/-/g, '')
  ctx.db.insert('payment_orders', {
    userId: String(user.id),
    productId,
    amount: product.amount,
    currency: product.currency,
    orderNo,
    status: 'pending',
  })

  const session = ctx.payment.stripe.checkoutSession({
    clientReferenceId: orderNo,
    amount: product.amount,
    currency: product.currency,
    name: product.name,
    customerEmail: user.email,
  })
  ctx.db.update('payment_orders', ctx.db.query('payment_orders', {
    where: { orderNo }, limit: 1,
  }).list[0].id, { stripeSessionId: session.id })

  return { orderNo, payUrl: session.url }
}
```

The browser redirects straight to `payUrl`:

```typescript
import { invoke } from 'talizen/func'

const { payUrl } = await invoke<{ payUrl: string }>('stripe.create', {
  productId: 'pro',
})
window.location.assign(payUrl)
```

| Parameter | Notes |
| --- | --- |
| `clientReferenceId` | Required. Your order id, up to 200 chars of letters, digits, `-` and `_`. **The platform does not generate it**: it is your table's key, Stripe's `client_reference_id`, `metadata.client_reference_id` and the default idempotency key |
| `amount` | Required. An **integer in the currency's smallest unit**: `500` means $5.00. A decimal string like `'9.90'` throws rather than being charged as 9 cents |
| `currency` | Three-letter code; falls back to the integration's default |
| `name` | Required. The product name shown on the checkout page |
| `description` / `quantity` | Optional description and quantity (default 1) |
| `successUrl` / `cancelUrl` | Optional, override the integration's return addresses |
| `customerEmail` | Optional, pre-fills the email field on the checkout page |
| `metadata` | Optional, echoed back on the session and on webhook events |
| `expiresAt` | Optional, unix seconds or a Date. Stripe only accepts 30 minutes to 24 hours from now; omit it for Stripe's 24h default |
| `idempotencyKey` | Optional, derived from the order id by default, so retrying the same order returns the same session |

It returns `{ id, url, clientReferenceId, amountTotal, currency, expiresAt, livemode }`.

## When the buyer comes back

After paying, the buyer lands on your `successUrl`. Send the `session_id` from the query string back into Func to reconcile:

```typescript
export function confirm(input: { sessionId?: string }, ctx: TalizenFuncContext) {
  const user = ctx.auth.requireUser()
  const s = ctx.payment.stripe.retrieveSession(String(input.sessionId || ''))
  if (!s.paid) return { paid: false }

  const { list } = ctx.db.query('payment_orders', {
    where: { orderNo: s.clientReferenceId },
    limit: 1,
  })
  const order = list[0]
  // The platform cannot do these: does the order exist, is it this user's,
  // do the amount and currency match.
  if (!order || order.userId !== String(user.id)) throw new Error('order not found')
  if (order.amount !== s.amountTotal || order.currency !== s.currency) {
    throw new Error('amount mismatch')
  }
  if (order.status === 'paid') return { paid: true } // already handled

  ctx.db.update('payment_orders', order.id, {
    status: 'paid',
    stripePaymentIntentId: s.paymentIntentId,
    paidAt: new Date().toISOString(),
  })
  // Fulfilment must be idempotent on orderNo too.
  return { paid: true }
}
```

```typescript
// The return URL looks like https://example.com/pay/done?session_id=cs_live_xxx
const sessionId = new URLSearchParams(location.search).get('session_id')
if (sessionId) {
  const { paid } = await invoke<{ paid: boolean }>('stripe.confirm', { sessionId })
}
```

**Can you trust the return parameters?**

`session_id` sits in the address bar and anyone can change it, so **it is not trustworthy by itself**. But a session fetched with `retrieveSession` can only belong to your own account — if it does not exist you get an error, and if it does you are looking at Stripe's own record. Fetching it back _is_ the authorisation step.

This is the extra path Stripe has over Alipay: you can fulfil without waiting for a webhook. What the platform **cannot** prove is that the order belongs to the logged-in user, so match `clientReferenceId` against their order yourself.

| Field | Notes |
| --- | --- |
| `paid` | `status === 'complete'` and `paymentStatus === 'paid'`, computed by the platform. **Do not check only one half** — `paymentStatus` alone would accept a session that never completed |
| `clientReferenceId` | The order id you passed when creating the session |
| `amountTotal` / `currency` | What was actually paid; compare both against your own order |
| `paymentIntentId` | Store it: refund events are matched by payment intent |
| `customerEmail` | What the buyer typed at checkout, else what you pre-filled |
| `status` / `paymentStatus` | Raw states: `open` / `complete` / `expired`, and `paid` / `unpaid` / `no_payment_required` |
| `metadata` / `session` | Metadata and the full Stripe object for anything not listed above |

## Receiving webhooks

Reconciling on return only covers the buyer who actually came back. Closing the tab, asynchronous payment methods and later refunds all reach you through webhooks only.

1. Publish the Func above so it has a public address, e.g. `https://example.com/func/stripe.webhook`.
2. In [Developers → Webhooks](https://dashboard.stripe.com/webhooks), add an endpoint pointing at it and subscribe to the events you need, such as `checkout.session.completed`. **Test and live mode each need their own endpoint.**
3. Copy that endpoint's `whsec_...` signing secret into the Stripe integration under **Backend → Integrations**.

```typescript
export async function webhook(_input: unknown, ctx: TalizenFuncContext) {
  // Verification failure throws, so a forged event never reaches the lines below.
  // Pass the raw body: input or a JSON.stringify'd copy breaks the signature.
  const event = ctx.payment.stripe.verifyWebhook(await ctx.request.text())

  // Events are redelivered and can arrive out of order; the de-dup table is yours.
  const seen = ctx.db.query('payment_events', { where: { eventId: event.id }, limit: 1 })
  if (seen.list.length > 0) return new Response('ok')

  if (event.type === 'checkout.session.completed') {
    const session = event.object as any
    if (session.payment_status === 'paid') {
      const { list } = ctx.db.query('payment_orders', {
        where: { orderNo: session.client_reference_id },
        limit: 1,
      })
      const order = list[0]
      if (!order) return new Response('order not found', { status: 400 })
      if (order.amount !== session.amount_total) {
        return new Response('amount mismatch', { status: 400 })
      }
      if (order.status !== 'paid') {
        ctx.db.update('payment_orders', order.id, {
          status: 'paid',
          stripePaymentIntentId: String(session.payment_intent || ''),
          paidAt: new Date().toISOString(),
        })
      }
      ctx.db.insert('payment_events', {
        eventId: event.id,
        orderNo: order.orderNo,
        type: event.type,
      })
    }
  }

  return new Response('ok')
}
```

**The platform blocks the two easiest mistakes**

**One: it has to be the raw body.** `verifyWebhook` takes `await ctx.request.text()`. Stripe signs the raw bytes, so parsing the JSON and serialising it back (key order, whitespace and escaping can all change) can never verify — which is exactly what using `input` or `JSON.stringify()` does.

**Two: you do not pass the signature header.** The platform reads `Stripe-Signature` from the request context. Leaving every site to find that header itself means an empty string when it fails, which shows up as 'verification always fails' and is invisible locally.

The platform handles: HMAC-SHA256 verification, a 5-minute timestamp tolerance (replay protection), reading the signature header, and checking the event's `livemode` against the integration. **Any failure throws** rather than returning something you could mistake for falsy. Return a non-2xx and Stripe will retry.

| Field | Notes |
| --- | --- |
| `id` | Event id — **use it as the key of your de-duplication table**; Stripe states plainly that events can repeat and arrive out of order |
| `type` | e.g. `checkout.session.completed`, `charge.refunded` |
| `object` | `event.data.object` — what you read in almost every handler |
| `event` | The full event, for fields like `data.previous_attributes` |
| `livemode` / `created` / `apiVersion` | Mode, timestamp and the event's API version |

## Refunds and the rest of the API

Refunds, subscriptions, charge lookups and everything else go through `call`, with the platform holding the key:

```typescript
// Full refund
const refund = ctx.payment.stripe.call('POST', '/v1/refunds', {
  payment_intent: order.stripePaymentIntentId,
})

// Nested params: { a: { b: [1] } } expands to a[b][0]=1
const session = ctx.payment.stripe.call('POST', '/v1/checkout/sessions', {
  mode: 'subscription',
  line_items: [{ price: 'price_xxx', quantity: 1 }],
  success_url: 'https://example.com/pay/done',
  cancel_url: 'https://example.com/pay/cancel',
})
```

The signature is `call(method, path, params?, idempotencyKey?)`. `method` accepts `GET` / `POST` / `DELETE` and `path` must start with `/v1/`. Params expand Stripe-style, so nested objects and arrays just work. HTTP errors are mapped by Stripe's error type to 400 (your config or parameters) or 502 (a problem on Stripe's side).

## Two Stripe accounts in one project

Give the two integrations different tags and pick one with `via()`:

```typescript
ctx.payment.stripe.checkoutSession({ ... })              // default channel, same as via('default')
ctx.payment.stripe.via('eu').checkoutSession({ ... })    // another receiving account
ctx.payment.stripe.via('eu').verifyWebhook(raw)          // the webhook must use the same channel
```

**One deliberate difference from ctx.email**

A tag matching several payment integrations **throws** instead of picking one at random the way email does. The reason is even harder here: webhook signing secrets are issued **per endpoint**, so an event from account A can never verify against account B's secret — the symptom would be 'the customer paid and the site never hears about it'.

So two receiving accounts means two tags, and creating the session and handling the webhook must use the **same** tag.

## What the platform does not do

These are where payments actually go wrong, and the platform cannot help:

- **Amounts come from a server-side product table**; the browser only sends a `productId`. Letting the browser send the amount lets people set their own price.
- **Check who the order belongs to.** `retrieveSession` proves the session belongs to your Stripe account, not that it belongs to the logged-in user.
- **Compare amount and currency** against your own order — both of them.
- **De-duplicate webhook events** on `event.id`; they are redelivered and can arrive out of order.
- **Make fulfilment idempotent** on the order id: the return path and the webhook can both mark the same payment as paid.

## Boundaries

- Only one-off Checkout payments with inline `price_data`. Subscriptions, dashboard-defined `priceId` s and Stripe Connect have no dedicated methods — use `call()`.
- Amounts are **integers in the smallest currency unit**; the platform does no unit conversion. `ctx.payment.alipay` takes yuan strings instead, and that inconsistency is deliberate: each provider matches its own upstream, which is safer than forcing one shape.
- Payment integrations **cannot** enable "expose the key to Func code": the key never enters the sandbox. To do everything yourself, take the other road — set `STRIPE_*` under **Backend → Environment variables** and write your own Func with `fetch` \+ `crypto.subtle`. The two paths do not interfere.
- Return URLs and the webhook URL must be **https**, and the webhook must be a **published** address: a Func on a preview domain will not receive live events.
- Test and live mode use separate keys and separate webhook endpoints. **Receive one real event in live mode before you launch** — there is no substitute for that step.

**Related**

Func fundamentals (files, invocation, tables, auth, timeouts, SSE) are in [the complete Func backend guide](/api/func-backend.md); for CNY see [Take Alipay payments with an integration](/api/func-alipay-integration.md); for email and verification codes see [Send email and verification codes with an integration](/api/func-email-integration.md).

> Full page index: [/llms.txt](/llms.txt)
