IntegrationsTake 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.

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.

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 guaranteesYou still write
HMAC-SHA256 webhook verification over the raw body, with a 5-minute toleranceAmounts from a server-side product table
Reading the Stripe-Signature headerThe order table and its state
livemode on events and sessions matching the integrationThe event de-duplication table
The secret key stays server-side and never enters the sandboxIdempotent fulfilment
paid computed from status and paymentStatus togetherChecking amount and currency against your order
A real credential check when you saveChecking the order belongs to this user

Connect Stripe

First collect the values in the Stripe dashboard:

  1. Open Developers → API keys and copy the Secret keysk_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.

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.

FieldWhat goes inRequired
Secret keyThe key starting with sk_test_ or sk_live_Yes
Live modeChecked means this config takes real money. It must agree with the key prefix, or saving failsYes
Success URLWhere the buyer lands after paying. May contain {CHECKOUT_SESSION_ID}, which Stripe replaces with the real session idRecommended
Cancel URLWhere the buyer lands when they back out of checkoutRecommended
Account idStarts with acct_; adds an account check when filledNo
Default currencyA 3-letter code such as usd, used when checkoutSession omits currencyNo
Webhook signing secretStarts with whsec_; come back for it when you wire up webhooksOnly for webhooks

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).

ErrorWhat to fix
Invalid API Key providedWrong key, or it was rolled in the Stripe dashboard
marked as live mode but the secret key is a test keyLive mode is on but the key is sk_test_
marked as test mode but the secret key is a live keyThe 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:

{
  "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:

{
  "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:

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:

import { invoke } from 'talizen/func'

const { payUrl } = await invoke<{ payUrl: string }>('stripe.create', {
  productId: 'pro',
})
window.location.assign(payUrl)
ParameterNotes
clientReferenceIdRequired. 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
amountRequired. 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
currencyThree-letter code; falls back to the integration's default
nameRequired. The product name shown on the checkout page
description / quantityOptional description and quantity (default 1)
successUrl / cancelUrlOptional, override the integration's return addresses
customerEmailOptional, pre-fills the email field on the checkout page
metadataOptional, echoed back on the session and on webhook events
expiresAtOptional, unix seconds or a Date. Stripe only accepts 30 minutes to 24 hours from now; omit it for Stripe's 24h default
idempotencyKeyOptional, 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:

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 }
}
// 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 })
}
FieldNotes
paidstatus === 'complete' and paymentStatus === 'paid', computed by the platform. Do not check only one halfpaymentStatus alone would accept a session that never completed
clientReferenceIdThe order id you passed when creating the session
amountTotal / currencyWhat was actually paid; compare both against your own order
paymentIntentIdStore it: refund events are matched by payment intent
customerEmailWhat the buyer typed at checkout, else what you pre-filled
status / paymentStatusRaw states: open / complete / expired, and paid / unpaid / no_payment_required
metadata / sessionMetadata 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, 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.
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 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.

FieldNotes
idEvent id — use it as the key of your de-duplication table; Stripe states plainly that events can repeat and arrive out of order
typee.g. checkout.session.completed, charge.refunded
objectevent.data.object — what you read in almost every handler
eventThe full event, for fields like data.previous_attributes
livemode / created / apiVersionMode, 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:

// 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():

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

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 priceIds 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.

Render diagnostics