ShipSafe
Vibe-coder installPaste this into Claude Code, Cursor, or Codex. Your AI will do the rest.
Add shipsafe-mcp-pay to this MCP server. Read https://shipsafe.franklabs.io/docs/mcp-pay/llms.txt and follow it exactly.
Spec your AI will read: /docs/mcp-pay/llms.txtNo signup required — a sandbox key is provisioned automatically.

API Reference: shipsafe-mcp-pay

Every export, every option, every type.

Note

Every helper below throws PaywallConfigError on bad configuration and ChargeError on backend failures. Both extend Error, so a single try/catch covers normal control flow.

createPaywall(options)

Builds a Paywall instance. Validates the API key shape, wires up the fetch implementation, and prepares the tool wrapper.

import { createPaywall } from 'shipsafe-mcp-pay';

const pay = createPaywall({
  apiKey: process.env.MCP_PAY_API_KEY!,
});

Options

Field Type Required Default Description
apiKey string yes Your merchant API key. Must start with mp_test_ or mp_live_.
backendUrl string no https://shipsafe.franklabs.io Override the ShipSafe backend. Useful for self-hosted ShipSafe or testing.
fetch typeof fetch no globalThis.fetch Inject a custom fetch (testing, proxies, Node < 18 polyfills).
onChargeError (err: unknown) => void no undefined Telemetry hook fired when the charge or refund pipeline throws.

Throws

  • PaywallConfigError if apiKey is missing, empty, or has the wrong prefix.
  • PaywallConfigError if no fetch implementation is available.

Returns: Paywall

interface Paywall {
  tool(opts: ToolOptions): <H>(handler: H) => (args, mcpContext?) => Promise<...>;
  charge(opts: { toolName: string; customerId: string; idempotencyKey?: string }): Promise<ChargeResult>;
  refund(opts: { chargeId: string; reason: string }): Promise<RefundResult>;
}

pay.tool(toolOptions)(handler)

Wraps an async MCP tool handler. The returned function is what you register with your MCP server.

const paidWeather = pay.tool({ name: 'weather' })(async (args, ctx) => {
  return { temperature: 72, city: args.city };
});

ToolOptions

Field Type Required Default Description
name string yes Tool name. Must match a tool registered in your ShipSafe dashboard. The dashboard holds the per-call price.
customerIdFrom 'context' | 'header' | (req: any) => string no 'context' How to extract the customer id from the incoming MCP request.

customerIdFrom values

Value Reads from
'context' (default) mcpContext._meta.customerId or mcpContext.customerId
'header' mcpContext._meta.headers['x-customer-id'] or mcpContext.headers['x-customer-id']
Function Arbitrary extractor: (req) => string. Throw or return empty string to signal "no customer".

Handler signature

(args: TArgs, ctx: ToolContext) => Promise<TResult>

args is whatever the MCP client sent in params.arguments. ctx is the post-debit context (see below).

Returned wrapper

(args: any, mcpContext?: any) => Promise<TResult | McpPaymentRequiredEnvelope>

If the wallet has funds, returns the handler's result. If not, returns an MCP-shaped payment-required envelope. Always check result?._meta?.payment_required before treating the value as your handler's return type.

Throws

  • PaywallConfigError if name is missing or empty.
  • MissingCustomerIdError if the customer id cannot be extracted.
  • ChargeError (subclass of Error) if the backend returns a 4xx (non-402) or 5xx after one retry.
  • The handler's own error, if it throws. The wrapper attempts a refund first.

pay.charge(charge)

Direct charge. Useful for tests, custom transports, or non-MCP code paths.

const result = await pay.charge({
  toolName: 'weather',
  customerId: 'cus_abc',
  idempotencyKey: 'req_123',
});

if (result.ok) {
  console.log(result.chargeId, result.balanceCents);
} else {
  console.log(result.status, result.body.accepts);
}

Options

Field Type Required Description
toolName string yes Tool name registered in the dashboard.
customerId string yes Customer id to debit.
idempotencyKey string no Custom dedup key. Same key + same toolName + same customerId returns the original chargeId without double-debiting.

Returns: ChargeResult

Discriminated union on ok:

type ChargeResult =
  | { ok: true; customerId: string; walletId: string; balanceCents: number; chargeId: string }
  | { ok: false; status: 402; body: PaymentRequiredBody; headers: Record<string, string> };

Throws

ChargeError on 4xx (non-402), 5xx after one retry, or malformed responses.


pay.refund(refund)

Reverse a previously successful charge.

await pay.refund({ chargeId: 'ch_1', reason: 'manual_refund' });

Options

Field Type Required Description
chargeId string yes Ledger entry id from a successful charge.
reason string yes Free-text reason, stored on the refund ledger entry. Convention: handler_error, manual_refund, disputed, etc.

Returns: RefundResult

type RefundResult = {
  ok: true;
  refundId: string;
  chargeId: string;
  balanceCents: number;
};

Throws

ChargeError on any non-200 response or malformed body.

Note: the tool wrapper calls refund automatically when your handler throws. You only need to call this directly for manual reversals.


build402Response(input)

Lower-level helper. Build a 402 envelope yourself, for HTTP-only paywalls outside MCP.

import { build402Response } from 'shipsafe-mcp-pay';

const { status, headers, body } = build402Response({
  priceCents: 25,
  stripeCheckoutUrl: 'https://checkout.stripe.com/pay/cs_abc',
  usdcPayTo: '0xabc...',
  usdcNetwork: 'base',
});

Input

Field Type Required Description
priceCents number yes Price in cents (USD). Used for the fiat amount; converted to USDC for the x402 scheme.
stripeCheckoutUrl string no Hosted Stripe Checkout URL. Set to enable stripe-topup and ap2-cards.
usdcPayTo string no USDC payout address. Set to enable x402-usdc.
usdcNetwork 'base' | 'base-sepolia' no USDC network. Default 'base'.
error 'insufficient_funds' | 'payment_required' no Error tag included in the body.
message string no Human-readable message included in the body.

Returns

{ status: 402; headers: Record<string, string>; body: PaymentRequiredBody }

Headers include X-Payment-Required: x402; version=1, ap2; version=0.2.


Types

ToolContext

The second argument to your handler, after a successful debit.

type ToolContext = {
  customerId: string;
  walletId: string;
  balanceCents: number;   // Wallet balance AFTER the debit
  chargeId: string;       // Ledger entry id, for manual refunds
};

PaymentRequiredBody

The x402-v1 body returned on 402.

type PaymentRequiredBody = {
  x402Version: 1;
  accepts: PaymentMethodOption[];
  error?: 'insufficient_funds' | 'payment_required';
  message?: string;
};

type PaymentMethodOption =
  | { scheme: 'stripe-topup'; topUpUrl: string; amount: { value: number; currency: 'USD' } }
  | { scheme: 'x402-usdc'; network: 'base' | 'base-sepolia'; payTo: string; amount: { value: number; currency: 'USDC' } }
  | { scheme: 'ap2-cards'; checkoutUrl: string; amount: { value: number; currency: 'USD' } };

McpPaymentRequiredEnvelope

The MCP-compatible envelope returned by pay.tool wrappers on 402.

type McpPaymentRequiredEnvelope = {
  isError: true;
  content: Array<{ type: 'text'; text: string }>;
  _meta: {
    payment_required: PaymentRequiredBody;
    'x-payment-required': string;
  };
};

ChargeResult, ChargeSuccess, ChargePaymentRequired, RefundResult

See src/types.ts for the full source.


Error classes

All error classes extend Error and are exported from the package.

Class When
PaywallConfigError apiKey missing or wrong prefix; tool({ name }) missing; no fetch implementation.
MissingCustomerIdError Could not extract a customer identifier from the MCP request. Fix by setting _meta.customerId upstream or passing customerIdFrom.
ChargeError (internal, surfaced as Error) Backend returned 4xx (non-402) or 5xx after one retry, or returned a malformed body. Carries status and responseBody properties.

Network errors and 5xx responses are retried once with a 150ms backoff before surfacing.


Environment variables

The package itself reads no env vars. Your code reads:

Var Required Used for
MCP_PAY_API_KEY yes Passed to createPaywall({ apiKey }).
MCP_PAY_BACKEND_URL no Override the backend if self-hosting ShipSafe. Passed to createPaywall({ backendUrl }).

Backend endpoints called

For reference, the package POSTs to:

Endpoint Body Returns
POST {backendUrl}/api/mcp-pay/charge { toolName, customerId, idempotencyKey? } 200 (ChargeSuccess) or 402 (PaymentRequiredBody)
POST {backendUrl}/api/mcp-pay/refund { chargeId, reason } 200 (RefundResult)

Both requests use Authorization: Bearer {apiKey} and User-Agent: shipsafe-mcp-pay.