MainSDK
Search⌘K
Interfaces

Payments and plans

Payments

pay

Take payments and subscriptions on the provider's hosted checkout, with verified webhooks, idempotent calls and no card data in your app.

Building for 0.3ships as a stable APIPhase 1 · Core, public betaSpec payFree in your app, Apache-2.0

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

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] }

Calls

  • pay.checkout()
  • pay.subscribe()
  • pay.refund()
  • pay.on()
  • pay.agent({ agent, amount, to })
  • createPay(opts)
  • fromMicros(micros)
  • toMicros(value)
  • parseRate(value)
  • pay.handleWebhook(rawBody, headers)
  • pay.reportUsage(event)
  • pay.providerFor(market)
  • razorpay(opts)

Guarantees

Whatever the driver.

  • Card data never touches the app
  • Provider webhooks verified before handlers run
  • Idempotency keys on checkout, subscribe and refund
  • Amounts in integer minor units with an ISO 4217 currency

Drivers

4 drivers. Select one to see its line in main.yaml.

main.yamlStripe (default)
payments:
  driver: stripe

Change this one line to swap providers; your calls stay the same.Stripe integration

Codes

CodeWhere it appearsMeaning
MAIN-PAY-0001Checkmainsdk check, before deployA form posts card data to an app route
MAIN-PAY-0002RuntimeError responses and logsAn amount isn't positive money, or doesn't fit the currency's minor units (400)
MAIN-PAY-0010RuntimeError responses and logsA payment webhook failed verification: bad or missing signature, or outside the 5-minute window (400); no handler ran
MAIN-PAY-0020RuntimeError responses and logsThe payment provider answered with an error
MAIN-PAY-0030RuntimeError responses and logspay.agent named a payee not in pay.payees, or an amount above the payee's max
MAIN-PAY-0031RuntimeError responses and logspay.agent was called with no agent payment rail set
MAIN-PAY-0032RuntimeError responses and logsA held agent payment was sent before a person approved it
MAIN-PAY-0033RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Agent payments inside an agent budgetPhase 20.7web3-ready
pay@1 contract with market routingPhase 10.3pay#contract
Hosted checkout and subscriptionsPhase 10.3pay#hosted
Card data checkPhase 10.3pay#card-check
Verified provider webhooksPhase 10.3pay#webhooks
Idempotency keysPhase 10.3pay#idempotency
Provider by marketPhase 10.3pay#routing
Stripe and Razorpay driversPhase 10.3pay#drivers
Signed payment approvals (PSD2, RBI)Phase 31.2signed-approvals#dynamic-linking
Subscription events set the tenant's planPhase 10.3entitlements#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.

main.yaml
schema: 1
pay:
  driver: local        # the default; stripe and razorpay take secret references, see Configuration
app/checkout/actions.ts
"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

main.yaml
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 }
KeyTypeWhat it does
pay.driverstringstripe, 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.stripemapkey and webhook secret references for Stripe; builds the Stripe driver for market routing
pay.razorpaymapkey (key_id:key_secret, a secret reference), webhook secret reference and methods (upi, cards); builds the Razorpay driver
paymentsmapProvider by market: { in: razorpay, default: stripe }. Pass market to pay.checkout(), pay.subscribe() and pay.refund(); pay.providerFor(market) names the provider
pay.keysecret referenceProvider API key. Defaults to secret:STRIPE_SECRET_KEY
pay.webhookSecretsecret referenceSecret used to verify webhooks. Defaults to secret:STRIPE_WEBHOOK_SECRET
pay.payees.<name>mapA 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:

CallWhat 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
lib/money.ts
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, subscribe and refund, generated from the action and its inputs unless you pass one.
  • Amounts as integer minor units with an ISO 4217 currency.
  • A local test-cards flow 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.

main.yaml
pay:
  payees:
    weather-api: { host: api.weather.example, max: $0.05/call }
network:
  allow: [api.weather.example]
lib/weather.ts
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.

app/orders/[id]/pay/actions.ts
"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.

app/api/pay/webhook/route.ts
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.

app/api/orders/[id]/refund/route.ts
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

app/billing/actions.ts
"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.

app/actions/refund.ts
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.

CodeWhenFix
MAIN-PAY-0002The 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-0010A webhook failed verification: bad signature, missing header or a timestamp over 5 minutes old. pay.handleWebhook returns 400 and the handler is not calledCheck pay.webhookSecret is the right signing secret, and pass the raw body
MAIN-PAY-0020The provider refused the request (502)Read the provider's dashboard logs
MAIN-PAY-0030An 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-0031pay.agent ran with no agentRail (500)Pass agentRail when you build the app; MainSDK sends no funds itself
MAIN-PAY-0032An agent payment was sent before a person approved it (403)Approve it first with agents.approve
MAIN-SDK-0003pay.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.

main.yaml
pay:
  driver: stripe   # was test-cards
  key: secret:STRIPE_SECRET_KEY
  webhookSecret: secret:STRIPE_WEBHOOK_SECRET

When 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:

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");

The refund test below asserts that a retried refund returns the same refund.

app/orders/refund.test.ts
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():

ts
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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet