Add crypto to your store without building crypto.
One React component for Stripe and Solana USDC. Uses your existing products. No deposit wallets. No monitoring. No custom crypto backend.
One component, two payment rails. Stripe for cards, Solana USDC for wallets. No second checkout.
Cedros Pay connects traditional payments (Stripe) with crypto (Solana x402) using the product IDs you already have. No deposit wallets to manage. No wallet infrastructure to secure. No custody risk.
Adding crypto to your store traditionally requires:
- Creating deposit wallets per user
- Monitoring deposits 24/7
- Sweeping funds to your merchant wallet
- Storing wallet state in your database
- Building Stripe separately
- Maintaining two systems
<CedrosPay
resource="your-product-id" // from your DB
onPaymentSuccess={(txId) => unlockContent(txId)}
/>x402 makes Solana payments stateless. The client includes a signed transaction with the request. Your backend verifies it on-chain and unlocks the resource.
Flow:
Traditional:
User β Deposit Wallet (you manage) β Monitor β Sweep β Merchant
β
Store state in DB
β
Custody risk
x402:
User β Sign Transaction β Your Backend β Verify On-Chain β Merchant (Direct)
Three key benefits:
- No deposit wallets
- No sweeping funds
- No payment state in your DB
Stripe for cards, Solana USDC for wallets. No second checkout.
<CedrosPay resource="product-1" />
// Both payment methods workPass your DB ID. No new schema.
<CedrosPay resource="existing-product-123" />
// resource = your database primary keyCarts, coupons, refunds, metadata.
<CedrosPay
items={[{ resource: "item-1", quantity: 2 }]}
couponCode="LAUNCH50"
/>No wallet: card only. Wallet: card and crypto.
// User without wallet sees:
[Pay with Card] button
// User with Phantom sees:
[Pay with Card] [Pay with Crypto]x402 over HTTP; agents pay per request.
GET /api/premium-data
X-PAYMENT: <signed-transaction>
# Agent gets data instantlyReact UI + Go backend. Open API.
<CedrosPay resource="item" wallets={customWallets} renderModal={CustomModal} />Additional Features:
- π Open source β MIT-licensed and extensible
- π Stateless & secure β No user accounts or deposit addresses required
- π§± Minimal integration β Middleware or proxy for Go APIs
If you can wrap a provider, you can ship dual payments.
Option 1: Stripe + Crypto (Full Features)
npm install @cedros/pay-react \
@solana/web3.js \
@solana/spl-token \
@solana/wallet-adapter-base \
@solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui \
@solana/wallet-adapter-walletsOption 2: Stripe Only (Smaller Bundle - ~75KB)
npm install @cedros/pay-reactUse the stripe-only entry point to get a dramatically smaller bundle:
import { CedrosProvider, StripeButton } from "@cedros/pay-react/stripe-only";
import "@cedros/pay-react/style.css";
function App() {
return (
<CedrosProvider
config={{
stripePublicKey: "pk_test_...",
serverUrl: window.location.origin,
solanaCluster: "mainnet-beta",
}}
>
<StripeButton resource="item-id" />
</CedrosProvider>
);
}For zero-build prototyping or simple sites, you can import directly from a CDN:
<!-- Styles -->
<link
rel="stylesheet"
href="https://unpkg.com/@cedros/pay-react@0.1.0/dist/style.css"
/>
<!-- Library (ESM) -->
<script type="module">
import {
CedrosProvider,
StripeButton,
} from "https://unpkg.com/@cedros/pay-react@0.1.0/dist/index.mjs";
// Your code here
</script>CDN Options:
- unpkg.com - Fast, reliable, global CDN
- jsdelivr.com - Multi-CDN with fallback
Performance Notes:
- CDN providers (unpkg, jsdelivr) automatically serve with immutable cache headers (
Cache-Control: public, max-age=31536000, immutable) - For self-hosted deployments, set the same cache headers on
/dist/*assets for optimal performance - Pin to specific version (
@0.1.0) in production to ensure stability
Option 3: Crypto Only
If you only need Solana crypto payments:
npm install @cedros/pay-react \
@solana/web3.js \
@solana/spl-token \
@solana/wallet-adapter-base \
@solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui \
@solana/wallet-adapter-walletsThen use the crypto-only entry point:
import { CedrosProvider, CryptoButton } from "@cedros/pay-react/crypto-only";
import "@cedros/pay-react/style.css";
function App() {
return (
<CedrosProvider
config={{
stripePublicKey: "pk_test_...", // Required even for crypto-only (use a placeholder)
serverUrl: window.location.origin,
solanaCluster: "mainnet-beta",
}}
>
<CryptoButton resource="item-id" />
</CedrosProvider>
);
}Note: Even when using the crypto-only entry point, stripePublicKey is still required in the config (use a test/placeholder key if you don't have Stripe integration). This is a known limitation that will be addressed in a future version.
Using the full bundle but hiding crypto button:
<CedrosPay resource="item-id" display={{ showCrypto: false }} />Wrap your app with credentials + cluster:
import { CedrosProvider } from "@cedros/pay-react";
import "@cedros/pay-react/style.css";
function App() {
return (
<CedrosProvider
config={{
stripePublicKey: "pk_test_...",
serverUrl: "https://your-api.com",
solanaCluster: "mainnet-beta",
}}
>
<YourApp />
</CedrosProvider>
);
}On success β fulfill order:
import { CedrosPay } from "@cedros/pay-react";
function Checkout() {
return (
<CedrosPay
resource="your-product-id"
callbacks={{
onPaymentSuccess: (result) => {
// Unlock content / fulfill order
unlockContent(result.transactionId);
},
}}
/>
);
}Backend options: Use the Go server, or implement the open API.
Links:
Cross-Domain Backend (Optional):
If your backend is on a different domain (e.g., api.example.com while your frontend is on example.com), explicitly set serverUrl:
<CedrosProvider
config={{
stripePublicKey: "pk_test_...",
serverUrl: "https://api.example.com", // Explicit URL for cross-domain
solanaCluster: "mainnet-beta",
}}
>
{/* ... */}
</CedrosProvider>Your backend must implement the Cedros Pay API endpoints:
go get github.com/cedros-pay/serverRequired Endpoints (v2.0.0+):
GET /cedros-health- Health check and route discoveryPOST /paywall/v1/quote- x402 payment quote (resource ID in body)POST /paywall/v1/verify- Payment verification (resource ID in X-PAYMENT header)POST /paywall/v1/stripe-session- Create Stripe checkout (single item)GET /paywall/v1/stripe-session/verify- Verify Stripe payment session (security-critical)POST /paywall/v1/cart/checkout- Create Stripe checkout (cart)POST /paywall/v1/cart/quote- Get x402 quote for cart itemsPOST /paywall/v1/gasless-transaction- Build gasless transaction (optional)POST /paywall/v1/nonce- Generate nonce for admin authenticationPOST /paywall/v1/refunds/request- Create refund request (requires signature from original payer or admin)POST /paywall/v1/refunds/pending- Get all pending refunds (admin-only, requires nonce)POST /paywall/v1/refunds/approve- Get fresh quote for pending refund (admin-only)POST /paywall/v1/refunds/deny- Deny pending refund (admin-only)
Example - Quote Request:
POST /paywall/v1/quote
Content-Type: application/json
{
"resource": "premium-article",
"couponCode": "SAVE20" # optional
}
# Response: 402 Payment Required with x402 quoteExample - Payment Verification:
POST /paywall/v1/verify
X-PAYMENT: <base64-encoded-payment-proof>
# Payment proof includes resource ID and type
# No resource IDs in URL path (security improvement)Example - Refund Request:
POST /paywall/v1/refunds/request
Content-Type: application/json
X-Signature: <base64-encoded-signature>
X-Message: request-refund:<transaction-signature>
X-Signer: <wallet-address>
{
"originalPurchaseId": "5jHxP...2QvK", // Original transaction signature
"recipientWallet": "9xQeW...Yhq",
"amount": 10.5,
"token": "USDC",
"reason": "Customer requested refund"
}
# Signer must be the original payer OR admin wallet
# Recipient wallet must match the payer from original transaction
# Only one refund allowed per transaction signatureExample - Get Pending Refunds (Admin - Nonce Required):
# Step 1: Generate nonce
POST /paywall/v1/nonce
Content-Type: application/json
{
"purpose": "list-pending-refunds"
}
# Response: { "nonce": "abc123...", "expiresAt": 1234567890 }
# Step 2: Fetch pending refunds with nonce
POST /paywall/v1/refunds/pending
Content-Type: application/json
X-Signature: <base64-encoded-signature>
X-Message: list-pending-refunds:<nonce>
X-Signer: <admin-wallet-address>
# Returns array of pending refund requests
# Response: [{ refundId, originalPurchaseId, recipientWallet, amount, token, reason, ... }]See Backend Integration and @backend-migration-resource-leakage.md for complete API reference and migration guide.
Content-Security-Policy:
script-src 'self' https://js.stripe.com;
connect-src 'self' https://api.stripe.com https://*.stripe.com https://api.mainnet-beta.solana.com https://*.solana.com;
frame-src https://js.stripe.com https://checkout.stripe.com;Breakdown:
script-src- Allows Stripe.js to load and executeconnect-src- Allows API calls to Stripe and Solana RPC endpointsframe-src- Allows Stripe Checkout iframe to load
Next.js (App Router)
// next.config.js
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: [
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com",
"connect-src 'self' https://api.stripe.com https://*.stripe.com https://api.mainnet-beta.solana.com https://*.solana.com https://*.helius-rpc.com https://*.quicknode.pro",
"frame-src https://js.stripe.com https://checkout.stripe.com",
].join("; "),
},
],
},
];
},
};Next.js (Pages Router with Middleware)
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set(
"Content-Security-Policy",
[
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com",
"connect-src 'self' https://api.stripe.com https://*.stripe.com https://api.mainnet-beta.solana.com https://*.solana.com",
"frame-src https://js.stripe.com https://checkout.stripe.com",
].join("; ")
);
return response;
}Vite (Development)
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
server: {
headers: {
"Content-Security-Policy": [
"script-src 'self' 'unsafe-inline' https://js.stripe.com",
"connect-src 'self' https://api.stripe.com https://*.stripe.com https://api.mainnet-beta.solana.com https://*.solana.com",
"frame-src https://js.stripe.com https://checkout.stripe.com",
].join("; "),
},
},
});Nginx
# nginx.conf
location / {
add_header Content-Security-Policy "script-src 'self' https://js.stripe.com; connect-src 'self' https://api.stripe.com https://*.stripe.com https://api.mainnet-beta.solana.com https://*.solana.com; frame-src https://js.stripe.com https://checkout.stripe.com;" always;
}Express.js
// server.js
const helmet = require("helmet");
app.use(
helmet.contentSecurityPolicy({
directives: {
scriptSrc: ["'self'", "https://js.stripe.com"],
connectSrc: [
"'self'",
"https://api.stripe.com",
"https://*.stripe.com",
"https://api.mainnet-beta.solana.com",
"https://*.solana.com",
],
frameSrc: ["https://js.stripe.com", "https://checkout.stripe.com"],
},
})
);HTML Meta Tag (Not Recommended)
<!-- Use server headers instead when possible -->
<meta
http-equiv="Content-Security-Policy"
content="script-src 'self' https://js.stripe.com; connect-src 'self' https://api.stripe.com https://*.stripe.com https://api.mainnet-beta.solana.com https://*.solana.com; frame-src https://js.stripe.com https://checkout.stripe.com;"
/>If you're using a custom Solana RPC provider (Helius, QuickNode, etc.), add their domains to connect-src:
connect-src 'self'
https://api.stripe.com
https://*.stripe.com
https://mainnet.helius-rpc.com
https://*.quicknode.pro
https://rpc.ankr.com;For development against Solana devnet or testnet:
connect-src 'self'
https://api.stripe.com
https://*.stripe.com
https://api.devnet.solana.com
https://api.testnet.solana.com;Symptom: Stripe Checkout doesn't load or throws CORS errors
Refused to load the script 'https://js.stripe.com/v3/' because it violates the following Content Security Policy directive: "script-src 'self'"
Fix: Add https://js.stripe.com to script-src
Symptom: Solana RPC calls fail with network errors
Refused to connect to 'https://api.mainnet-beta.solana.com' because it violates the following Content Security Policy directive: "connect-src 'self'"
Fix: Add your Solana RPC endpoint to connect-src
Symptom: Stripe Checkout redirects fail or show blank page
Refused to display 'https://checkout.stripe.com' in a frame because it violates the following Content Security Policy directive: "frame-src 'self'"
Fix: Add https://checkout.stripe.com to frame-src
- Open browser DevTools β Console
- Look for CSP violation warnings (usually in red)
- Check the Network tab for blocked requests
- Add blocked domains to appropriate CSP directives
Chrome DevTools Example:
[Report Only] Refused to connect to 'https://api.stripe.com/v1/tokens'
because it violates the document's Content Security Policy.
β DO:
- Use server-side headers (not meta tags) for CSP
- Test CSP in staging before deploying to production
- Use wildcards sparingly (
*.stripe.comis okay,*is not) - Include your custom RPC provider domains
β DON'T:
- Use
'unsafe-inline'in production unless necessary - Block Stripe or Solana domains
- Forget to add
frame-srcfor Stripe Checkout - Use overly permissive directives like
* 'unsafe-eval'
generateCSP() helper to automatically generate correct CSP directives for your configuration. This prevents common misconfigurations that break payment widgets.
import { generateCSP, RPC_PROVIDERS } from "@cedros/pay-react";
// Generate CSP for production with custom RPC
const csp = generateCSP({
solanaCluster: "mainnet-beta",
solanaEndpoint: "https://mainnet.helius-rpc.com",
allowUnsafeScripts: true, // Required for Next.js
});
// Use in your framework
response.setHeader("Content-Security-Policy", csp);interface CSPConfig {
solanaCluster?: "mainnet-beta" | "devnet" | "testnet";
solanaEndpoint?: string; // Custom RPC URL
customRpcProviders?: string[]; // Additional RPC providers
allowUnsafeScripts?: boolean; // For Next.js, etc.
additionalScriptSrc?: string[];
additionalConnectSrc?: string[];
additionalFrameSrc?: string[];
includeStripe?: boolean; // Set false for crypto-only
}Next.js App Router:
// next.config.js
import { generateCSP } from "@cedros/pay-react";
const csp = generateCSP({
solanaCluster: "mainnet-beta",
solanaEndpoint: process.env.SOLANA_RPC_URL,
allowUnsafeScripts: true,
});
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [{ key: "Content-Security-Policy", value: csp }],
},
];
},
};
export default nextConfig;Express with Helmet:
import { generateCSP } from "@cedros/pay-react";
import helmet from "helmet";
const cspDirectives = generateCSP(
{
solanaCluster: "mainnet-beta",
solanaEndpoint: process.env.SOLANA_RPC_URL,
},
"helmet" // Returns object format for helmet
);
app.use(helmet.contentSecurityPolicy({ directives: cspDirectives }));Vite Development:
// vite.config.ts
import { defineConfig } from "vite";
import { generateCSP } from "@cedros/pay-react";
const csp = generateCSP({
solanaCluster: "devnet",
allowUnsafeScripts: true,
});
export default defineConfig({
server: {
headers: {
"Content-Security-Policy": csp,
},
},
});Use presets for common scenarios:
import { generateCSP, CSP_PRESETS } from "@cedros/pay-react";
// Production mainnet with custom RPC
const csp1 = generateCSP(
CSP_PRESETS.MAINNET_CUSTOM_RPC("https://mainnet.helius-rpc.com")
);
// Next.js with mainnet
const csp2 = generateCSP(
CSP_PRESETS.MAINNET_NEXTJS("https://mainnet.helius-rpc.com")
);
// Devnet testing
const csp3 = generateCSP(CSP_PRESETS.DEVNET());
// Crypto-only (no Stripe)
const csp4 = generateCSP(CSP_PRESETS.CRYPTO_ONLY());
// Stripe-only (no Solana)
const csp5 = generateCSP(CSP_PRESETS.STRIPE_ONLY());import { RPC_PROVIDERS } from "@cedros/pay-react";
const csp = generateCSP({
customRpcProviders: [
RPC_PROVIDERS.HELIUS, // https://*.helius-rpc.com
RPC_PROVIDERS.QUICKNODE, // https://*.quicknode.pro
RPC_PROVIDERS.FLUX, // https://*.fluxrpc.com
RPC_PROVIDERS.TRITON, // https://*.rpcpool.com
],
});The helper supports multiple output formats:
// HTTP header format (default)
const header = generateCSP(config, "header");
// "script-src 'self' https://js.stripe.com; connect-src ..."
// HTML meta tag format
const meta = generateCSP(config, "meta");
// Next.js config format
const nextjs = generateCSP(config, "nextjs");
// Express helmet format (object)
const helmet = generateCSP(config, "helmet");
// { scriptSrc: [...], connectSrc: [...], frameSrc: [...] }
// Nginx config format
const nginx = generateCSP(config, "nginx");
// Raw directives object
const directives = generateCSP(config, "directives");β Prevents common errors:
- Forgetting Solana RPC endpoints
- Missing Stripe iframe domains
- Wrong cluster URLs (devnet vs mainnet)
β Type-safe configuration:
- TypeScript autocomplete for all options
- Validates cluster names
- Catches typos at compile time
β Framework-agnostic:
- Works with Next.js, Express, Vite, Nginx, etc.
- Multiple output formats
- No dependencies
Why SRI is NOT used:
- Stripe updates frequently - Security patches and bug fixes are pushed without URL changes
- SRI breaks automatic updates - Hardcoded hashes prevent receiving critical security fixes
- Stripe's official recommendation - Stripe explicitly advises against using SRI
- Alternative protection - Content Security Policy (CSP) provides the security layer
From Stripe's documentation:
"We do not recommend using Subresource Integrity (SRI) with Stripe.js. Stripe.js is served from a highly-available CDN, and we regularly update the library to address security issues and improve functionality. Using SRI would prevent you from receiving these automatic updates."
How Cedros Pay Protects Against CDN Compromise:
-
Content Security Policy (CSP)
Content-Security-Policy: script-src 'self' https://js.stripe.com- Prevents loading scripts from unauthorized domains
- Blocks inline scripts and eval()
- Works with Stripe's automatic updates
-
Package Integrity via npm
{ "dependencies": { "@stripe/stripe-js": "^2.4.0" } }package-lock.jsoncontains integrity hashes for npm packages- npm verifies package integrity on installation
- Protects against tampering with the loader
-
HTTPS Enforcement
- Stripe.js is loaded over HTTPS only
- Modern browsers enforce secure connections
- Certificate pinning via browser trust store
-
Version Pinning (optional)
{ "dependencies": { "@stripe/stripe-js": "2.4.0" // Exact version (no caret) } }- Prevents unexpected updates
- Review changelog before upgrading
- Balance security updates vs. stability
Recommended Security Checklist:
β DO:
- Use CSP headers with
script-src https://js.stripe.com - Keep
@stripe/stripe-jsupdated for security patches - Use HTTPS for all connections
- Enable npm package auditing (
npm audit) - Review Stripe's changelog before major updates
- Monitor Stripe's security advisories
β DON'T:
- Add SRI hashes to Stripe.js (breaks updates)
- Allow
script-src *in CSP (too permissive) - Use outdated versions of @stripe/stripe-js
- Load Stripe.js from third-party CDNs
- Disable HTTPS enforcement
Alternative: Self-Hosting Stripe.js (NOT RECOMMENDED)
While technically possible to self-host Stripe.js with SRI, Stripe strongly discourages this:
- β Miss critical security updates
- β Break PCI DSS compliance requirements
- β Lose Stripe's CDN performance benefits
- β Violate Stripe's Terms of Service
For maximum security, follow Stripe's recommendations and use CSP instead of SRI.
Cedros Pay supports multiple languages with automatic browser locale detection and zero-configuration setup.
Currently available (auto-detected from src/i18n/translations/ folder):
- πΊπΈ English (en) - Default
- πͺπΈ Spanish (es)
Automatic (recommended):
import { useTranslation } from "@cedros/pay-react";
function PaymentButton() {
const { t } = useTranslation(); // Auto-detects browser language
return (
<button>{t("ui.pay_with_card")}</button> // "Pay with Card" or "Pagar con Tarjeta"
);
}Manual locale override:
function SpanishOnlyButton() {
const { t } = useTranslation("es"); // Force Spanish
return <button>{t("ui.pay_with_card")}</button>; // Always "Pagar con Tarjeta"
}Error messages (automatic):
import { PaymentError } from "@cedros/pay-react";
// Errors are automatically localized based on user's browser language
error.getUserMessage(); // Returns localized message + action
error.getShortMessage(); // Returns just the message (no action)
error.getAction(); // Returns just the action guidanceUI Labels:
ui.pay_with_card- "Pay with Card"ui.pay_with_crypto- "Pay with USDC"ui.connect_wallet- "Connect Wallet"ui.processing- "Processing..."ui.loading- "Loading..."ui.close- "Close"ui.cancel- "Cancel"ui.confirm- "Confirm"ui.retry- "Try Again"ui.contact_support- "Contact Support"
Error Messages:
t("errors.insufficient_funds_token.message"); // "Insufficient balance in your wallet"
t("errors.insufficient_funds_token.action"); // "Add more funds to your wallet and try again."Wallet Messages:
t("wallet.no_wallet_detected"); // "No Solana wallet detected"
t("wallet.install_wallet"); // "Please install a Solana wallet..."
t("wallet.wallet_connection_failed"); // "Failed to connect wallet"-
Create translation file in
src/i18n/translations/{locale}.json:// src/i18n/translations/fr.json { "locale": "fr", "ui": { "pay_with_card": "Payer par Carte", "pay_with_crypto": "Payer avec USDC", ... }, "errors": { ... } }
-
That's it! The system automatically detects new files and loads them.
See src/i18n/TRANSLATION_INSTRUCTIONS.md for detailed translation guidelines.
The i18n system:
- β Auto-detects available languages from file system
- β Only loads the language the user needs (tree-shakeable)
- β Falls back to English if translation missing
- β Zero configuration required
Cedros Pay uses semantic versioning for TypeScript types to prevent breaking changes from affecting your code.
All types are exported in versioned namespaces (v1, v2, etc.):
// Recommended: Use top-level exports (always points to current stable version)
import { X402Requirement, PaymentResult } from "@cedros/pay-react";
// Explicit version (locks to v1, won't break on v2 release)
import { v1 } from "@cedros/pay-react";
const requirement: v1.X402Requirement = {
/* ... */
};
// Future: When v2 is released, you can migrate gradually
import { v2 } from "@cedros/pay-react";
const newRequirement: v2.X402Requirement = {
/* ... */
};If we need to change X402Requirement.maxAmountRequired from string to bigint:
- v1 namespace remains unchanged - Your existing code keeps working
- v2 namespace is created with the new type
- Top-level exports point to v2 (with major version bump)
- You can migrate at your own pace:
// Your old code still works with v1
import { v1 } from "@cedros/pay-react";
const oldReq: v1.X402Requirement = { maxAmountRequired: "1000000" };
// New code uses v2
import { v2 } from "@cedros/pay-react";
const newReq: v2.X402Requirement = { maxAmountRequired: 1000000n };- v1 types are frozen - No breaking changes, ever
- Top-level exports may change across major versions
- Older versions remain available for backward compatibility
- Clear migration path when breaking changes are necessary
<CedrosPay
resource="article-123"
callbacks={{
onPaymentSuccess: (result) => unlockContent(result.transactionId),
}}
/><CedrosPay
items={[
{ resource: "product-1", quantity: 2 },
{ resource: "product-2", quantity: 1 },
]}
callbacks={{
onPaymentSuccess: (result) => processOrder(result.transactionId),
}}
/><CedrosPay
resource="premium-content"
checkout={{
couponCode: "LAUNCH50", // Pass from user input or auto-apply
}}
/>Coupon Stacking Supported! Unlimited auto-apply coupons can stack with 1 manual coupon code. Percentage discounts apply first (multiplicatively), then fixed discounts are subtracted.
Coupons are applied in two phases to provide clear pricing transparency:
-
Catalog-level coupons - Product-specific discounts shown on product pages
- Configured with
applies_at: catalogand specificproduct_ids - Example: "20% off this specific item"
- Discounted price shown immediately when viewing the product
- Configured with
-
Checkout-level coupons - Site-wide promotions applied at cart
- Configured with
applies_at: checkoutandscope: all - Example: "10% off your entire order"
- Applied after catalog discounts at checkout
- Configured with
Single Product Quote Response:
{
"crypto": {
"maxAmountRequired": "184000", // Actual amount to charge (atomic units)
"extra": {
"original_amount": "1.000000",
"discounted_amount": "0.184000",
"applied_coupons": "PRODUCT20,SITE10,CRYPTO5AUTO,FIXED5", // All applied
"catalog_coupons": "PRODUCT20", // Product-specific
"checkout_coupons": "SITE10,CRYPTO5AUTO,FIXED5", // Site-wide
"decimals": 6
}
}
}Cart Quote Response:
{
"totalAmount": 2.7661,
"metadata": {
"subtotal_after_catalog": "3.820000",
"discounted_amount": "2.766100",
"catalog_coupons": "PRODUCT20",
"checkout_coupons": "SITE10,CRYPTO5AUTO,FIXED5",
"coupon_codes": "PRODUCT20,SITE10,CRYPTO5AUTO,FIXED5"
},
"items": [
{
"resource": "item-1",
"priceAmount": 0.8, // After catalog discount
"originalPrice": 1.0,
"appliedCoupons": ["PRODUCT20"]
}
]
}Display Guidelines:
- Product pages: Show strikethrough original price with catalog discount
- Cart: Show catalog discounts on items, checkout discounts in summary
- Always use
maxAmountRequiredfor actual transactions -extrafields are display-only
Coupons are configured server-side with:
- Percentage or fixed amount discounts
- Expiration dates
- Usage limits
- Auto-apply functionality
- Payment method filtering (Stripe-only, x402-only, or both)
- Phase configuration (
applies_at: catalogorcheckout)
After a successful x402 payment, parse applied coupons from the settlement response:
import {
parseCouponCodes,
calculateDiscountPercentage,
} from "@cedros/pay-react";
// Parse applied coupons
const appliedCoupons = parseCouponCodes(settlement.metadata);
// ["SITE10", "CRYPTO5AUTO", "SAVE20"]
// Calculate total discount percentage
const discountPercent = calculateDiscountPercentage(
parseFloat(settlement.metadata.original_amount),
parseFloat(settlement.metadata.discounted_amount)
);<CedrosProvider
config={{
stripePublicKey: "pk_test_...",
serverUrl: window.location.origin,
solanaCluster: "mainnet-beta",
theme: "dark", // "light" or "dark"
themeOverrides: {
stripeBackground: "#6366f1",
cryptoBackground: "#0ea5e9",
buttonBorderRadius: "12px",
},
}}
>
{/* Your app */}
</CedrosProvider>For complete control over styling, use the unstyled prop to disable all default styles:
<CedrosProvider
config={{
stripePublicKey: "pk_test_...",
serverUrl: window.location.origin,
solanaCluster: "mainnet-beta",
unstyled: true, // Disables all default CSS classes and styles
}}
>
<CedrosPay
resource="item-id"
display={{ className: "my-custom-button-class" }}
/>
</CedrosProvider>Why use unstyled mode?
- Build custom design systems without fighting CSS specificity
- Use your own CSS framework (Tailwind, Material UI, etc.)
- Full control over component appearance and behavior
- No need to override or reset default styles
What gets disabled:
- All
cedros-theme__*CSS classes - Default inline styles from theme tokens
- Button styling (stripe/crypto gradients, hover effects)
- Error/success message styling
What you still get:
- All payment logic and wallet integration
- Event handlers and callbacks
- Component structure and behavior
- Props like
classNamefor your custom styling
| Prop | Type | Description |
|---|---|---|
stripePublicKey |
string |
Stripe publishable key (required) |
solanaCluster |
'mainnet-beta' | 'devnet' | 'testnet' |
Solana network (required) |
serverUrl |
string |
Backend API URL (defaults to current origin) |
theme |
'light' | 'dark' |
Theme mode (default: 'light') |
themeOverrides |
Partial<CedrosThemeTokens> |
Custom theme token overrides |
unstyled |
boolean |
Disable all default styles (default: false) |
solanaEndpoint |
string |
Custom Solana RPC endpoint |
tokenMint |
string |
SPL token mint address (default: USDC) - see Token Mint Validation |
dangerouslyAllowUnknownMint |
boolean |
Allow unknown token mints (default: false) - |
logLevel |
LogLevel |
Logging verbosity (default: LogLevel.WARN in production, LogLevel.DEBUG in development) - see Logging |
| Prop | Type | Description |
|---|---|---|
resource |
string |
Single resource ID (use this OR items) |
items |
CartItem[] |
Array of cart items (use this OR resource) |
checkout |
CheckoutOptions |
Customer email, coupons, redirects, metadata |
display |
DisplayOptions |
Labels, visibility (showCard, showCrypto), layout, className |
callbacks |
CallbackOptions |
onPaymentSuccess, onPaymentError, onPaymentAttempt |
advanced |
AdvancedOptions |
Custom wallets, autoDetectWallets, testPageUrl |
| Field | Type | Description |
|---|---|---|
customerEmail |
string |
Pre-fill email for Stripe checkout |
couponCode |
string |
Coupon code to apply |
successUrl |
string |
Stripe redirect URL on success |
cancelUrl |
string |
Stripe redirect URL on cancel |
metadata |
Record<string, string> |
Custom tracking data |
| Field | Type | Description |
|---|---|---|
cardLabel |
string |
Stripe button label |
cryptoLabel |
string |
Crypto button label |
showCard |
boolean |
Show Stripe button (default: true) |
showCrypto |
boolean |
Show crypto button (default: true) |
layout |
'vertical' | 'horizontal' |
Button layout (default: 'vertical') |
className |
string |
Custom CSS class |
| Field | Type | Description |
|---|---|---|
onPaymentSuccess |
(result: PaymentSuccessResult) => void |
Called on successful payment |
onPaymentError |
(error: PaymentErrorDetail) => void |
Called on payment error |
onPaymentAttempt |
(method: 'stripe' | 'crypto') => void |
Called when payment starts |
CRITICAL: Typos in token mint addresses result in payments being sent to the wrong token, causing permanent loss of funds.
Cedros Pay includes strict validation against known stablecoin addresses to prevent catastrophic misconfigurations. If you specify a tokenMint that doesn't match a known stablecoin, initialization will fail with an error.
| Symbol | Mint Address |
|---|---|
| USDC | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v |
| USDT | Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB |
| PYUSD | 2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo |
| CASH | CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH |
By default, unknown token mints throw an error:
<CedrosProvider
config={{
stripePublicKey: "pk_test_...",
serverUrl: window.location.origin,
solanaCluster: "mainnet-beta",
tokenMint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" // β
USDC - works
}}
>
{/* ... */}
</CedrosProvider>
// Using an unknown token mint throws an error:
<CedrosProvider
config={{
stripePublicKey: "pk_test_...",
serverUrl: window.location.origin,
solanaCluster: "devnet",
tokenMint: "CustomTokenMint123..." // β Throws error: SAFETY ERROR
}}
>
{/* ... */}
</CedrosProvider>Error Message:
SAFETY ERROR: Unrecognized token mint address in CedrosConfig.tokenMint
Provided: CustomTokenMint123...
This token mint does not match any known stablecoin addresses.
Using an unknown token mint can result in PERMANENT LOSS OF FUNDS if it's a typo.
Known stablecoin mints (mainnet-beta):
USDC: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
USDT: Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB
PYUSD: 2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo
CASH: CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH
If you are CERTAIN this is the correct mint address (custom token, testnet, or new stablecoin),
set dangerouslyAllowUnknownMint={true} in your CedrosProvider config.
For custom tokens, testnet tokens, or new stablecoins, you must explicitly opt-in:
<CedrosProvider
config={{
stripePublicKey: "pk_test_...",
serverUrl: window.location.origin,
solanaCluster: "devnet",
tokenMint: "CustomTokenMint123...", // Custom token
dangerouslyAllowUnknownMint: true, // β οΈ Explicit opt-in required
}}
>
{/* ... */}
</CedrosProvider>dangerouslyAllowUnknownMint if you have TRIPLE-CHECKED the mint address. A typo will result in permanent loss of funds.
Strict validation runs at three points to protect against fund loss:
- Config initialization - When
<CedrosProvider>mounts - Payment quote - When backend returns x402 quote with
assetfield - Runtime - When building Solana transactions
Best Practices:
- β Use known stablecoin mints in production (USDC, USDT, PYUSD, CASH)
- β
Triple-check any custom mint addresses before enabling
dangerouslyAllowUnknownMint - β Test thoroughly on devnet before deploying to mainnet
- β Never copy-paste mint addresses without verification
- β Never use
dangerouslyAllowUnknownMintunless absolutely necessary
Cedros Pay includes structured logging with configurable log levels to control verbosity and keep production logs clean.
import { LogLevel } from "@cedros/pay-react";
export enum LogLevel {
DEBUG = 0, // Detailed debug information (verbose)
INFO = 1, // Informational messages
WARN = 2, // Warnings and potentially problematic situations
ERROR = 3, // Error messages only
SILENT = 4, // No logging
}- Development:
LogLevel.DEBUG(show all logs) - Production:
LogLevel.WARN(warnings and errors only)
Control logging verbosity via the logLevel prop:
import { CedrosProvider, LogLevel } from '@cedros/pay-react';
// Production: Only show errors
<CedrosProvider
config={{
stripePublicKey: "pk_live_...",
serverUrl: window.location.origin,
solanaCluster: "mainnet-beta",
logLevel: LogLevel.ERROR
}}
>
<App />
</CedrosProvider>
// Development: Show all logs (default)
<CedrosProvider
config={{
stripePublicKey: "pk_test_...",
serverUrl: window.location.origin,
solanaCluster: "devnet",
logLevel: LogLevel.DEBUG
}}
>
<App />
</CedrosProvider>
// CI/Testing: Silence all logs
<CedrosProvider
config={{
stripePublicKey: "pk_test_...",
serverUrl: window.location.origin,
solanaCluster: "devnet",
logLevel: LogLevel.SILENT
}}
>
<App />
</CedrosProvider>For custom logging or integration with your logging infrastructure:
import { createLogger, LogLevel } from "@cedros/pay-react";
// Create a custom logger instance
const logger = createLogger({
level: LogLevel.INFO,
prefix: "[MyApp]", // Optional prefix for all logs
});
// Use directly
logger.debug("Debug message");
logger.info("Info message");
logger.warn("Warning message");
logger.error("Error message");
// Update log level dynamically
logger.setLevel(LogLevel.ERROR);All logs include timestamps and severity levels:
[2025-11-09T10:43:12.345Z] [CedrosPay] [WARN] Token mint validation warning...
[2025-11-09T10:43:15.678Z] [CedrosPay] [ERROR] Payment verification failed
- Production: Use
LogLevel.ERRORorLogLevel.WARNto avoid exposing sensitive data - Development: Use
LogLevel.DEBUGto troubleshoot payment flows - CI/Testing: Use
LogLevel.SILENTto keep test output clean - Monitoring: Integrate with your logging infrastructure (Datadog, Sentry, etc.)
- Paywalled blog or API monetization
- Agent-to-agent microtransactions
- Subscription and one-time digital content unlocks
- AI service pay-per-call endpoints
We follow Semantic Versioning:
- Major (x.0.0): Breaking changes, API removals
- Minor (0.x.0): New features, backwards-compatible additions
- Patch (0.0.x): Bug fixes, no API changes
These exports are guaranteed stable and follow semantic versioning:
- β Components - All exported React components (CedrosPay, StripeButton, CryptoButton, etc.)
- β Hooks - useCedrosContext, useStripeCheckout, useX402Payment, etc.
- β Manager Interfaces - IStripeManager, IX402Manager, IWalletManager, IRouteDiscoveryManager
- β Types - All types exported via versioned namespaces (v1, v2, etc.)
- β Utilities - validateConfig, parseCouponCodes, rate limiters, logging, events
Use interfaces, not concrete classes:
// β
CORRECT: Use interface from context
import { useCedrosContext } from '@cedros/pay-react';
function MyComponent() {
const { stripeManager } = useCedrosContext();
// stripeManager is typed as IStripeManager (stable)
await stripeManager.processPayment({ ... });
}
// β WRONG: Direct class import (unsupported)
import { StripeManager } from '@cedros/pay-react'; // Not exported
const manager = new StripeManager(...); // Will breakWhen APIs are deprecated:
- Deprecation Notice - Warning logged, replacement documented
- Minimum 3 months - Grace period for migration
- Migration Guide - Step-by-step upgrade instructions
- Major Version - Removal in next major release only
Example Timeline:
- v2.1.0: Deprecate oldAPI, introduce newAPI
- v2.2.0 - v2.x: Both supported, warnings logged
- v3.0.0: Remove oldAPI, only newAPI available
Types use versioned namespaces to prevent breaking changes:
// Top-level exports (current stable version)
import { X402Requirement } from '@cedros/pay-react';
// Explicit version (locks to v1, won't break on v2)
import { v1 } from '@cedros/pay-react';
const req: v1.X402Requirement = { ... };
// Future version
import { v2 } from '@cedros/pay-react';
const newReq: v2.X402Requirement = { ... };Read more: See API_STABILITY.md for our complete stability policy.
Cedros Pay includes opt-in error telemetry with correlation IDs for production debugging. Telemetry is disabled by default and requires explicit configuration.
- β Opt-in only - No data sent without your explicit configuration
- β User-controlled - You choose what service to use (Sentry, Datadog, custom, or none)
- β PII sanitization - Private keys, wallet addresses, emails automatically redacted
- β No hidden network calls - Data only sent via your callback function
import { configureTelemetry, ErrorSeverity } from "@cedros/pay-react";
import * as Sentry from "@sentry/react";
// Enable telemetry with Sentry
configureTelemetry({
enabled: true,
sdkVersion: "2.0.0",
environment: process.env.NODE_ENV,
sanitizePII: true, // ALWAYS keep enabled
onError: (error) => {
Sentry.captureException(error.error, {
extra: {
correlationId: error.correlationId,
paymentContext: error.paymentContext,
},
tags: error.tags,
level: error.severity,
});
},
});- Correlation IDs - Track errors across distributed systems
- Error Enrichment - Add payment context (method, stage, amount) without PII
- PII Sanitization - 15+ patterns including:
- Private keys (Solana, Ethereum)
- Wallet addresses
- Seed phrases
- API keys, JWT tokens
- Credit cards, emails, phone numbers
- Integration Examples - Sentry, Datadog, custom backends
/**
* SECURITY GUARANTEE:
* - NEVER logs private keys, seed phrases, or wallet credentials
* - NEVER sends data without explicit user configuration
* - Sanitization ENABLED BY DEFAULT and cannot be fully disabled
* - All sensitive crypto data patterns are redacted automatically
*/import { generateCorrelationId } from "@cedros/pay-react";
function PaymentButton() {
const [correlationId] = useState(generateCorrelationId());
const handleError = (error: Error) => {
reportError(error, { correlationId });
// Show correlation ID to user for support
alert(`Payment failed. Support ID: ${correlationId}`);
};
}-
Copy the environment template:
cp .env.example .env
-
Configure your credentials:
Edit
.envand add your keys:# Stripe test key (required for card payments) VITE_STRIPE_PUBLIC_KEY=pk_test_your_key_here # Solana RPC endpoint (required for crypto payments) VITE_SOLANA_RPC_URL=https://your-rpc-endpoint/ # Backend server URL VITE_SERVER_URL=http://localhost:8080
β οΈ Required for testing:- Stripe: Get a test key from Stripe Dashboard
- Solana RPC: Public RPCs have strict rate limits. Get a free endpoint from:
-
Run Storybook:
npm run storybook
Error: "Endpoint URL must start with http: or https:"
This means VITE_SOLANA_RPC_URL or VITE_STORYBOOK_SOLANA_ENDPOINT is missing or empty in your .env file.
Fix:
# Add to .env
VITE_SOLANA_RPC_URL=https://api.devnet.solana.com
# Or for Storybook-specific override:
VITE_STORYBOOK_SOLANA_ENDPOINT=https://api.devnet.solana.comError: "Invalid Cedros configuration: serverUrl must be a non-empty string"
The VITE_SERVER_URL or VITE_STORYBOOK_SERVER_URL is missing.
Fix:
# Add to .env
VITE_SERVER_URL=http://localhost:8080| Variable | Description | Required |
|---|---|---|
VITE_STRIPE_PUBLIC_KEY |
Stripe publishable key | β For card payments |
VITE_SERVER_URL |
Backend API endpoint | β
(default: http://localhost:8080) |
VITE_SOLANA_CLUSTER |
Solana network | Optional (default: mainnet-beta) |
VITE_SOLANA_RPC_URL |
Custom Solana RPC | β For crypto payments |
VITE_STORYBOOK_SERVER_URL |
Override server URL for Storybook | Optional |
VITE_STORYBOOK_SOLANA_ENDPOINT |
Override RPC for Storybook | Optional |
We welcome contributions! Please see CONTRIBUTING.md for guidelines on:
- Development setup and workflow
- Code standards and architecture principles
- Testing requirements
- PR submission process
- Security guidelines
Before submitting a PR, make sure all tests pass:
npm run lint
npm run type-check
npm test
npm run test:coverageMIT License - see LICENSE for details.