Billing & Checkout
The SDK provides checkout(), openPortal(), and getSubscription() helpers that work with the logged-in customer’s JWT. No server-side code is needed for the basic flow.
Prerequisites
Section titled “Prerequisites”- Stripe keys configured in Dashboard > Settings > Stripe
- At least one product with a paid price (products sync to Stripe automatically)
- Customer logged in via the SDK (
login()orregister())
Checkout
Section titled “Checkout”Redirect the customer to Stripe Checkout to collect payment:
import { checkout } from '@latch/sdk';
// Redirect to Stripe Checkoutawait checkout({ priceId: 'your-latch-price-uuid', successUrl: 'https://yoursite.com/welcome', cancelUrl: 'https://yoursite.com/pricing',});The browser redirects to Stripe’s hosted checkout page. After payment, the customer is redirected back to successUrl. Latch creates the subscription automatically via webhook.
If successUrl or cancelUrl are omitted, they default to the current page.
Checkout options
Section titled “Checkout options”| Option | Type | Required | Description |
|---|---|---|---|
priceId | string | Yes | Latch price UUID (not the Stripe price ID) |
successUrl | string | No | Redirect after successful payment. Defaults to current page. |
cancelUrl | string | No | Redirect if customer cancels. Defaults to current page. |
Server-side checkout (sk_ key)
Section titled “Server-side checkout (sk_ key)”If you prefer to create checkout sessions from your server, use the REST API directly:
curl -X POST https://your-api/api/v1/subscriptions/checkout \ -H "X-API-Key: sk_..." \ -H "Content-Type: application/json" \ -d '{ "priceId": "price-uuid", "customerId": "customer-uuid", "successUrl": "https://yoursite.com/welcome", "cancelUrl": "https://yoursite.com/pricing" }'You can also pass email instead of customerId to auto-create the customer:
curl -X POST https://your-api/api/v1/subscriptions/checkout \ -H "X-API-Key: sk_..." \ -H "Content-Type: application/json" \ -d '{ "priceId": "price-uuid", "email": "[email protected]", "successUrl": "https://yoursite.com/welcome", "cancelUrl": "https://yoursite.com/pricing" }'Billing Portal
Section titled “Billing Portal”Let customers manage their subscription (update payment method, cancel, view invoices) through Stripe’s Customer Portal:
import { openPortal } from '@latch/sdk';
// Redirect to Stripe Customer Portalawait openPortal({ returnUrl: 'https://yoursite.com/account',});Check subscription status
Section titled “Check subscription status”Query the current customer’s active subscription without leaving the page:
import { getSubscription } from '@latch/sdk';
const sub = await getSubscription();if (sub) { console.log(`Status: ${sub.status}`); console.log(`Renews: ${sub.currentPeriodEnd}`); console.log(`Cancelling: ${sub.cancelAtPeriodEnd}`);} else { console.log('No active subscription');}Subscription fields
Section titled “Subscription fields”| Field | Type | Description |
|---|---|---|
id | string | Latch subscription ID |
priceId | string | The price this subscription is for |
status | string | active, trialing, past_due, cancelled, expired |
cancelAtPeriodEnd | boolean | Whether the subscription is set to cancel at period end |
currentPeriodStart | string | null | ISO 8601 date |
currentPeriodEnd | string | null | ISO 8601 date |
cancelledAt | string | null | When the subscription was cancelled |
createdAt | string | ISO 8601 date |
Complete example
Section titled “Complete example”A minimal page with login, checkout, portal, and subscription status:
<!DOCTYPE html><html><head><title>My Publication</title></head><body> <div id="auth-section"> <h2>Account</h2> <div id="logged-out"> <input id="email" type="email" placeholder="Email" /> <input id="password" type="password" placeholder="Password" /> <button id="login-btn">Log In</button> <button id="register-btn">Sign Up</button> </div> <div id="logged-in" style="display:none"> <p>Welcome, <span id="user-name"></span></p> <p id="sub-status"></p> <button id="checkout-btn" style="display:none">Subscribe ($9.99/mo)</button> <button id="portal-btn" style="display:none">Manage Subscription</button> <button id="logout-btn">Log Out</button> </div> </div>
<script type="module"> import { init, login, register, logout, isAuthenticated, getStoredCustomer, onAuthChange, checkout, openPortal, getSubscription } from 'https://your-api/sdk/latch.js';
const PRICE_ID = 'your-price-uuid-here';
init({ apiKey: 'pk_...', apiUrl: 'https://your-api' });
// ── Auth handlers ── document.getElementById('login-btn').onclick = async () => { const email = document.getElementById('email').value; const password = document.getElementById('password').value; await login(email, password); };
document.getElementById('register-btn').onclick = async () => { const email = document.getElementById('email').value; const password = document.getElementById('password').value; await register(email, password); };
document.getElementById('logout-btn').onclick = () => logout();
// ── Billing handlers ── document.getElementById('checkout-btn').onclick = () => { checkout({ priceId: PRICE_ID }); };
document.getElementById('portal-btn').onclick = () => { openPortal({ returnUrl: window.location.href }); };
// ── UI updates on auth change ── onAuthChange(async (customer) => { const loggedOut = document.getElementById('logged-out'); const loggedIn = document.getElementById('logged-in');
if (customer) { loggedOut.style.display = 'none'; loggedIn.style.display = 'block'; document.getElementById('user-name').textContent = customer.name || customer.email;
// Check subscription const sub = await getSubscription(); const statusEl = document.getElementById('sub-status'); const checkoutBtn = document.getElementById('checkout-btn'); const portalBtn = document.getElementById('portal-btn');
if (sub && sub.status === 'active') { statusEl.textContent = `Subscribed (renews ${new Date(sub.currentPeriodEnd).toLocaleDateString()})`; checkoutBtn.style.display = 'none'; portalBtn.style.display = 'inline'; } else { statusEl.textContent = 'No active subscription'; checkoutBtn.style.display = 'inline'; portalBtn.style.display = 'none'; } } else { loggedOut.style.display = 'block'; loggedIn.style.display = 'none'; } }); </script></body></html>How it works
Section titled “How it works”checkout()callsPOST /api/v1/subscriptions/checkoutwith the customer’s JWT as a Bearer token and the publishable key- The API creates a Stripe Checkout session with the customer’s Stripe ID and the price’s Stripe ID
- The SDK redirects the browser to the Stripe-hosted checkout page
- After payment, Stripe sends a
checkout.session.completedwebhook to Latch - Latch creates a subscription record and the customer’s access checks now return
granted: true openPortal()works the same way — it creates a Stripe Customer Portal session and redirects
Webhooks
Section titled “Webhooks”Latch handles Stripe webhooks at POST /api/v1/webhooks/stripe.
In production, when you save your Stripe secret key, Latch automatically creates a webhook endpoint in Stripe with the correct URL and events. No manual configuration needed.
In local development, Stripe can’t reach localhost. Use the Stripe CLI to forward events:
stripe listen --forward-to http://localhost:4000/api/v1/webhooks/stripeCopy the whsec_... signing secret the CLI prints and either:
- Set
STRIPE_WEBHOOK_SECRETin your environment, or - Pass it as
webhookSecretwhen saving Stripe settings
See Webhooks for full details on supported events and processing.