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.

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 Gr4vy add-only 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.

FieldTypeRequiredDescription
customerIdstringYesThe 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

FieldTypeRequiredDescription
isDefaultbooleanYesMust 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 a Gr4vy 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

Browser — mount add-card embed
// 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 Gr4vy embed with the returned payload
throttleEmbed.mount('#add-card-container', {
  embedToken: embedPayload.embedToken,
  checkoutSessionId: embedPayload.checkoutSessionId,
  gr4vyId: embedPayload.gr4vyId,
  environment: embedPayload.environment,
  amount: embedPayload.amount,
  currency: embedPayload.currency,
});
Response — 200 OK
{
  "data": {
    "embedToken": "eyJhbGci...",
    "checkoutSessionId": "cs_xyz",
    "amount": 0,
    "currency": "USD",
    "gr4vyId": "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:

FieldTypeDescription
idstringPayment method ID (cpm_*).
methodTypestringAlways "card" in v1.
cardBrandstringIssuer brand, e.g. "visa", "mastercard", "amex".
cardLastFourstringLast 4 digits of the card number.
cardExpMonthnumberCard expiry month (1–12).
cardExpYearnumberCard expiry year (4-digit).
isDefaultbooleanWhether this is the customer's default payment method. Only one card per customer can be the default at a time.

Error reference

StatusCodeWhen it firesHow to handle
401unauthorizedThe X-Throttle-PM-Token is missing, malformed, or expired.Ask your backend to mint a fresh token and retry.
403forbiddenThe 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.
404not_foundThe card ID does not exist, was already removed, or belongs to a different customer.Refresh the list and update the UI.
409cannot_remove_defaultRemoving 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 Gr4vy 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.

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 @gr4vy/embed-react 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: @gr4vy/embed-react 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
@gr4vy/embed-react 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.