Skip to content

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.

  1. Stripe keys configured in Dashboard > Settings > Stripe
  2. At least one product with a paid price (products sync to Stripe automatically)
  3. Customer logged in via the SDK (login() or register())

Redirect the customer to Stripe Checkout to collect payment:

import { checkout } from '@latch/sdk';
// Redirect to Stripe Checkout
await 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.

OptionTypeRequiredDescription
priceIdstringYesLatch price UUID (not the Stripe price ID)
successUrlstringNoRedirect after successful payment. Defaults to current page.
cancelUrlstringNoRedirect if customer cancels. Defaults to current page.

If you prefer to create checkout sessions from your server, use the REST API directly:

Terminal window
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:

Terminal window
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"
}'

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 Portal
await openPortal({
returnUrl: 'https://yoursite.com/account',
});

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');
}
FieldTypeDescription
idstringLatch subscription ID
priceIdstringThe price this subscription is for
statusstringactive, trialing, past_due, cancelled, expired
cancelAtPeriodEndbooleanWhether the subscription is set to cancel at period end
currentPeriodStartstring | nullISO 8601 date
currentPeriodEndstring | nullISO 8601 date
cancelledAtstring | nullWhen the subscription was cancelled
createdAtstringISO 8601 date

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>
  1. checkout() calls POST /api/v1/subscriptions/checkout with the customer’s JWT as a Bearer token and the publishable key
  2. The API creates a Stripe Checkout session with the customer’s Stripe ID and the price’s Stripe ID
  3. The SDK redirects the browser to the Stripe-hosted checkout page
  4. After payment, Stripe sends a checkout.session.completed webhook to Latch
  5. Latch creates a subscription record and the customer’s access checks now return granted: true
  6. openPortal() works the same way — it creates a Stripe Customer Portal session and redirects

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:

Terminal window
stripe listen --forward-to http://localhost:4000/api/v1/webhooks/stripe

Copy the whsec_... signing secret the CLI prints and either:

  • Set STRIPE_WEBHOOK_SECRET in your environment, or
  • Pass it as webhookSecret when saving Stripe settings

See Webhooks for full details on supported events and processing.