---
title: "Payments"
description: "Take payments and subscriptions on the provider's hosted checkout, with verified webhooks, idempotent calls and no card data in your app."
section: "interfaces"
status: "Building for 0.3 · ships as a stable API"
spec: "pay"
canonical: "https://mainsdk.dev/docs/interfaces/pay"
updated: "2026-10-28"
---

# Payments

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

Status: Building for 0.3 · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/pay · 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

- Status: Building for 0.3 · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/pay.md
- Category: Payments and plans

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)`

In main.yaml:

```yaml
# 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] }
```

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

Pick one in main.yaml with `payments.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Stripe | `stripe` | yes |  |
| Razorpay | `razorpay` |  |  |
| Paddle | `paddle` |  | planned driver |
| Local test cards | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-PAY-0001` | check | A form posts card data to an app route |
| `MAIN-PAY-0002` | runtime | An amount isn't positive money, or doesn't fit the currency's minor units (400) |
| `MAIN-PAY-0010` | runtime | A payment webhook failed verification: bad or missing signature, or outside the 5-minute window (400); no handler ran |
| `MAIN-PAY-0020` | runtime | The payment provider answered with an error |
| `MAIN-PAY-0030` | runtime | `pay.agent` named a payee not in `pay.payees`, or an amount above the payee's `max` |
| `MAIN-PAY-0031` | runtime | `pay.agent` was called with no agent payment rail set |
| `MAIN-PAY-0032` | runtime | A held agent payment was sent before a person approved it |
| `MAIN-PAY-0033` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Agent payments inside an agent budget | 2 | 0.7 | `web3-ready` |
| pay@1 contract with market routing | 1 | 0.3 | `pay#contract` |
| Hosted checkout and subscriptions | 1 | 0.3 | `pay#hosted` |
| Card data check | 1 | 0.3 | `pay#card-check` |
| Verified provider webhooks | 1 | 0.3 | `pay#webhooks` |
| Idempotency keys | 1 | 0.3 | `pay#idempotency` |
| Provider by market | 1 | 0.3 | `pay#routing` |
| Stripe and Razorpay drivers | 1 | 0.3 | `pay#drivers` |
| Signed payment approvals (PSD2, RBI) | 3 | 1.2 | `signed-approvals#dynamic-linking` |
| Subscription events set the tenant's plan | 1 | 0.3 | `entitlements#pay-source` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

Start on the local test card flow, which needs no account, then start a hosted checkout. [Configuration](#configuration) shows the Stripe and Razorpay keys for real payments.

```yaml title="main.yaml"
schema: 1
pay:
  driver: local        # the default; stripe and razorpay take secret references, see Configuration
```

```ts title="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

```yaml title="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 }
```

| 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](https://mainsdk.dev/docs/interfaces/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` |

```ts title="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](https://mainsdk.dev/docs/interfaces/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.

```yaml title="main.yaml"
pay:
  payees:
    weather-api: { host: api.weather.example, max: $0.05/call }
network:
  allow: [api.weather.example]
```

```ts title="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](https://mainsdk.dev/docs/interfaces/entitlements).

## Examples

### Check out an order

Load the order on the server and charge its stored total, never an amount from the browser.

```ts title="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.

```ts title="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.

```ts title="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

```ts title="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](https://mainsdk.dev/docs/interfaces/action) and agents must get a person's yes before running it.

```ts title="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.

| 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](https://mainsdk.dev/docs/concepts/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.

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

> **Warning:** Payments are stateful. Customers, saved methods and subscriptions live in the provider, so moving them to another provider is a migration, not a switch. A guided migration with a rollback is planned. Routing new checkouts to a second provider by market needs no migration.

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:

```ts title="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.

```ts title="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`](https://mainsdk.dev/docs/interfaces/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](https://mainsdk.dev/docs/interfaces/webhooks): send and receive other signed webhooks.
- [Actions and agent tools](https://mainsdk.dev/docs/interfaces/action): expose refunds to agents safely.
- [Secrets](https://mainsdk.dev/docs/interfaces/secrets): keep provider keys out of code.
- [Take a payment](https://mainsdk.dev/docs/guides/take-a-payment)
