Skip to content

Create payments on your server

Every Stack payment starts on your server. Your server knows the price and holds your secret key. It creates a payment intent and gives your app the intent’s client_secret, which is all the Stack sheet needs.

Terminal window
npm install @usestack/node

Node 18 or later. @usestack/node has no dependencies.

import Stack from '@usestack/node';
const stack = new Stack(process.env.STACK_SECRET_KEY!);
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>;
Parameter Required
amount Yes In kobo, as an integer. ₦24,000 is 2400000. At least 1.
currency Yes 'NGN' (the only currency for now).
description No Up to 500 characters. Shown to the customer in the sheet.
metadata No Any JSON object, returned on the intent and in webhooks. Put your order id here.
customer No { email }: who is paying, if you know. See Tell Stack who is paying.

It returns the payment intent with its client_secret. Send the client_secret to your app, and don’t log or store it. Everything else on the intent is safe to keep. See Payment intents.

A payment intent can be paid for 30 minutes (expires_at). After that, create a new one.

Networks fail, and your server may retry a request that actually went through. Pass an idempotencyKey that is unique to the thing being paid for, such as the order:

await stack.paymentIntents.create({ amount, currency: 'NGN' }, { idempotencyKey: `order_${orderId}` });

Sending the same key again returns the same payment intent instead of creating a second one. If you don’t pass a key, the SDK makes one per call, so its own retries are still safe.

const intent = await stack.paymentIntents.retrieve('pi_...');
if (intent.status === 'succeeded') {
// paid
}
// A payment the customer hasn't paid yet, e.g. the order was abandoned:
await stack.paymentIntents.cancel('pi_...');

Cancelling a payment that is already canceled just returns it. A payment that is processing or succeeded can’t be cancelled: refund it instead.

Every failed call throws a StackError:

import { StackError } from '@usestack/node';
try {
await stack.paymentIntents.create({ amount: 0, currency: 'NGN' });
} catch (err) {
if (err instanceof StackError) {
console.log(err.type); // 'invalid_request'
console.log(err.status); // 400
console.log(err.message); // what went wrong, safe to log
console.log(err.requestId); // quote this when you contact us
}
}

A connection failure has type: 'network_error' and status: 0. See Errors for every type.

const stack = new Stack(process.env.STACK_SECRET_KEY!, {
timeoutMs: 30_000, // per attempt (default 30 seconds)
maxRetries: 2, // for network errors, 429 and 5xx (default 2)
});

Retries back off exponentially and always reuse the same idempotency key, so they can never create a second payment.

Not on Node? The REST API is plain JSON over HTTPS with your secret key as a Bearer token.