@usethrottle/payment-methods
v0.1.7
Published
Drop-in saved-card wallet (list, add, set default, remove) for Throttle storefronts — headless core + React component.
Downloads
1,156
Readme
@usethrottle/payment-methods
Drop-in saved-card wallet for Throttle storefronts. Headless core + React component + server helpers.
Install
npm install @usethrottle/payment-methods
# or
pnpm add @usethrottle/payment-methodsReact and react-dom are peer dependencies (>=18).
How it works
- Backend calls
mintClientToken(from@usethrottle/payment-methods/server) using your secret key. It returns a short-lived JWT scoped to one customer. - Frontend passes that token to
<PaymentMethods clientToken={token} />(or the headlesscreatePaymentMethods/usePaymentMethods). The token authorises all card management operations — your secret key never reaches the browser.
Your Backend ──POST /api/v1/payment-methods/client-token──► Throttle API
◄── { token, expiresAt } ──────────────────────
Your Backend ──{ token }──────────────────────────────────► Browser
Browser ──<PaymentMethods clientToken={token} />──────► wallet UIBackend: mint a client token
// 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 getAuthUser(request); // your own 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,
// baseUrl?: 'https://api.usethrottle.dev' (default)
// environmentId?: 'env_...' (omit to use key default)
});
return Response.json({ token, expiresAt });
}mintClientToken options:
| Option | Type | Required | Description |
|-----------------|--------|----------|----------------------------------------------------------|
| apiKey | string | yes | Your sk_* secret key. |
| customerId | string | yes | Throttle customer ID (cus_*). |
| baseUrl | string | no | API base URL (defaults to https://api.usethrottle.dev).|
| environmentId | string | no | Override the environment derived from the key. |
Returns { token: string; expiresAt: string }.
Frontend: React component
// components/Wallet.tsx
'use client';
import dynamic from 'next/dynamic';
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',
colorText: '#111827',
colorBorder: '#E5E7EB',
borderRadius: '8px',
}}
onChange={(cards) => console.log('cards updated', cards)}
/>
);
}<PaymentMethods> props
| Prop | Type | Required | Description |
|---------------|-------------------------|----------|------------------------------------------------------------------------|
| clientToken | string | yes | Short-lived token from mintClientToken. |
| baseUrl | string | no | API base URL override. |
| theme | PaymentMethodsTheme | no | Colour + radius tokens (see table below). |
| onChange | (cards: SavedCard[]) => void | no | Fires after every list change (initial load, set default, remove, add card). |
PaymentMethodsTheme tokens
| Token | Default | Description |
|-----------------|-------------|-------------------------|
| colorPrimary | #1D56E8 | Accent / button colour. |
| colorText | #111827 | Body text colour. |
| colorBorder | #E5E7EB | Card border colour. |
| borderRadius | 8px | Corner radius. |
SSR note
@gr4vy/embed-react uses browser globals. Always render <PaymentMethods> in a client component ('use client') and wrap it with dynamic(..., { ssr: false }) at any route that server-renders. The component itself lazy-loads the Gr4vy embed internally, so it is safe inside a client component; the dynamic guard prevents the module from being evaluated during SSR.
Frontend: headless hook
'use client';
import { usePaymentMethods } from '@usethrottle/payment-methods';
export function CardList({ clientToken }: { clientToken: string }) {
const { cards, isLoading, error, setDefault, remove, addCard } =
usePaymentMethods({ clientToken });
if (isLoading) return <p>Loading...</p>;
if (error) return <p style={{ color: 'red' }}>{error.message}</p>;
return (
<ul>
{cards.map((card) => (
<li key={card.id}>
{card.cardBrand} .... {card.cardLastFour}
{!card.isDefault && (
<>
<button onClick={() => setDefault(card.id)}>Make default</button>
<button onClick={() => remove(card.id)}>Remove</button>
</>
)}
</li>
))}
<li>
<button onClick={() => addCard().then((payload) => console.log(payload)).catch(console.error)}>
+ Add card
</button>
</li>
</ul>
);
}usePaymentMethods returns:
| Key | Type | Description |
|-------------|---------------------------------------|---------------------------------------------------------------------|
| cards | SavedCard[] | Current list of saved cards. |
| isLoading | boolean | True while the initial list is fetching. |
| error | ThrottlePaymentMethodsError \| null | Last error, or null. |
| setDefault| (id: string) => Promise<void> | Promote a card to default; refetches list on success. |
| remove | (id: string) => Promise<void> | Remove a card; refetches list on success. |
| addCard | () => Promise<EmbedPayload> | Fetches a Gr4vy add-only embed payload for the card-add UI. Unlike setDefault/remove, it does not route failures into error — await it in a try/catch (the <PaymentMethods> component does this for you). |
| refetch | () => Promise<void> | Manually re-fetches the list. |
Frontend: headless client (no React)
import { createPaymentMethods } from '@usethrottle/payment-methods';
const client = createPaymentMethods({ clientToken: token });
const cards = await client.list();
await client.setDefault('cpm_...');
await client.remove('cpm_...');
const embedPayload = await client.startAddCard();createPaymentMethods({ clientToken, baseUrl? }) returns { list, setDefault, remove, startAddCard }.
Backend: server CRUD
Use createServerPaymentMethods when your backend needs to manage a customer's cards directly (e.g. admin tooling), without a browser session.
import { createServerPaymentMethods } from '@usethrottle/payment-methods/server';
const server = createServerPaymentMethods({
apiKey: process.env.THROTTLE_SECRET_KEY!,
customerId: 'cus_...',
});
const cards = await server.list();
await server.setDefault('cpm_...');
await server.remove('cpm_...');
// Note: startAddCard is not available on the server client —
// the Gr4vy embed flow is inherently browser-side.createServerPaymentMethods options match mintClientToken (apiKey, customerId, baseUrl?, environmentId?).
Error handling
All errors thrown by this package are instances of ThrottlePaymentMethodsError (extends ThrottleError):
import { isThrottleError, ThrottlePaymentMethodsError } from '@usethrottle/payment-methods';
try {
await client.remove(cardId);
} catch (err) {
if (err instanceof ThrottlePaymentMethodsError) {
if (err.code === 'cannot_remove_default') {
// 409 — buyer must set another card as default first
alert('Set another card as default before removing this one.');
} else {
console.error(err.code, err.message, err.statusCode);
}
}
}
// isThrottleError also works (catches both ThrottleError and ThrottlePaymentMethodsError):
if (isThrottleError(err)) { /* … */ }Common error codes:
| Status | Code | When |
|--------|-------------------------|----------------------------------------------------------------------|
| 401 | unauthorized | Token missing, malformed, or expired. Mint a fresh token and retry. |
| 403 | forbidden | PM token used outside the /me/payment-methods surface. |
| 404 | not_found | Card ID not found or already removed. |
| 409 | cannot_remove_default | Removing the default card while an active subscription is attached. |
SavedCard shape
interface SavedCard {
id: string; // "cpm_*"
methodType: string; // "card" in v1
cardBrand: string | null; // "visa" | "mastercard" | "amex" | ...
cardLastFour: string | null; // "4242"
cardExpMonth: number | null; // 1-12
cardExpYear: number | null; // 4-digit year
isDefault: boolean;
}