Payments
payTake payments and subscriptions on the provider's hosted checkout, with verified webhooks, idempotent calls and no card data in your app.
payFree in your app, Apache-2.0Status 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.
pay takes payments and subscriptions on your provider's hosted checkout, with market routing, verified webhooks, idempotent calls and no card data in your app, plus a local test flow.
At a glance
# main.yaml
payments: { driver: stripe }
pay:
stripe: { key: secret:STRIPE_SECRET_KEY, webhook: secret:STRIPE_WEBHOOK_SECRET }
razorpay: { key: secret:RAZORPAY_KEY, webhook: secret:RAZORPAY_WEBHOOK_SECRET }
# main.in.yaml: the India overlay changes only the provider
payments: { driver: razorpay, methods: [upi, cards] }import { pay } from "mainsdk";
const session = await pay.checkout({
order: order.id,
amount: 125000, // minor units
currency: "INR",
successUrl: "/orders/done",
cancelUrl: "/cart",
});app/checkout.test.ts
import { createTestApp } from "@mainsdk/testing";
import { pay } from "mainsdk";
createTestApp({ yaml: "schema: 1\npay: {}\n" });
const session = await pay.checkout({ order: "o_1", amount: 125000, currency: "INR", successUrl: "/done", cancelUrl: "/cart" });
if (!("url" in session)) throw new Error("expected a hosted url");Drivers
4 drivers. Select one to see its line in main.yaml.
payments:
driver: stripeChange this one line to swap providers; your calls stay the same.Stripe integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-PAY-0001 | Checkmainsdk check, before deploy | A form posts card data to an app route |
MAIN-PAY-0002 | RuntimeError responses and logs | An amount isn't positive money, or doesn't fit the currency's minor units (400) |
MAIN-PAY-0010 | RuntimeError responses and logs | A payment webhook failed verification: bad or missing signature, or outside the 5-minute window (400); no handler ran |
MAIN-PAY-0020 | RuntimeError responses and logs | The payment provider answered with an error |
MAIN-PAY-0030 | RuntimeError responses and logs | pay.agent named a payee not in pay.payees, or an amount above the payee's max |
MAIN-PAY-0031 | RuntimeError responses and logs | pay.agent was called with no agent payment rail set |
MAIN-PAY-0032 | RuntimeError responses and logs | A held agent payment was sent before a person approved it |
MAIN-PAY-0033 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Agent payments inside an agent budget | Phase 2 | 0.7 | web3-ready |
| pay@1 contract with market routing | Phase 1 | 0.3 | pay#contract |
| Hosted checkout and subscriptions | Phase 1 | 0.3 | pay#hosted |
| Card data check | Phase 1 | 0.3 | pay#card-check |
| Verified provider webhooks | Phase 1 | 0.3 | pay#webhooks |
| Idempotency keys | Phase 1 | 0.3 | pay#idempotency |
| Provider by market | Phase 1 | 0.3 | pay#routing |
| Stripe and Razorpay drivers | Phase 1 | 0.3 | pay#drivers |
| Signed payment approvals (PSD2, RBI) | Phase 3 | 1.2 | signed-approvals#dynamic-linking |
| Subscription events set the tenant's plan | Phase 1 | 0.3 | entitlements#pay-source |
Quick start
Start on the local test card flow, which needs no account, then start a hosted checkout. Configuration shows the Stripe and Razorpay keys for real payments.
schema: 1
pay:
driver: local # the default; stripe and razorpay take secret references, see Configuration"use server";
import { auth, pay } from "mainsdk";
export async function startCheckout(req: Request, orderId: string) {
await auth.require(req);
const session = await pay.checkout({
order: orderId,
amount: 125000, // integer minor units
currency: "INR", // ISO 4217
successUrl: "/orders/done",
cancelUrl: "/cart",
});
if ("url" in session) return new Response(null, { status: 303, headers: { location: session.url } });
return session; // { id, clientSecret } for hosted fields
}pay.checkout() returns { id, url } for a hosted page, or { id, clientSecret } for the provider's hosted fields. With driver: local (the default when pay.driver is not set; the older name test-cards still works), a local flow returns a local URL and fires payment.succeeded, with no account or network.
Configuration
pay:
driver: stripe
key: secret:STRIPE_SECRET_KEY # provider API key, a secret reference
webhookSecret: secret:STRIPE_WEBHOOK_SECRET # secret used to verify webhooks
payees: # only for agent payments
weather-api: { host: api.weather.example, max: $0.05/call }| Key | Type | What it does |
|---|---|---|
pay.driver | string | stripe, razorpay, local or test-cards (the older name of local). Defaults to payments.default, else local. Any other name fails with MAIN-SDK-0003 |
pay.stripe | map | key and webhook secret references for Stripe; builds the Stripe driver for market routing |
pay.razorpay | map | key (key_id:key_secret, a secret reference), webhook secret reference and methods (upi, cards); builds the Razorpay driver |
payments | map | Provider by market: { in: razorpay, default: stripe }. Pass market to pay.checkout(), pay.subscribe() and pay.refund(); pay.providerFor(market) names the provider |
pay.key | secret reference | Provider API key. Defaults to secret:STRIPE_SECRET_KEY |
pay.webhookSecret | secret reference | Secret used to verify webhooks. Defaults to secret:STRIPE_WEBHOOK_SECRET |
pay.payees.<name> | map | A payee an agent may pay: its host and the most one call may send (max) |
Provider keys must be secret: references. A literal pay.key fails config loading with MAIN-CONFIG-0004. See Secrets.
To build the Razorpay driver yourself, for example in a test, call razorpay({ key, webhookSecret, fetch, methods }) and pass it to createPay. It sends every request through the fetch you give it, so the network layer allows api.razorpay.com.
Webhooks from a routed provider go to pay.handleWebhook(rawBody, headers, provider); without provider the default driver verifies them.
Building and money helpers
createPay(opts) builds a pay over a driver. It takes driver, the agents governance, log, and optionally payees, network (or isAllowed), agentRail, tenantId and now. Most apps never call it; the app runtime builds pay from main.yaml. Use it in tests or when you wire the parts by hand.
Money is a string such as "$0.05" and is held as integer micro-dollars so many small amounts never drift. Three helpers convert it:
| Call | What it does |
|---|---|
toMicros(value) | Parse a money string into micro-dollars; anything else throws MAIN-SDK-0006 |
fromMicros(micros) | Format micro-dollars as a money string with at least two decimals |
parseRate(value) | Parse a limit such as "$5/day" or "500/day" into { amount, money, per }, where per is call, minute, hour, day or month |
import { fromMicros, parseRate, toMicros } from "mainsdk";
const micros = toMicros("$0.05"); // 50000
const label = fromMicros(micros); // "$0.05"
const limit = parseRate("$5/day"); // { amount, money, per: "day" }
if (label !== "$0.05" || limit.per !== "day") throw new Error("unexpected money");What you get
- Hosted checkout and subscriptions on Stripe and Razorpay, with the provider chosen per request from the market.
- Provider webhooks verified (signature and 5-minute tolerance) before your handler runs, and each event handled once.
- Idempotency keys on
checkout,subscribeandrefund, generated from the action and its inputs unless you pass one. - Amounts as integer minor units with an ISO 4217 currency.
- A local
test-cardsflow that needs no account. - Agent payments to declared payees, and plans set from subscription events.
Agent payments
pay.agent() lets an app agent pay a declared payee, such as a metered API. It always runs behind the agent's budget and approval rules (see agents). The payee must be listed under pay.payees, its host must be allowed by network, and an amount above the payee's max is refused with MAIN-PAY-0030. MainSDK holds no keys or funds: your app supplies the agentRail that sends the payment, or the call fails with MAIN-PAY-0031. Spec web3-ready covers the rails.
pay:
payees:
weather-api: { host: api.weather.example, max: $0.05/call }
network:
allow: [api.weather.example]import { pay } from "mainsdk";
await pay.agent({ agent: "support-bot", amount: "$0.04", to: "weather-api" });Plans from subscriptions
A verified subscription.updated event sets the tenant's plan, so entitlements knows what to allow. Map each price to a plan under plans.<name>.prices, and put the tenant id in the subscription's metadata.tenant. See plans and entitlements.
Examples
Check out an order
Load the order on the server and charge its stored total, never an amount from the browser.
"use server";
import { auth, pay } from "mainsdk";
type Order = { id: string; totalMinor: number; currency: string; paymentId: string };
declare function loadOrder(user: string, id: string): Promise<Order>; // your db.orders.for(user).get(id)
export async function payForOrder(req: Request, orderId: string) {
const session = await auth.require(req);
const order = await loadOrder(session.user, orderId);
const checkout = await pay.checkout({
order: order.id,
amount: order.totalMinor, // integer minor units
currency: order.currency, // "INR", "USD", "EUR"
successUrl: `/orders/${order.id}/done`,
cancelUrl: `/orders/${order.id}`,
});
if ("url" in checkout) return new Response(null, { status: 303, headers: { location: checkout.url } });
return checkout;
}The idempotency key is generated from the action and its inputs by default, so a double-click starts one checkout, not two. Within a tenant, the key is scoped to that tenant.
Handle a verified webhook
Register handlers with pay.on(), and point your provider's webhook at a route that passes the raw body and headers to pay.handleWebhook(). The signature and timestamp are checked before your handler runs; an unverified call gets 400 and is logged, and an event delivered twice runs your handler once.
import { jobs, pay } from "mainsdk";
pay.on("payment.succeeded", async (event) => {
await jobs.run("orders.markPaid", event); // slow work goes to a background job
});
pay.on("payment.failed", async (event) => {
await jobs.run("orders.notifyPaymentFailed", event);
});
export async function POST(req: Request) {
const { status, body } = await pay.handleWebhook(await req.text(), req.headers); // the raw, unparsed body
return new Response(body, { status });
}The events are payment.succeeded, payment.failed, refund.succeeded and subscription.updated. Each is { id, type, data }, where data is the provider's object for the event. A handler that throws gets a 500, so the provider retries.
Refund safely with an idempotency key
A refund retried after a timeout never refunds twice. Pass your own key when you have a natural one, such as a support ticket ID.
import { auth, pay } from "mainsdk";
type Order = { id: string; paymentId: string };
declare function loadOrder(user: string, id: string): Promise<Order>; // your db.orders.for(user).get(id)
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth.require(req, { role: "support", fresh: "5m" });
const { reason, ticketId } = (await req.json()) as { reason: string; ticketId: string };
const order = await loadOrder(session.user, id);
const refund = await pay.refund(order.paymentId, {
reason,
idempotencyKey: `refund:${ticketId}`, // same key, same refund ID on every retry
});
return Response.json(refund);
}Leave out amount for a full refund, or pass an integer in minor units for a partial one. The key is passed to the provider's native idempotency where it exists.
Start a subscription
"use server";
import { auth, pay } from "mainsdk";
export async function subscribe(req: Request, plan: string) {
const session = await auth.require(req);
return pay.subscribe({
customer: session.user, // a Stripe customer id (cus_...), or your own id
plan, // the provider's price id
successUrl: "/billing/done",
cancelUrl: "/billing",
}); // { id, status, url? }: send the customer to url
}Subscription changes arrive as subscription.updated through pay.on().
Refund as an agent tool
Make refunds an action and agents must get a person's yes before running it.
import { action, pay } from "mainsdk";
import { s } from "mainsdk/schema";
type Order = { id: string; paymentId: string };
declare function loadOrder(user: unknown, id: string): Promise<Order>; // your db.orders.for(user).get(id)
export const refundOrder = action({
name: "orders.refund",
intent: "Refund an order the signed-in user owns",
input: s.object({ orderId: s.string(), reason: s.string() }),
risk: "destructive",
run: async ({ input, user }) => {
const order = await loadOrder(user, input.orderId);
return pay.refund(order.paymentId, { reason: input.reason });
},
});Signed payment approvals for PSD2 and RBI flows are planned for phase 3.
Errors and checks
Errors are MainError values that serialise as RFC 9457 problem details with a code, a fix and a docs link.
| Code | When | Fix |
|---|---|---|
MAIN-PAY-0002 | The Stripe driver got an amount that is not a positive integer in minor units, or money with more decimals than the currency has (400) | Pass minor units such as 125000, or money such as "$20.00" |
MAIN-PAY-0010 | A webhook failed verification: bad signature, missing header or a timestamp over 5 minutes old. pay.handleWebhook returns 400 and the handler is not called | Check pay.webhookSecret is the right signing secret, and pass the raw body |
MAIN-PAY-0020 | The provider refused the request (502) | Read the provider's dashboard logs |
MAIN-PAY-0030 | An agent payment names an undeclared payee, is above its max, or its host is not allowed by network (403) | Declare the payee, pay less, or add the host to network.allow |
MAIN-PAY-0031 | pay.agent ran with no agentRail (500) | Pass agentRail when you build the app; MainSDK sends no funds itself |
MAIN-PAY-0032 | An agent payment was sent before a person approved it (403) | Approve it first with agents.approve |
MAIN-SDK-0003 | pay.driver names a provider that is not built yet (400) | Use local, stripe or razorpay |
A retried checkout, subscribe or refund with the same key returns the first result; nothing is charged or refunded twice.
A pan label in a data model fails mainsdk check with MAIN-DATA-0020: card numbers are never stored. Planned: the wider card-data check (card-number inputs outside hosted fields, raw card API calls) as MAIN-PAY-0001. See the check for how gates and exit codes work.
Swapping drivers
Change pay.driver. Your calls to pay.checkout(), pay.subscribe(), pay.refund() and pay.on() stay the same.
pay:
driver: stripe # was test-cards
key: secret:STRIPE_SECRET_KEY
webhookSecret: secret:STRIPE_WEBHOOK_SECRETWhen you need a provider-only feature, pay.raw() returns the driver. It is the escape hatch for calls the contract does not cover.
Testing
createTestApp() puts pay on the test-cards driver, with no provider account. A checkout there returns a local URL:
import { createTestApp } from "@mainsdk/testing";
import { pay } from "mainsdk";
createTestApp({ yaml: "schema: 1\npay: {}\n" });
const session = await pay.checkout({ order: "o_1", amount: 125000, currency: "INR", successUrl: "/done", cancelUrl: "/cart" });
if (!("url" in session)) throw new Error("expected a hosted url");The refund test below asserts that a retried refund returns the same refund.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { pay } from "mainsdk";
describe("refunds", () => {
it("pay.AC4 returns the same refund when a refund is retried", async () => {
createTestApp({ main: "./main.yaml" }); // every interface on its fake
const first = await pay.refund("pi_1", { reason: "duplicate", idempotencyKey: "r1" });
const retry = await pay.refund("pi_1", { reason: "duplicate", idempotencyKey: "r1" });
assert.deepEqual(retry, first);
});
});To test a webhook handler, sign a body with the driver and pass it to pay.handleWebhook():
import { pay } from "mainsdk";
const body = JSON.stringify({ id: "evt_1", type: "payment.succeeded", data: { id: "cs_1" } });
const sig = (pay.raw() as unknown as { sign(b: string): string }).sign(body);
await pay.handleWebhook(body, { "x-test-signature": sig }); // { status: 200, body: "ok" }Charge assertions (toHaveCharged) and outage injection for pay are planned in the testing kit.
Security and privacy
- Checkout always uses the provider's hosted page or hosted fields. Card numbers, CVV and expiry never pass through your server and are never stored.
- Webhooks are verified (signature and a 5-minute timestamp tolerance) before any handler runs.
- Amounts are integers in minor units with an ISO 4217 currency, so there are no rounding errors.
- Provider keys are secret references, read on the server only.
FAQ
Can I build my own card form? No. Card data must stay on the provider's hosted page or hosted fields; a card-number input outside them fails MAIN-PAY-0001.
How do I charge by usage? Record usage with meter, which can send closed hours to the provider through pay.reportUsage().
Do I need a Stripe account to develop? No. Without pay.driver, the test-cards driver runs a local flow, and tests use it too.
What about Play Billing or StoreKit purchases? They are not part of pay@1, and neither is Paddle.
What is not decided yet? How customer maps to a provider customer beyond a Stripe cus_ id.
Next
- Webhooks: send and receive other signed webhooks.
- Actions and agent tools: expose refunds to agents safely.
- Secrets: keep provider keys out of code.
- Take a payment
Was this page helpful?