Quickstart
You’ll build the three pieces every Stack integration has:
- An endpoint on your server that creates a payment.
- A Pay with Stack button in your app or website that opens the Stack sheet.
- A webhook that fulfils the order.
Everything here runs in test mode.
-
Create a project
Sign up in the Stack dashboard and create a project with the bank account you want paid into. Then:
- API keys: copy the publishable key (
pk_test_…) and create a secret key (sk_test_…). The secret key is shown once. - Webhooks: set your endpoint URL, for example
https://your-server.example/stack/webhook, and copy the signing secret (whsec_…).
- API keys: copy the publishable key (
-
Install the SDKs
Terminal window npm install @usestack/node # on your servernpm install @usestack/react # in your websiteTerminal window npm install @usestack/node # on your servernpx expo install @usestack/react-native expo-secure-store expo-crypto expo-web-browser expo-clipboard react-native-svg -
Create the payment on your server
Your server decides the price and creates a payment intent with your secret key. It sends the intent’s
client_secretto your app. Amounts are in kobo: ₦4,500 is450000.server.ts import express from 'express';import Stack from '@usestack/node';// Your secret key stays on your server. Never ship it in an app or a website.const stack = new Stack(process.env.STACK_SECRET_KEY!);const app = express();// Your prices, in kobo. The app sends what the customer wants, never an amount.const PRICES: Record<string, number> = { jollof: 450_000, zobo: 120_000 };app.post('/checkout', express.json(), async (req, res) => {const items: { id: string; qty: number }[] = req.body.items;const amount = items.reduce((sum, item) => sum + PRICES[item.id]! * item.qty, 0);const orderId = await createOrder(items, amount); // your own databaseconst intent = await stack.paymentIntents.create({amount, // ₦5,700 is 570000currency: 'NGN',description: 'Stack Food order',metadata: { orderId },},// Retrying this request never creates a second payment for the same order.{ idempotencyKey: `order_${orderId}` },);res.json({ orderId, clientSecret: intent.client_secret });});app.listen(4242);declare function createOrder(items: { id: string; qty: number }[], amount: number): Promise<string>; -
Open the Stack sheet in your app
Wrap your app in
StackProviderwith your publishable key. Then callpresentPaymentSheetwith the client secret. Stack signs the customer in with an email code, shows them how they can pay, and takes the payment.main.tsx import { StackProvider } from '@usestack/react';import { createRoot } from 'react-dom/client';import { Checkout } from './Checkout';createRoot(document.getElementById('root')!).render(// The publishable key is safe in the browser.<StackProvider publishableKey="pk_test_..."><Checkout /></StackProvider>,);Checkout.tsx import { PayWithStackButton, usePaymentSheet } from '@usestack/react';import { useState } from 'react';export function Checkout() {const { presentPaymentSheet, loading } = usePaymentSheet();const [message, setMessage] = useState<string | null>(null);const pay = async () => {// 1. Your server creates the payment and returns its client secret.const res = await fetch('/checkout', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ items: [{ id: 'jollof', qty: 1 }] }),});const { orderId, clientSecret } = await res.json();// 2. The Stack sheet signs the customer in and takes the payment. It never throws.const result = await presentPaymentSheet({ clientSecret });// 3. Show the result. Fulfil the order from the webhook, not from here.if (result.status === 'succeeded') setMessage(`Thanks! Order ${orderId} is confirmed.`);else if (result.status === 'failed') setMessage(result.error.message);// 'canceled': the customer closed the sheet.};return (<><PayWithStackButton onClick={() => void pay()} loading={loading} />{message && <p>{message}</p>}</>);}App.tsx import { StackProvider } from '@usestack/react-native';import { Checkout } from './Checkout';export default function App() {return (// The publishable key is safe in the app. In development, apiUrl is your// computer's LAN address (a phone can't reach "localhost").<StackProvider publishableKey="pk_test_..." biometricPrompt="Confirm your payment"><Checkout /></StackProvider>);}Checkout.tsx import { PayWithStackButton, usePaymentSheet } from '@usestack/react-native';import { Alert, View } from 'react-native';const SERVER = 'https://your-server.example';export function Checkout() {const { presentPaymentSheet, loading } = usePaymentSheet();const pay = async () => {// 1. Your server creates the payment and returns its client secret.const res = await fetch(`${SERVER}/checkout`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ items: [{ id: 'jollof', qty: 1 }] }),});const { orderId, clientSecret } = await res.json();// 2. The Stack sheet signs the customer in and takes the payment. It never throws.const result = await presentPaymentSheet({ clientSecret });// 3. Show the result. Fulfil the order from the webhook, not from here.if (result.status === 'succeeded') Alert.alert('Order placed', `Order ${orderId} is confirmed.`);else if (result.status === 'failed') Alert.alert('Payment failed', result.error.message);// 'canceled': the customer closed the sheet.};return (<View style={{ padding: 16 }}><PayWithStackButton onPress={() => void pay()} loading={loading} /></View>);} -
Fulfil the order from the webhook
The sheet’s result tells your customer what happened. Your server should only fulfil the order when Stack’s signed
payment_intent.succeededevent arrives, because anything in the browser or app can be faked or lost.webhook.ts import express from 'express';import Stack, { StackSignatureError, type PaymentFailedData, type PaymentSucceededData } from '@usestack/node';const stack = new Stack(process.env.STACK_SECRET_KEY!);const app = express();// Signatures are over the exact bytes Stack sent, so take the raw body here.app.post('/stack/webhook', express.raw({ type: 'application/json' }), async (req, res) => {let event;try {event = stack.webhooks.verify(req.body, req.headers['stack-signature'], process.env.STACK_WEBHOOK_SECRET!);} catch (err) {if (err instanceof StackSignatureError) return res.status(400).send('Invalid signature');throw err;}// "Send test event" in the dashboard carries { test: true } instead of a payment.if ((event.data as { test?: boolean }).test) return res.sendStatus(200);if (event.type === 'payment_intent.succeeded') {const payment = event.data as unknown as PaymentSucceededData;const orderId = payment.metadata?.orderId as string;// Stack may deliver the same event more than once: fulfilling twice must be a no-op.await markOrderPaid(orderId, { paymentId: payment.id, amount: Number(payment.amount) });} else if (event.type === 'payment_intent.failed') {const failure = event.data as unknown as PaymentFailedData;// The payment is still open: the customer can try again in the sheet.await noteFailedAttempt(failure.id, failure.last_error);}// Any 2xx tells Stack you have it. Anything else, or no answer in 10 seconds, is retried.res.sendStatus(200);});app.listen(4242);declare function markOrderPaid(orderId: string, payment: { paymentId: string; amount: number }): Promise<void>;declare function noteFailedAttempt(paymentId: string, reason: string): Promise<void>; -
Try it
Tap Pay with Stack, enter an email you can open, and type the code Stack sends. You have no Stack balance yet, so choose Card › Add a new card and pay with the test card
4084 0840 8408 4081, CVV408, any future expiry. Your webhook receivespayment_intent.succeeded, and the payment appears in the dashboard under Transactions.
- Fulfil orders with webhooks: signatures, retries and duplicates.
- Tell Stack who is paying: skip straight to the code.
- How customers pay: what your customers see in the sheet.