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 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.
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.
pay:
driver: stripe
key: secret:STRIPE_SECRET_KEY
webhookSecret: secret:STRIPE_WEBHOOK_SECRETKeep driver: test-cards (or leave the section out) while you build.
2. Start a checkout
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.
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
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:
pnpm mainsdk checkA 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:
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.idWith 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
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?