Buyer wallet

Payment Methods

Let buyers view, manage, and add payment methods in your storefront without exposing your secret key to the browser. A short-lived customer-scoped token gates all buyer-facing card operations; your backend mints it, the browser uses it.

React package available
@usethrottle/payment-methods wraps the flow below as a headless core, React hooks, and a pre-built <PaymentMethods /> component. See the React package section at the bottom of this page, or use the HTTP routes directly if you prefer.
Cards must be added through this flow to be chargeable

Only cards vaulted through the add-card embed — the flow on this page, which finishes at POST /api/v1/me/payment-methods/confirm — can be charged later for subscription renewals, plan changes, or any other merchant-initiated payment. That step is what records the processor buyer reference those charges require.

Attaching a card directly with POST /api/v1/customers/{id}/payment-methods stores it and lists it correctly, but it cannot establish that reference, so every later charge fails with 402 payment_failed: payment_method_not_chargeable. Use that endpoint only for record-keeping, never as a card wallet.

Two-step auth flow

The payment-methods API uses a client token pattern that mirrors how Throttle's embedded checkout protects your secret key. Your backend mints a short-lived token scoped to one customer; the browser uses that token for all card management calls. The secret key never leaves your server.

  1. Backend mints a client token. Call POST /api/v1/payment-methods/client-token with your sk_* secret key and the customer's Throttle customerId. Throttle returns a short-lived JWT.
  2. Browser calls the /me routes. All /api/v1/me/payment-methods* routes accept the token in the X-Throttle-PM-Token header. The token authorizes ONLY that one customer's payment methods — no cross-customer reads are possible.
sequenceDiagram
  participant B as Buyer Browser
  participant M as Your Backend
  participant T as Throttle API
  M->>T: POST /api/v1/payment-methods/client-token { customerId }
  note over M,T: Authenticated with sk_* secret key
  T-->>M: { token, expiresAt }
  M-->>B: { token }
  B->>T: GET /api/v1/me/payment-methods
  note over B,T: X-Throttle-PM-Token: <token>
  T-->>B: { data: SavedCard[] }
  B->>T: PATCH /api/v1/me/payment-methods/:id { isDefault: true }
  T-->>B: { data: SavedCard }
  B->>T: POST /api/v1/me/payment-methods/embed-token
  T-->>B: { embedToken, checkoutSessionId, ... }
  note over B,T: Buyer adds new card via the add-only vault embed

Endpoint reference

POST /api/v1/payment-methods/client-token

Mints a customer-scoped payment-method client token. Call this from your backend; never expose your secret key to the browser.

Field Type Required Description
customerId string Yes The Throttle customer ID (cus_*) for the authenticated buyer.

Auth: X-API-Key (secret key, sk_*). The key must have the customer_payment_methods:write scope.

Backend — mint client token
// Your backend — Node.js / Next.js App Router route handler
// POST /api/pm-token
import { mintClientToken } from '@usethrottle/payment-methods/server';
export async function POST(request: Request) {
  const user = await auth(); // your auth layer
  if (!user) return new Response(null, { status: 401 });
  // Resolve the Throttle customerId for this buyer
  const customerId = await resolveThrottleCustomerId(user.id); // cus_*
  const { token, expiresAt } = await mintClientToken({
    apiKey: process.env.THROTTLE_SECRET_KEY!,
    customerId,
  });
  return Response.json({ token, expiresAt });
}
Response — 200 OK
{
  "data": {
    "token": "pm_tkn_eyJhbGci...",
    "expiresAt": "2026-06-24T11:00:00.000Z"
  }
}

The returned token is a signed JWT. Pass it to the browser as a session value or a short-lived cookie; do not persist it long-term. The expiresAt timestamp (ISO 8601) tells the browser when to request a fresh token.

GET /api/v1/me/payment-methods

Returns the list of saved cards for the customer identified by the PM token.

Auth: X-Throttle-PM-Token header (browser-safe)

Browser — list saved cards
// Browser — fetch the current customer's saved cards
const res = await fetch('https://api.usethrottle.dev/api/v1/me/payment-methods', {
  headers: { 'X-Throttle-PM-Token': token },
});
const { data: cards } = await res.json();
Response — 200 OK
{
  "data": [
    {
      "id": "cpm_abc123",
      "methodType": "card",
      "cardBrand": "visa",
      "cardLastFour": "4242",
      "cardExpMonth": 12,
      "cardExpYear": 2027,
      "isDefault": true
    },
    {
      "id": "cpm_def456",
      "methodType": "card",
      "cardBrand": "mastercard",
      "cardLastFour": "0001",
      "cardExpMonth": 8,
      "cardExpYear": 2026,
      "isDefault": false
    }
  ]
}

PATCH /api/v1/me/payment-methods/:id

Sets a saved card as the default. Currently the only writable field is isDefault: true — you cannot unset a default directly; set another card as default instead.

Auth: X-Throttle-PM-Token header

Field Type Required Description
isDefault boolean Yes Must be true. Promotes this card to the default; the previously-default card is demoted automatically.
Browser — set default card
// Browser — set a card as the default
const res = await fetch(
  `https://api.usethrottle.dev/api/v1/me/payment-methods/${cardId}`,
  {
    method: 'PATCH',
    headers: {
      'X-Throttle-PM-Token': token,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ isDefault: true }),
  },
);
const { data: updatedCard } = await res.json();

DELETE /api/v1/me/payment-methods/:id

Removes a saved card. Returns 200 with { data: { id }, meta } on success.

Auth: X-Throttle-PM-Token header

Browser — remove a card
// Browser — remove a saved card
const res = await fetch(
  `https://api.usethrottle.dev/api/v1/me/payment-methods/${cardId}`,
  {
    method: 'DELETE',
    headers: { 'X-Throttle-PM-Token': token },
  },
);
// 200 { data: { id: "cpm_..." }, meta: { ... } } on success
// 409 { error: { code: "cannot_remove_default" } } if removing default while subscription is active
Cannot remove default while subscription is active
If the card being deleted is the default payment method and the customer has at least one active subscription, Throttle returns 409 cannot_remove_default. Prompt the buyer to set a different card as the default first, then retry the delete.
409 cannot_remove_default
// 409 response when removing the default card while an active subscription exists
{
  "error": {
    "code": "cannot_remove_default",
    "message": "Add another card and make it the default before removing this one.",
    "status": 409
  }
}

POST /api/v1/me/payment-methods/embed-token

Returns an add-only vault embed payload so the buyer can add a new card without going through a full checkout flow. The embed is configured to vault-only (zero-amount); it will not capture a charge.

Auth: X-Throttle-PM-Token header

Most integrations don't call this directly
The <PaymentMethods> React component (see React package below) calls this endpoint and mounts the embed for you — you never handle the raw payload. Reach for the endpoint below only if you are building a fully custom embed; the provider-specific fields it returns are consumed by the widget, not by your code.
Browser — mount add-card embed (custom integrations)
// Browser — get an add-only vault embed token so the buyer can add a new card
const res = await fetch('https://api.usethrottle.dev/api/v1/me/payment-methods/embed-token', {
  method: 'POST',
  headers: { 'X-Throttle-PM-Token': token },
});
const { data: embedPayload } = await res.json();
// Mount the payment embed with the returned payload
throttleEmbed.mount('#add-card-container', {
  embedToken: embedPayload.embedToken,
  checkoutSessionId: embedPayload.checkoutSessionId,
  processorInstanceId: embedPayload.processorInstanceId,
  environment: embedPayload.environment,
  amount: embedPayload.amount,
  currency: embedPayload.currency,
});
Response — 200 OK
{
  "data": {
    "embedToken": "eyJhbGci...",
    "checkoutSessionId": "cs_xyz",
    "amount": 0,
    "currency": "USD",
    "processorInstanceId": "spider-store",
    "merchantAccountId": "acct_abc",
    "environment": "sandbox",
    "hostedUrl": "https://checkout.usethrottle.dev/...",
    "embedUrl": "https://embed.gr4vy.com/..."
  }
}

The SavedCard shape

Every card management endpoint returns (or operates on) a SavedCard object:

Field Type Description
id string Payment method ID (cpm_*).
methodType string Always "card" in v1.
cardBrand string Issuer brand, e.g. "visa", "mastercard", "amex".
cardLastFour string Last 4 digits of the card number.
cardExpMonth number Card expiry month (1–12).
cardExpYear number Card expiry year (4-digit).
isDefault boolean Whether this is the customer's default payment method. Only one card per customer can be the default at a time.

Error reference

Status Code When it fires How to handle
401 unauthorized The X-Throttle-PM-Token is missing, malformed, or expired. Ask your backend to mint a fresh token and retry.
403 forbidden The token was used on a route outside the /api/v1/me/payment-methods surface (e.g. a merchant CRUD route or the mint endpoint). PM tokens are confined to the /me surface. Use a secret key for merchant routes.
404 not_found The card ID does not exist, was already removed, or belongs to a different customer. Refresh the list and update the UI.
409 cannot_remove_default Removing the default card while the customer has an active subscription attached to it. Prompt the buyer to set another card as the default ( PATCH …/:otherId) then retry the delete.

Security notes

  • PM tokens are short-lived JWTs signed with PAYMENT_METHODS_TOKEN_SECRET. Treat them like session tokens — pass them in-memory or as HttpOnly cookies, not in local storage.
  • A PM token grants access to only the cards owned by the one customer it was minted for. There is no way to escalate to another customer or to call any other Throttle API with it.
  • The embed-token route returns an add-only vault session. The resulting embed cannot initiate a charge; it only stores a new card. The newly-vaulted card will appear in the next GET /api/v1/me/payment-methods call after the embed completes.
  • CORS for the /me/payment-methods* routes is governed by the application's allowedOrigins setting, the same list that protects the checkout embed.

Cards must be attached through the embed

Card entry goes through the hosted embed — this page's client-token flow, or hosted checkout. A card attached by any other route saves successfully, appears in the customer's saved-card list, and then fails at charge time with payment_method_not_chargeable.

Nothing warns you at attach time, so the failure surfaces at the first renewal rather than during integration. The tokenise endpoint that accepts a raw PAN is internal tooling, is hidden from the public API surface, and was removed from the client in 2.10.0.

Using the embed is also what keeps card data out of your DOM and out of your PCI scope: the provider-hosted iframe means a PAN never reaches your servers.

React package

@usethrottle/payment-methods ships a pre-built card-management component, a headless React hook, and a framework-agnostic client — all backed by the routes above. It mirrors the patterns established by @usethrottle/subscriptions and @usethrottle/invoices .

Install:

Install
npm install @usethrottle/payment-methods

Step 1 — backend: mint a client token

Call mintClientToken from @usethrottle/payment-methods/server using your secret key. This is the only server-side step; the returned token is passed to the browser.

Backend — mintClientToken
// app/api/pm-token/route.ts  (Next.js App Router)
import { mintClientToken } from '@usethrottle/payment-methods/server';
export async function POST(request: Request) {
  const user = await auth();
  if (!user) return new Response(null, { status: 401 });
  const customerId = await resolveThrottleCustomerId(user.id); // cus_*
  const { token, expiresAt } = await mintClientToken({
    apiKey: process.env.THROTTLE_SECRET_KEY!,
    customerId,
  });
  return Response.json({ token, expiresAt });
}

Step 2 — frontend: render the component

Pass the token to <PaymentMethods clientToken={token} />. Because the embedded card widget accesses browser globals at module evaluation time, always render inside a 'use client' component and wrap with dynamic(…, { ssr: false }) in Next.js.

Frontend — <PaymentMethods />
// components/Wallet.tsx
'use client';
import dynamic from 'next/dynamic';
// SSR guard: the embedded card widget touches window at module evaluation time.
// Always wrap <PaymentMethods> with dynamic({ ssr: false }) in Next.js.
const PaymentMethods = dynamic(
  () => import('@usethrottle/payment-methods').then((m) => m.PaymentMethods),
  { ssr: false },
);
export function Wallet({ clientToken }: { clientToken: string }) {
  return (
    <PaymentMethods
      clientToken={clientToken}
      theme={{ colorPrimary: '#1D56E8', borderRadius: '8px' }}
      onChange={(cards) => console.log('cards updated', cards)}
    />
  );
}
SSR / Next.js
The embedded card widget touches window on import. Wrap <PaymentMethods> with dynamic(() => import(...), { ssr: false }) at every Next.js route that server-renders. The component lazy-loads the embed internally, so a plain 'use client' boundary alone is sufficient for non-SSR routes.