---
title: "Take a payment"
description: "Send a customer to a hosted checkout, handle the verified result and refund an order, with no card data in your app."
section: "guides"
status: "Building for 0.3"
spec: "pay"
canonical: "https://mainsdk.dev/docs/guides/take-a-payment"
updated: "2026-10-28"
---

# Take a payment

> Send a customer to a hosted checkout, handle the verified result and refund an order, with no card data in your app.

Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/take-a-payment · Updated: 2026-10-28

You will configure the payment driver, create a checkout, handle the verified webhook and refund an order. You can finish it on the local test card driver without a provider account; switching to Stripe is a config change. It takes about 15 minutes.

## You need

- A project with `main.yaml` (see [Start a project](https://mainsdk.dev/docs/guides/start-a-project)).
- For Stripe: an account in test mode, with its secret key and webhook signing secret in your secrets manager. Not needed for the local driver.

## 1. Configure

With no `pay.driver`, `pay` uses the local `test-cards` driver: no network, no account.

```yaml title="main.yaml"
pay:
  driver: stripe
  key: secret:STRIPE_SECRET_KEY
  webhookSecret: secret:STRIPE_WEBHOOK_SECRET
```

Keep `driver: test-cards` (or leave the section out) while you build.

## 2. Start a checkout

```ts title="app/api/checkout/route.ts"
import { pay } from "mainsdk";

declare function loadOrder(id: string): Promise<{ id: string; totalMinor: number }>;   // your code

export async function POST(req: Request) {
  const { orderId } = (await req.json()) as { orderId: string };
  const order = await loadOrder(orderId);   // your code: read the total on the server
  const session = await pay.checkout({
    order: order.id,
    amount: order.totalMinor,   // integer minor units, never from the browser
    currency: "INR",
    successUrl: "/orders/done",
    cancelUrl: "/cart",
  });
  return Response.json(session);   // { id, url }, or { id, clientSecret } for hosted fields
}
```

Redirect the customer to `session.url`. Card data never touches your app. A repeat of the same call returns the same session, so a double-click does not start two checkouts.

## 3. Handle the result

Point your provider's webhook at a route that hands the raw body and headers to `pay.handleWebhook()`. It verifies the signature and timestamp, then runs your handlers once per event.

```ts title="app/api/pay/webhook/route.ts"
import { pay } from "mainsdk";

declare function markPaid(paymentId: string): Promise<void>;   // your code

pay.on("payment.succeeded", async (event) => {
  await markPaid(event.data.id as string);   // your code
});

export async function POST(req: Request) {
  const { status, body } = await pay.handleWebhook(await req.text(), req.headers);
  return new Response(body, { status });
}
```

With the local driver, `payment.succeeded` fires straight after `pay.checkout()`, so you can test this handler without a webhook.

## 4. Refund

```ts title="app/api/refund/route.ts"
import { pay } from "mainsdk";

declare const order: { paymentId: string };   // loaded by your code
declare const ticketId: string;

const refund = await pay.refund(order.paymentId, { reason: "damaged" });   // full refund
// partial: { amount: 500, reason: "damaged", idempotencyKey: `refund:${ticketId}` }
```

Checkout, subscribe and refund take idempotency keys, so a retry never charges or refunds twice. Pass your own key when you have a natural one.

## Verify

Run your test with the local driver and the check:

```bash
pnpm mainsdk check
```

A pass shows no findings for `pay`. In a test, a checkout returns a URL, your handler runs once, and a retried refund returns the same refund:

```ts
import { pay } from "mainsdk";

const session = await pay.checkout({ order: "o1", amount: 2000, successUrl: "/ok", cancelUrl: "/no" });
const a = await pay.refund("pi_1", { reason: "dup", idempotencyKey: "r1" });
const b = await pay.refund("pi_1", { reason: "dup", idempotencyKey: "r1" });
// session.url is set; a.id === b.id
```

With Stripe in test mode, the test card `4242 4242 4242 4242` completes a checkout and your webhook handler runs after the signature is verified.

## If it fails

| Code | Cause | Fix |
| --- | --- | --- |
| `MAIN-CONFIG-0004` | `pay.key` is a literal value | Write it as `secret:STRIPE_SECRET_KEY` |
| `MAIN-PAY-0002` | The amount is not a positive integer in minor units | Pass `125000`, or money such as `"$1250.00"` |
| `MAIN-PAY-0010` | The webhook failed verification (you get 400) | Use the right `pay.webhookSecret`, and pass the raw, unparsed body |
| `MAIN-PAY-0020` | Stripe refused the request | Read the request in your Stripe dashboard logs |
| `MAIN-SDK-0003` | `pay.driver` names a provider that is not built yet | Use `local`, `stripe` or `razorpay` |
| `MAIN-DATA-0020` | A data model has a `pan` field | Drop it; hosted checkout keeps card data out of your app |

## For agents

```text
Goal: add a hosted checkout, a verified webhook route and a refund call using mainsdk `pay`.
Touch: main.yaml (pay section only), the checkout route, one webhook route, the refund route.
Rules: the amount comes from a server-side order, in integer minor units. Provider keys are `secret:` references. Never add a card-number field or store card data. Register pay.on() handlers and call pay.handleWebhook(rawBody, headers) with the raw body.
Verify: pnpm mainsdk check && pnpm test
Never weaken or skip a check; fix the finding it names.
```

## Next

- [Payments](https://mainsdk.dev/docs/interfaces/pay)
- [Plans and entitlements](https://mainsdk.dev/docs/interfaces/entitlements)
- [Limits, metering and entitlements](https://mainsdk.dev/docs/guides/limits-and-metering)
