Skip to content

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.

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.

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.

ProviderRequired secretOptional secret
Polar (default)POLAR_ACCESS_TOKENPOLAR_SERVER"production" (default) / "sandbox"
StripeSTRIPE_SECRET_KEY
PaddlePADDLE_API_KEYPADDLE_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.

Checkout is two SDK calls split across two handlers — one to start the payment, one to confirm it when the Visitor returns.

  1. Start. Call checkout(input) to get a checkoutUrl, then redirect the Visitor to it.
  2. 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 calls verifySession(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.

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.

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.

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>
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 }).

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.

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.

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 }).

MethodPolarStripePaddle
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.

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.

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"
kindstatusWhen it is thrown
"secret-missing"412The provider key is not set in Secret Storage
"validation"400Bad input, or an option this provider does not support
"product-not-found"404The productId does not exist for this provider
"card-declined"402The card was declined (Stripe synchronous charge paths only)
"auth"502The provider key is invalid or lacks the required scope
"rate-limited"429Throttled by the provider; respect retryAfterSec
"provider-error"502 / 503Upstream issue; 503 means the circuit breaker is open (fail-fast)
"not-available"501This 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.

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 raw fetch. 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_secret and let the SDK resolve it by name at call time.

  • The checkout pattern is redirect, then verify. checkout() gives a URL — redirect with Response.redirect(checkoutUrl, 303). On return, read the appended session id and call verifySession(); 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 (status 501) with no network call. Polar is checkout-only; Stripe is the full lifecycle; Paddle omits createSubscription and listInvoices.

  • 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 503 on provider-error means the breaker is open. Stripe and Paddle wrap outbound calls in a fail-fast circuit breaker. A 502 is a live upstream failure; a 503 is 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_ENV to "sandbox" (or use a Stripe sk_test_… key) and run a full checkout-then-verify cycle before taking real payments.