Payment Gateway Integration#
Build a custom payment gateway using PortalPay infrastructure.
Overview#
This guide demonstrates how to build a payment gateway that accepts cryptocurrency payments using PortalPay's infrastructure.
Security & Headers#
- All developer API requests require the APIM subscription header:
markup
Ocp-Apim-Subscription-Key: {your-subscription-key} - Always perform PortalPay API calls on your server; never expose your subscription key in browser code.
- Origin enforcement: requests must transit Azure Front Door (AFD). APIM validates an internal x-edge-secret set by AFD. Direct-origin calls are denied (403) in protected environments.
- Rate limiting headers may be returned: markup,
X-RateLimit-Limitmarkup,X-RateLimit-Remainingmarkup.X-RateLimit-Reset
Use Cases#
- Accept crypto payments on your website
- Add crypto payment option to existing checkout
- Build a payment widget/plugin
- Create a white-label payment solution
Integration Flow#
markupCustomer → Your App → Generate Receipt → PortalPay Payment Page → Confirmation
Quick Integration#
1. Generate Payment Link#
typescriptasync function createPaymentLink(amount: number, description: string) { // Create a simple order const response = await fetch('https://pay.ledger1.ai/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Ocp-Apim-Subscription-Key': process.env.PORTALPAY_SUBSCRIPTION_KEY! }, body: JSON.stringify({ items: [ { sku: 'PAYMENT-001', qty: 1 } ] }) }); const order = await response.json(); return { receiptId: order.receipt.receiptId, paymentUrl: `https://pay.ledger1.ai/pay/${order.receipt.receiptId}`, amount: order.receipt.totalUsd }; }
2. Display Payment Options#
tsxfunction CheckoutButton({ amount }: { amount: number }) { const [loading, setLoading] = useState(false); async function handlePayWithCrypto() { setLoading(true); try { const { paymentUrl } = await createPaymentLink(amount, 'Order Payment'); window.location.href = paymentUrl; } catch (error) { console.error('Payment failed:', error); setLoading(false); } } return ( <button onClick={handlePayWithCrypto} disabled={loading} className="px-6 py-3 bg-blue-600 text-white rounded-md" > {loading ? 'Processing...' : 'Pay with Crypto'} </button> ); }
Advanced Integration#
Embedded Portal iframe (recommended)#
PortalPay provides a portal route you can embed directly to collect payments. This works in dashboard modals and shop pages and specifically allows embedding from https://pay.ledger1.ai.
Portal URL Parameters#
The portal supports multiple parameters to customize the experience:
| Parameter | Type | Required | Description |
|---|---|---|---|
markup | string | Yes | The receipt/order ID (in URL path) |
markup | string | Yes | Merchant wallet address (0x...) for Cosmos partitioning |
markup | string | No | Layout mode: markup (default), markup , or markup |
markup | string | No | Set to markup to enable transparent background for iframe embedding |
markup | string | No | Parent tracking ID for subscription flows and postMessage coordination |
markup | string | No | Set to markup to force PortalPay branding instead of merchant theme |
Layout Modes#
Compact Mode (Default)#
- Max width: 428px
- Layout: Single column, mobile-optimized
- Best for: Modal dialogs, mobile views, subscription upgrades
- URL: markup
/portal/{receiptId}?recipient={wallet}
tsx// Compact mode example (default) const compactUrl = `https://pay.ledger1.ai/portal/${receiptId}?recipient=${wallet}`;
Wide Mode#
- Max width: 980px
- Layout: Two-column grid (receipt left, payment right)
- Best for: Full-page checkouts, desktop experiences
- URL: markup
/portal/{receiptId}?recipient={wallet}&layout=wide
tsx// Wide mode example const wideUrl = `https://pay.ledger1.ai/portal/${receiptId}?recipient=${wallet}&layout=wide`;
Invoice Mode#
- Max width: responsive two-column (receipt left, payment right) with decorative gradient
- Layout: Invoice presentation optimized for full-page, desktop/tablet
- Best for: Invoicing flows, quotes, long line items
- URL options:
- markup(suffix forces invoice view)
/portal/{receiptId}?recipient={wallet}&invoice=1 - markup
/portal/{receiptId}?recipient={wallet}&layout=invoice - markup
/portal/{receiptId}?recipient={wallet}&mode=invoice
tsx// Invoice mode example (using invoice=1 suffix) const invoiceUrl = `https://pay.ledger1.ai/portal/${receiptId}?recipient=${wallet}&invoice=1`; // Alternate forms: const invoiceUrlByLayout = `https://pay.ledger1.ai/portal/${receiptId}?recipient=${wallet}&layout=invoice`; const invoiceUrlByMode = `https://pay.ledger1.ai/portal/${receiptId}?recipient=${wallet}&mode=invoice`;
Sizing & Presentation#
- Set markupwhen rendering in an iframe to enable transparent background.
embedded=1 - Width should be markup. Height should be managed via the PostMessage event
100%markup.portalpay-preferred-height - The portal posts markupas content changes; set your iframe height to the provided value.
portalpay-preferred-height - Typical minimum heights:
- Compact: ~560–600px
- Wide: ~800px
- Invoice: ~720–900px depending on content
- You can optionally set markupto hint the embedded widget panel height inside invoice/wide views.
e_h={pixels}
Embedding Patterns#
Pattern 1: Modal Embedding (Recommended for Dashboards)#
Use compact layout with embedded mode for dashboard modals:
tsxfunction PaymentModal({ receiptId, recipient, onClose }: { receiptId: string; recipient: `0x${string}`; onClose: () => void; }) { const [iframeHeight, setIframeHeight] = useState(600); useEffect(() => { // Listen for height adjustments from portal function handleMessage(event: MessageEvent) { if (event.data?.type === 'portalpay-preferred-height') { setIframeHeight(event.data.height); } if (event.data?.type === 'portalpay-card-success') { // Payment succeeded onClose(); } if (event.data?.type === 'portalpay-card-cancel') { // User cancelled onClose(); } } window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [onClose]); const params = new URLSearchParams({ recipient, embedded: '1', // Transparent background correlationId: receiptId, }); const portalUrl = `https://pay.ledger1.ai/portal/${receiptId}?${params}`; return ( <div className="modal-overlay"> <iframe src={portalUrl} width="100%" height={iframeHeight} frameBorder="0" title="Payment Checkout" allow="payment; clipboard-write" style={{ border: 'none', borderRadius: '8px' }} /> </div> ); }
Pattern 2: Full-Page Embedding#
Use wide layout for full-page checkout experiences:
tsxfunction CheckoutPage({ receiptId, recipient }: { receiptId: string; recipient: `0x${string}`; }) { const params = new URLSearchParams({ recipient, layout: 'wide', // Two-column layout }); const portalUrl = `https://pay.ledger1.ai/portal/${receiptId}?${params}`; return ( <iframe src={portalUrl} width="100%" height="800" frameBorder="0" title="PortalPay Checkout" allow="payment; clipboard-write" style={{ border: "1px solid #e5e7eb", borderRadius: "8px" }} /> ); }
Pattern 3: Subscription Payment#
Use correlationId for subscription flows:
tsxfunction SubscriptionUpgrade({ subscriptionId, recipient, }: { subscriptionId: string; recipient: `0x${string}`; }) { const params = new URLSearchParams({ recipient, embedded: '1', correlationId: subscriptionId, forcePortalTheme: '1', // Use PortalPay branding }); // Receipt ID can be the subscription ID - API will create receipt const portalUrl = `https://pay.ledger1.ai/portal/${subscriptionId}?${params}`; return ( <iframe src={portalUrl} width="100%" height="600" frameBorder="0" title="Upgrade Subscription" allow="payment; clipboard-write" /> ); }
PostMessage API#
The portal communicates with parent windows via postMessage for embedded scenarios.
Events Sent from Portal#
1. Preferred Height (markupportalpay-preferred-height
)#
portalpay-preferred-heightSent when portal content size changes for responsive iframe sizing.
typescript{ type: 'portalpay-preferred-height', height: number, // Preferred height in pixels correlationId?: string, // Your tracking ID receiptId: string // Receipt ID }
2. Payment Success (markupportalpay-card-success
)#
portalpay-card-successSent when payment completes successfully.
typescript{ type: 'portalpay-card-success', token: string, // Confirmation token (ppc_{receiptId}_{timestamp}) correlationId?: string, // Your tracking ID receiptId: string, // Receipt ID recipient: string // Merchant wallet }
3. Payment Cancel (markupportalpay-card-cancel
)#
portalpay-card-cancelSent when user cancels the payment.
typescript{ type: 'portalpay-card-cancel', correlationId?: string, // Your tracking ID receiptId: string, // Receipt ID recipient: string // Merchant wallet }
Example Event Handler#
typescriptuseEffect(() => { function handlePortalMessage(event: MessageEvent) { // Verify origin for security const trustedOrigin = 'https://pay.ledger1.ai'; if (event.origin !== trustedOrigin) return; switch (event.data?.type) { case 'portalpay-preferred-height': // Adjust iframe height setIframeHeight(event.data.height); break; case 'portalpay-card-success': // Payment succeeded console.log('Payment confirmed:', event.data.token); handlePaymentSuccess(event.data.receiptId); break; case 'portalpay-card-cancel': // User cancelled handlePaymentCancel(); break; } } window.addEventListener('message', handlePortalMessage); return () => window.removeEventListener('message', handlePortalMessage); }, []);
Receipt API Integration#
Key Points#
- Use the portal URL shape: markup
/portal/{receiptId}?recipient={wallet}&correlationId={id} - markupis the merchant wallet address (0x...), used as the Cosmos partition
recipient - markupis recommended for subscription flows (maps to a seeded receipt amount via
correlationIdmarkup)apim_subscription_payment - The receipts API returns a uniform markuppayload via GET and will ensure a positive total when possible
{ receipt }
Receipts API and positive totals#
- GET markupreturns
/api/receipts/{id}markupand requires an APIM subscription key. For embedded scenarios, use the{ receipt }markuproute./portal/{receiptId} - For subscription flows, if the direct receipt lookup has markup, the API falls back to
totalUsd <= 0markup(or tip) byapim_subscription_paymentmarkupto construct a positive-total receipt (e.g., $399 Pro or $500 Enterprise).correlationId=id - Always pass the merchant markupwallet in the iframe query so the portal can partition correctly.
recipient
Allowed origins and CSP#
- The app’s middleware sets markupwith
Content-Security-Policymarkup(and yourframe-ancestors 'self' https://pay.ledger1.aimarkuphost if configured) specifically forNEXT_PUBLIC_APP_URLmarkup./portal/* - markupis omitted for
X-Frame-Optionsmarkupso CSP exclusively governs embedding./portal/* - If you self-host, ensure your markupis set and your middleware includes its host in
NEXT_PUBLIC_APP_URLmarkupforframe-ancestorsmarkup./portal/*
Example: Subscription fallback embed#
When your subscription endpoint returns a 402 with a fallback:
json{ "fallback": { "type": "portalpay-card", "paymentPortalUrl": "https://your-app/portal/{correlationId}?recipient={ownerWallet}&correlationId={correlationId}", "amountUsd": 399, "productId": "portalpay-pro", "correlationId": "{correlationId}" } }
paymentPortalUrlcorrelationIdPayment Confirmation#
Server-side status proxy (required)#
typescript// Next.js (app or pages) API route example export async function GET(req: Request) { const { searchParams } = new URL(req.url); const receiptId = searchParams.get('receiptId'); if (!receiptId) { return new Response(JSON.stringify({ error: 'missing_receiptId' }), { status: 400 }); } const res = await fetch(`https://pay.ledger1.ai/api/receipts/status?receiptId=${encodeURIComponent(receiptId)}`, { headers: { 'Ocp-Apim-Subscription-Key': process.env.PORTALPAY_SUBSCRIPTION_KEY!, }, }); const data = await res.json(); return new Response(JSON.stringify(data), { status: res.status, headers: { 'Content-Type': 'application/json' } }); }
Return URL#
After payment, redirect customer back to your site:
typescriptconst paymentUrl = `https://pay.ledger1.ai/pay/${receiptId}?returnUrl=${encodeURIComponent('https://yoursite.com/order-confirmation')}`;
Confirmation Page#
Call your backend proxy route; do not call PortalPay directly from the browser.
typescript// pages/order-confirmation.tsx export default function OrderConfirmation({ searchParams }: any) { const receiptId = searchParams.receiptId; const [status, setStatus] = useState('checking'); useEffect(() => { async function checkStatus() { const response = await fetch( `/api/portalpay/receipts/status?receiptId=${receiptId}` ); const data = await response.json(); setStatus(data.status); } checkStatus(); }, [receiptId]); if (status === 'completed') { return <div>✓ Payment successful! Receipt: {receiptId}</div>; } return <div>Checking payment status...</div>; }
Best Practices#
- Always use server-side API calls
- Verify payment status before fulfillment
- Handle payment timeouts gracefully
- Provide clear payment instructions
- Support multiple cryptocurrencies
- Test with small amounts first
Next Steps#
- E-commerce Guide - Full integration
- POS Guide - In-person payments
- API Reference
