MainSDK
Search⌘K
How-to

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.

Building for 0.3

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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).
  • 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.

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

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.

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

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

CodeCauseFix
MAIN-CONFIG-0004pay.key is a literal valueWrite it as secret:STRIPE_SECRET_KEY
MAIN-PAY-0002The amount is not a positive integer in minor unitsPass 125000, or money such as "$1250.00"
MAIN-PAY-0010The webhook failed verification (you get 400)Use the right pay.webhookSecret, and pass the raw, unparsed body
MAIN-PAY-0020Stripe refused the requestRead the request in your Stripe dashboard logs
MAIN-SDK-0003pay.driver names a provider that is not built yetUse local, stripe or razorpay
MAIN-DATA-0020A data model has a pan fieldDrop 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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet