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.
@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.
- Backend mints a client token. Call
POST /api/v1/payment-methods/client-tokenwith yoursk_*secret key and the customer's ThrottlecustomerId. Throttle returns a short-lived JWT. - Browser calls the /me routes. All
/api/v1/me/payment-methods*routes accept the token in theX-Throttle-PM-Tokenheader. 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 embedEndpoint 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.
// 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 });
}{
"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 — 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();{
"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 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 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 active409 cannot_remove_default. Prompt the buyer to set a different card as the default first, then retry the delete.// 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 — 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,
});{
"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:
| 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 asHttpOnlycookies, 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-methodscall after the embed completes. - CORS for the
/me/payment-methods*routes is governed by the application'sallowedOriginssetting, 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:
npm install @usethrottle/payment-methodsStep 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.
// 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.
// 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)}
/>
);
}@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.