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 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:
- Open Developers → API keys and copy the Secret key —
sk_test_for testing,sk_live_for production. Do not copy the publishable key. - (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. - 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.
| 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 |
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:
{
"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)
| 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:
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 })
}
| 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.
- Publish the Func above so it has a public address, e.g.
https://example.com/func/stripe.webhook. - 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. - 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.
| 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:
// 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.
retrieveSessionproves 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-definedpriceIds and Stripe Connect have no dedicated methods — usecall(). - Amounts are integers in the smallest currency unit; the platform does no unit conversion.
ctx.payment.alipaytakes 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 withfetch+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.
