Skip to content

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.

  1. 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_…).
  2. Install the SDKs

    Terminal window
    npm install @usestack/node # on your server
    npm install @usestack/react # in your website
  3. 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_secret to your app. Amounts are in kobo: ₦4,500 is 450000.

    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 database
    const intent = await stack.paymentIntents.create(
    {
    amount, // ₦5,700 is 570000
    currency: '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>;
  4. Open the Stack sheet in your app

    Wrap your app in StackProvider with your publishable key. Then call presentPaymentSheet with 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>}
    </>
    );
    }
  5. 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.succeeded event 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>;
  6. 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, CVV 408, any future expiry. Your webhook receives payment_intent.succeeded, and the payment appears in the dashboard under Transactions.