Payments
Payments is needs-provider-setup: it requires your own provider account, and Anyknown has not smoke-verified the flow end-to-end. You bring your own Polar, Stripe, or Paddle account and set its API key as a secret; the SDK then dispatches every call to that provider on your behalf. Anyknown does not proxy or resell payment flows, and has not run an end-to-end verification against real or sandbox credentials — you must supply your own key and test the checkout flow against your provider’s sandbox before going live. The contract on this page is final and stable; the live confidence is yours to establish.
Payments lets your App charge your Visitors using a hosted-checkout redirect model. You never collect card details: your handler asks the SDK for a checkout URL, redirects the Visitor to the provider’s hosted page, and confirms the result when they return. Because cardholder data never touches your App or Anyknown, this model keeps you at PCI SAQ-A — the lightest compliance tier.
Your App always calls sdk.payments.*. The platform selects the provider, injects your API key from Secret Storage at call time, and normalizes the result. Your handler never talks to Polar, Stripe, or Paddle directly, and the raw key never appears in your source, your logs, or a response.
Choose a provider
Section titled “Choose a provider”Payments runs on Polar by default; Stripe and Paddle are selectable at the platform level. The provider is chosen by the PAYMENTS_PROVIDER env binding on your App ("polar" | "stripe" | "paddle"); absent means Polar. There is no plan flag — Payments is available once a provider key is set. The SDK surface is identical across all three providers; switching providers changes which lifecycle methods are available (see Provider support matrix), not the call shapes.
Set up your provider key
Section titled “Set up your provider key”Use set_secret to store the key your provider needs — you never paste it into code; the SDK reads it through the platform on each call. Ask your Builder to store the key, or set it in your App’s secret manager.
| Provider | Required secret | Optional secret |
|---|---|---|
| Polar (default) | POLAR_ACCESS_TOKEN | POLAR_SERVER — "production" (default) / "sandbox" |
| Stripe | STRIPE_SECRET_KEY | — |
| Paddle | PADDLE_API_KEY | PADDLE_ENV — "production" (default) / "sandbox" |
The key format is validated when you call. A missing key throws PaymentsError with kind: "secret-missing" (status 412); a malformed or unauthorized key throws kind: "auth" (status 502). STRIPE_SECRET_KEY must look like sk_test_… or sk_live_…, and POLAR_ACCESS_TOKEN must be a polar_at_… / polar_oat_… token.
The checkout flow
Section titled “The checkout flow”Checkout is two SDK calls split across two handlers — one to start the payment, one to confirm it when the Visitor returns.
- Start. Call
checkout(input)to get acheckoutUrl, then redirect the Visitor to it. - Confirm. When the provider sends the Visitor back to your
successUrl, it appends a session id to the query string. Your success handler reads it and callsverifySession(sessionId)to confirm the payment status.
checkout and verifySession are the only two methods implemented by every provider — they are the backbone of the flow.
Start checkout
Section titled “Start checkout”checkout(input: CheckoutInput): Promise<CheckoutResult>
type CheckoutInput = { productId: string // required successUrl: string // required; full URL the provider redirects back to customerEmail?: string metadata?: Record<string, string> mode?: "payment" | "subscription" currency?: string // ISO 4217 3-letter code taxId?: string customerCountry?: string // ISO 3166-1 alpha-2 customerState?: string lineItems?: Array<{ productId: string; quantity?: number }> idempotencyKey?: string}
type CheckoutResult = { sessionId: string checkoutUrl: string}Inside a handler, redirect the Visitor to the URL the SDK returns:
const sdk = c.get("sdk")const { checkoutUrl } = await sdk.payments.checkout({ productId: "prod_123", successUrl: "https://your-app.anyknown.app/checkout/return",})return Response.redirect(checkoutUrl, 303)lineItems is optional: omit it and the SDK synthesizes a single-item line from productId (quantity 1). Pass lineItems only when you need several products or explicit quantities. Pass idempotencyKey to make a retried checkout call safe — the SDK forwards it to the provider’s native idempotency mechanism, and generates a per-call UUID when you omit it.
Confirm the session
Section titled “Confirm the session”When the Visitor lands back on your successUrl, the provider has appended a session id (Polar and others use a checkout_id query parameter). Read it and verify:
verifySession(sessionId: string): Promise<VerifySessionResult>
type CheckoutStatus = "open" | "expired" | "confirmed" | "succeeded" | "failed"
type VerifySessionResult = { sessionId: string status: CheckoutStatus subscriptionId?: string amount?: number currency?: string customerId?: string}const sdk = c.get("sdk")const sessionId = new URL(c.req.url).searchParams.get("checkout_id")const result = await sdk.payments.verifySession(sessionId)
if (result.status === "succeeded" || result.status === "confirmed") { // treat the Visitor as paid — grant access, fulfil the order, etc.}Treat "succeeded" or "confirmed" as paid. "open" means the Visitor has not completed payment yet; "expired" and "failed" are terminal non-payments.
Lifecycle methods
Section titled “Lifecycle methods”Beyond checkout, the SDK exposes subscription, refund, invoice, and customer-portal methods. These are provider-dependent — not every provider implements every one. Calling a method the active provider does not support throws PaymentsError({ kind: "not-available", status: 501 }) immediately, with no network call. Check the support matrix and call only the methods your provider implements rather than catching not-available reactively.
createSubscription(input: SubscriptionInput): Promise<SubscriptionResult>getSubscription(subscriptionId: string): Promise<SubscriptionResult>cancelSubscription(subscriptionId: string, opts?: CancelSubscriptionOpts): Promise<SubscriptionResult>refund(input: RefundInput): Promise<RefundResult>getInvoice(invoiceId: string): Promise<Invoice>listInvoices(subscriptionId: string): Promise<Invoice[]>getCustomerPortalUrl(customerId: string): Promise<CustomerPortal>Subscriptions
Section titled “Subscriptions”type SubscriptionInput = { productId: string planId?: string billingInterval?: "month" | "year" trialDays?: number customerId?: string metadata?: Record<string, string> idempotencyKey?: string}
type SubscriptionResult = { subscriptionId: string status: "trialing" | "active" | "past_due" | "canceled" | "paused" nextBillingAt?: string // ISO string}
type CancelSubscriptionOpts = { atPeriodEnd?: boolean}You can also start a subscription through checkout itself by passing mode: "subscription" to checkout — on Stripe this opens a subscription-mode hosted checkout. To cancel at the end of the current billing period rather than immediately, pass cancelSubscription(id, { atPeriodEnd: true }).
Refunds
Section titled “Refunds”type RefundInput = { sessionId?: string chargeId?: string subscriptionId?: string amount?: number // omit for a full refund reason?: string idempotencyKey?: string}
type RefundResult = { refundId: string status: "pending" | "succeeded" | "failed" amount?: number}On Paddle, a refund is keyed by sessionId (the Paddle transaction id) and resolves as status: "pending" — Paddle processes refunds as asynchronous adjustments, so the final outcome arrives later via webhook.
Invoices and the customer portal
Section titled “Invoices and the customer portal”type Invoice = { id: string url?: string pdfUrl?: string total?: number currency?: string status: "draft" | "open" | "paid" | "void" issuedAt?: string}
type CustomerPortal = { url: string expiresAt?: string}getCustomerPortalUrl(customerId) returns a hosted portal URL you can redirect a paying Visitor to so they can manage their own subscription and payment method.
Provider support matrix
Section titled “Provider support matrix”The same sdk.payments.* surface is offered by every provider, but the provider you select determines which methods actually run. Anything not implemented throws PaymentsError({ kind: "not-available", status: 501 }).
| Method | Polar | Stripe | Paddle |
|---|---|---|---|
checkout | ✅ | ✅ | ✅ |
verifySession | ✅ | ✅ | ✅ |
createSubscription | — | ✅ | — |
getSubscription | — | ✅ | ✅ |
cancelSubscription | — | ✅ | ✅ |
refund | — | ✅ | ✅ |
getInvoice | — | ✅ | ✅ |
listInvoices | — | ✅ | — |
getCustomerPortalUrl | — | ✅ | ✅ |
Polar is one-time checkout only — it implements checkout and verifySession, and every lifecycle method throws not-available. Cancellation is handled in Polar’s own hosted UI. Stripe implements the full lifecycle. Paddle implements most of it but not createSubscription or listInvoices.
Webhooks
Section titled “Webhooks”For asynchronous results — a renewal, an async card decline, a dispute — wire a payment webhook. Payment webhooks reuse the Inbound Integration ingress: register a receiver with set_webhook, storing the signing secret as STRIPE_WEBHOOK_SECRET_<id> (Stripe) or PADDLE_WEBHOOK_SECRET_<id> (Paddle).
In your webhook handler, pass the verified raw event to parsePaymentWebhook to normalize it into a provider-agnostic shape:
import { parsePaymentWebhook } from "@anyknown/app-server"
parsePaymentWebhook( provider: "stripe" | "paddle", rawEvent: unknown): PaymentWebhookEvent | null // null = unrecognized event type, discard it
type PaymentWebhookEventType = | "checkout.succeeded" | "checkout.failed" | "subscription.created" | "subscription.renewed" | "subscription.canceled" | "subscription.past_due" | "refund.issued" | "refund.succeeded" | "refund.failed" | "dispute.created"
type PaymentWebhookEvent = { type: PaymentWebhookEventType sessionId?: string subscriptionId?: string refundId?: string amount?: number currency?: string metadata?: Record<string, string>}const event = parsePaymentWebhook("stripe", rawEvent)if (event === null) { return new Response(null, { status: 200 }) // unrecognized type — acknowledge and ignore}switch (event.type) { case "subscription.renewed": // ...}A null return means the event type is not one you act on — acknowledge and move on. parsePaymentWebhook covers Stripe and Paddle only; Polar’s one-time checkout model has no normalized webhook events.
Handling errors
Section titled “Handling errors”Every failure raises a PaymentsError. It carries a kind, an HTTP status you can forward verbatim, and — for throttling — a retryAfterSec.
import { PaymentsError } from "@anyknown/app-server"
class PaymentsError extends Error { readonly kind: PaymentsErrorKind readonly status: number readonly providerErrorType?: string // the provider's own error-type string readonly retryAfterSec?: number // set when kind === "rate-limited"}
type PaymentsErrorKind = | "secret-missing" | "validation" | "product-not-found" | "card-declined" | "auth" | "rate-limited" | "provider-error" | "not-available"kind | status | When it is thrown |
|---|---|---|
"secret-missing" | 412 | The provider key is not set in Secret Storage |
"validation" | 400 | Bad input, or an option this provider does not support |
"product-not-found" | 404 | The productId does not exist for this provider |
"card-declined" | 402 | The card was declined (Stripe synchronous charge paths only) |
"auth" | 502 | The provider key is invalid or lacks the required scope |
"rate-limited" | 429 | Throttled by the provider; respect retryAfterSec |
"provider-error" | 502 / 503 | Upstream issue; 503 means the circuit breaker is open (fail-fast) |
"not-available" | 501 | This provider does not implement the method you called |
Catch the error and forward its status, building the response from err.message and err.status directly:
const sdk = c.get("sdk")try { const { checkoutUrl } = await sdk.payments.checkout({ productId: "prod_123", successUrl: "https://your-app.anyknown.app/checkout/return", }) return Response.redirect(checkoutUrl, 303)} catch (err) { if (err instanceof PaymentsError) { return new Response(err.message, { status: err.status }) } throw err}For Stripe, distinguish "card-declined" (status 402) from "provider-error" in your UI — a decline is the Visitor’s card, not an upstream fault, and the two deserve different messaging. On Paddle, card declines surface asynchronously via webhook rather than as a synchronous card-declined error.
Things to know
Section titled “Things to know”These are the details that most often trip people up when wiring payments into handlers.
-
Always go through
sdk.payments. Never call a provider’s API with rawfetch. Only the SDK path injects your key through the credential resolver, so the raw value never reaches your handler code or logs. -
Never inline a provider key. Store it with
set_secretand let the SDK resolve it by name at call time. -
The checkout pattern is redirect, then verify.
checkout()gives a URL — redirect withResponse.redirect(checkoutUrl, 303). On return, read the appended session id and callverifySession(); treat"succeeded"/"confirmed"as paid. -
Check provider support before calling a lifecycle method. A method that the active provider does not implement throws
not-available(status501) with no network call. Polar is checkout-only; Stripe is the full lifecycle; Paddle omitscreateSubscriptionandlistInvoices. -
Hosted checkout keeps you at PCI SAQ-A. Card data is entered on the provider’s hosted page and never reaches your App or Anyknown.
-
A
503onprovider-errormeans the breaker is open. Stripe and Paddle wrap outbound calls in a fail-fast circuit breaker. A502is a live upstream failure; a503is the breaker refusing to call a provider that has been failing. -
Test against your provider’s sandbox first. Anyknown has not smoke-verified Payments end-to-end. Set
POLAR_SERVER/PADDLE_ENVto"sandbox"(or use a Stripesk_test_…key) and run a full checkout-then-verify cycle before taking real payments.