This guide is for backend services that consume Apollo Billing. It explains how an internal app should check entitlements, block paid operations, report usage, and expose billing errors in a consistent way.
The goal is the same pattern used by large platform teams: product backends do not implement billing rules locally. They call a central billing control plane through a small local client, then enforce a local decision before running expensive or premium work.
Each internal app should own one small billing module:
src/billing/
BillingClient
BillingGate
BillingErrors
BillingUsageReporter
The product backend should not call billing endpoints directly from arbitrary routes. Route handlers should depend on app-level helpers such as:
billing.requireFeature(orgId, "advancedWebhooks")billing.requireQuota(orgId, "project", "maxProjects")billing.requireMeterBalance(orgId, "automationRunBalance", 1)billing.requireMeterBalance(orgId, "aiCreditBalance", 1)billing.reportUsage(orgId, "signal.automation.run", 1, metadata)billing.reportUsage(orgId, "signal.ai.credit.used", 1, metadata)billing.getProductCatalog("signal")for plan/add-on/pack selectorsbilling.getEntitlements(orgId)for dashboards
This keeps billing behavior consistent across controllers, jobs, workers, and RPC methods.
Examples in this guide are shown in both TypeScript and Kotlin because Apollo internal apps may be built on either stack.
Apollo Billing is the source of truth for billing state and billing decisions. Internal apps should treat it as a control plane, not as a helper database.
The billing service owns:
- customer-to-organization billing mappings;
- plan, add-on, and one-time purchase catalog IDs;
- subscription and purchase state synchronized from Polar webhooks;
- entitlement resolution for limits, features, usage, and remaining quota;
- billing enforcement decisions for paid, quota-limited, and meter-backed work;
- checkout session creation for subscriptions, add-ons, and one-time purchases;
- customer billing profile operations that proxy Polar Customer Portal APIs;
- metered usage ingestion and forwarding to Polar;
- Polar webhook verification and state updates;
- service-to-service authentication for internal billing APIs.
Internal apps own:
- authenticating the user;
- authorizing the user for the target organization;
- deciding where in product workflows billing gates apply;
- calling the generated billing SDK from backend code;
- mapping billing failures to product-specific responses;
- reporting usage only after successful work;
- local consistency controls for app-owned resources, such as transactions or locks around hard quotas.
Do not bypass Apollo Billing for billing behavior. These operations must not be implemented manually inside internal apps:
- Do not call Polar directly from internal app backends for subscriptions, products, checkout, customer billing details, payment methods, or metered usage.
- Do not create checkout sessions in browser code.
- Do not expose Polar API keys, service JWTs, Maven publishing credentials, or billing secrets to browsers, mobile apps, logs, analytics tools, or customer-facing error payloads.
- Do not hardcode Polar product IDs, meter IDs, plan prices, or entitlement limits inside product apps. Use the SDK and Apollo Billing catalog responses.
- Do not decide plan access locally from a cached plan name alone. Use enforcement for write paths and expensive work.
- Do not report usage before the product operation has committed successfully.
- Do not let public request bodies choose arbitrary
appSlug,eventKey, product slug, meter key, or feature key values. Product code should map user actions to known constants. - Do not treat dashboard entitlements as authorization for later writes. Always re-check billing in the backend path that performs the work.
- Do not retry usage ingestion in a tight request loop. Use an outbox or bounded background retry when exact accounting matters.
- Do not store copies of customer billing profile data unless the product has a clear business requirement and data-retention policy.
Use this sequence for user-facing operations:
- Authenticate the user.
- Resolve the organization ID.
- Authorize the user for that org.
- Run billing preflight checks.
- Run the operation.
- Commit app-side state.
- Report billable usage asynchronously or with a short timeout.
- Return the product response.
Do not report usage before the operation commits. If the app fails after usage is reported, the customer can be charged for work that did not happen.
Example:
POST /projects
auth user
resolve org
require org role
require quota maxProjects
create project
return project
Metered example:
POST /automations/run
auth user
resolve org
require feature automations
require meter balance automationRunBalance >= estimated runs
run automation
save result
report actual automation run usage
return result
Use the generated SDKs instead of writing raw HTTP calls in each app:
- Kotlin/JVM:
com.apollodeploy:billing-sdk - TypeScript:
@apollo-deploy/billing-sdk
The Kotlin/JVM SDK is published publicly to Maven Central. Installation and release instructions are in SDK README.
Apollo Billing guards all /internal/billing/* endpoints with OAuth 2.1
client_credentials verification. Callers obtain a short-lived EdDSA-signed
JWT from the platform and send it as a bearer token. Billing verifies it locally
against the platform's public JWKS — no shared secret required.
-
The calling service requests a
client_credentialstoken from the platform:POST {PLATFORM_URL}/auth/oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials &client_id={PLATFORM_CLIENT_ID} &client_secret={PLATFORM_CLIENT_SECRET} &resource={PLATFORM_AUDIENCE_URL}The
resourceparameter is required — it tells the platform to issue a JWT (EdDSA-signed) rather than an opaque token. Its value must match billing'sAUTH_OAUTH_VALID_AUDIENCES. -
The platform returns a signed EdDSA JWT:
{ "access_token": "<jwt>", "expires_in": 3600, "token_type": "Bearer" } -
The caller sends the JWT to billing:
Authorization: Bearer <jwt> GET /internal/billing/entitlements/signal/org_123
-
Billing fetches the platform's JWKS (
AUTH_JWKS_URL) once and caches it for 300 seconds, verifying:- EdDSA signature against the public key
issmatchesAUTH_OAUTH_ISSUER_URLaudmatchesAUTH_OAUTH_VALID_AUDIENCESazp/sub(client_id) is inOAUTH_SERVICE_CLIENT_IDSexphas not passed
-
Register an OAuth client on the platform:
cd apps/platform bun run oauth:register-clients # key: <service-slug>, grant types: client_credentials, skipConsent: true -
Save the returned
client_idandclient_secretin the calling service's config:PLATFORM_CLIENT_ID=<client_id> PLATFORM_CLIENT_SECRET=<client_secret> PLATFORM_URL=http://platform:3000 # in-cluster token endpoint PLATFORM_AUDIENCE_URL=https://api.platform.apollodeploy.local # JWT audience BILLING_BASE_URL=https://billing.apollodeploy.com
-
Add the
client_idto the platform'sOAUTH_SERVICE_CLIENT_IDSandOAUTH_TRUSTED_CLIENT_IDS, then restart the platform. -
Add the
client_idto billing'sOAUTH_SERVICE_CLIENT_IDS, then restart billing.
The Kotlin SDK client is rebuilt whenever the cached OAuth token rotates (the
MachineOAuthClient handles refresh automatically):
val platformUrl = System.getenv("PLATFORM_URL")
// MachineOAuthClient fetches and caches the client_credentials JWT.
val m2mClient = MachineOAuthClient {
httpClient(httpClient)
tokenEndpoint("${platformUrl.trimEnd('/')}/auth/oauth2/token")
clientId(System.getenv("PLATFORM_CLIENT_ID"))
clientSecret(System.getenv("PLATFORM_CLIENT_SECRET"))
audience(System.getenv("PLATFORM_AUDIENCE_URL").ifBlank { platformUrl })
clientSecretPost()
if (platformUrl.startsWith("http://")) allowInsecureHttp()
}
// ApolloBillingClientProvider rebuilds the SDK client when the token rotates.
val billingProvider = ApolloBillingClientProvider(
baseUrl = System.getenv("BILLING_BASE_URL"),
m2mClient = m2mClient,
)
// Use in coroutines:
val sdk = billingProvider.get()
val entitlements = sdk.billingEntitlements.getBillingEntitlements("signal", orgId)Tokens expire in 3600 seconds (1 hour). MachineOAuthClient refreshes the token
60 seconds before expiry. There is no shared secret to rotate — to revoke a
service's access, remove its client_id from OAUTH_SERVICE_CLIENT_IDS on
billing and OAUTH_SERVICE_CLIENT_IDS on the platform, then rotate the
client_secret via bun run oauth:register-clients → rotate secret.
Use enforcement for blocking decisions. The most important rule: only an SDK
response with allowed = true is an unconditional allow.
TypeScript quota check:
const billing = createBillingSdk();
const decision = await billing.billingEnforcement.enforceBillingCheck({
orgId: "org_123",
appSlug: "signal",
check: {
type: "quota",
resource: "project",
limitKey: "maxProjects",
},
});
if (!decision.allowed) {
throw new BillingBlockedError("Project quota exceeded");
}TypeScript feature check:
await billing.billingEnforcement.enforceBillingCheck({
orgId: "org_123",
appSlug: "signal",
check: {
type: "feature",
feature: "dedicatedIps",
},
});TypeScript meter balance check:
await billing.billingEnforcement.enforceBillingCheck({
orgId: "org_123",
appSlug: "signal",
check: {
type: "meter",
meterKey: "automationRunBalance",
needed: 1,
},
});Use meterKey: "aiCreditBalance" for AI-credit-backed work. Report consumed
credits with the signal.ai.credit.used usage event after the AI operation
commits successfully.
Kotlin quota check:
import com.apollodeploy.billing.sdk.EnforceRequest
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
val billing = createBillingSdk()
val decision = billing.billingEnforcement.enforceBillingCheck(
EnforceRequest(
orgId = "org_123",
appSlug = "signal",
check = buildJsonObject {
put("type", "quota")
put("resource", "project")
put("limitKey", "maxProjects")
},
),
)
if (!decision.allowed) {
throw BillingBlockedError("Project quota exceeded")
}The SDK returns EnforceResponse for 200 responses and throws
SDKError/SdkException for non-2xx responses. Map those failures as follows:
| Status | Meaning | Consumer action |
|---|---|---|
200 |
Allowed | Continue the operation. |
401 |
Missing, expired, or invalid service token | Treat as a deployment/configuration error. Do not retry blindly. |
402 |
Quota exceeded, feature unavailable, or meter balance too low | Block the operation and return an upgrade/payment error. |
404 |
No active subscription found | Apply the app's free-tier policy or block if the feature requires a paid plan. |
422 |
Unknown app/product/configuration issue | Treat as an internal integration bug. |
500 |
Billing service failed | Use the operation's fail-open/fail-closed policy. |
The most important rule: only allowed = true is an unconditional allow.
Use entitlements for dashboards, settings pages, and explaining why something is blocked.
TypeScript:
const entitlements = await billing.billingEntitlements.getBillingEntitlements(
"signal",
"org_123",
);Kotlin:
val entitlements = billing.billingEntitlements.getBillingEntitlements(
appSlug = "signal",
orgId = "org_123",
)The SDK response includes:
planIdlimitsfeaturesusageremaining
Do not use dashboard entitlements as the only gate for write operations. A user can open a page, wait, then submit after usage has changed. Always re-check inside the backend path that performs the work.
Use usage ingest after successful billable work.
TypeScript:
await billing.billingUsage.ingestBillingUsage({
orgId: "org_123",
eventKey: "signal.automation.run",
quantity: 3,
metadata: {
requestId: "req_123",
workflowId: "workflow_123",
runId: "run_123",
},
});Kotlin:
import com.apollodeploy.billing.sdk.UsageIngestRequest
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
billing.billingUsage.ingestBillingUsage(
UsageIngestRequest(
orgId = "org_123",
eventKey = "signal.automation.run",
quantity = 3,
metadata = buildJsonObject {
put("requestId", "req_123")
put("workflowId", "workflow_123")
put("runId", "run_123")
},
),
)The billing API forwards this to Polar with external_customer_id = orgId.
Failures return 202 with accepted = false so callers do not create retry
storms. If exact usage accounting matters, the product backend should write an
outbox event locally and let a worker retry until accepted.
Use checkout when the backend needs to create a billing session.
First read the public product catalog so product selection and displayed prices come from billing/Polar, not hardcoded Polar IDs:
const catalog = await fetch("https://billing.apollodeploy.com/billing/catalog/signal")
.then((response) => response.json());
const aiPacks = catalog.products.filter(
(product) => product.kind === "CREDIT_PACK" && product.slug.startsWith("signal-ai-credits-"),
);
const dedicatedIp = catalog.products.find(
(product) => product.slug === "signal-dedicated-ip-addon",
);This catalog endpoint is public and does not require a service JWT. The frontend may receive product slugs and display names/prices from it. It should send the selected slug back to the app backend. The backend must still map allowed customer actions to known product categories before calling checkout.
TypeScript:
const checkout = await billing.billingCheckout.createBillingCheckout({
orgId: "org_123",
appSlug: "signal",
productSlug: "signal-growth",
customerEmail: "billing@example.com",
successUrl: "https://signal.apollodeploy.com/settings/billing/success",
returnUrl: "https://signal.apollodeploy.com/settings/billing",
});
return checkout.url;Kotlin:
import com.apollodeploy.billing.sdk.CreateCheckoutRequest
val checkout = billing.billingCheckout.createBillingCheckout(
CreateCheckoutRequest(
orgId = "org_123",
appSlug = "signal",
productSlug = "signal-growth",
customerEmail = "billing@example.com",
successUrl = "https://signal.apollodeploy.com/settings/billing/success",
returnUrl = "https://signal.apollodeploy.com/settings/billing",
),
)
return checkout.urlReturn the checkout URL to the frontend. Do not expose Polar API keys or create Polar sessions from the browser.
Use the billing profile endpoints from the app backend after the user has been authorized for the org. These calls proxy Polar Customer Portal APIs through a short-lived customer session created server-side.
Update billing details:
await billing.billingCustomer.updateCustomerBillingInfo({
orgId: "org_123",
email: "billing@example.com",
billingName: "Apollo Signal Ltd",
billingAddress: {
line1: "123 Market St",
city: "San Francisco",
state: "CA",
postal_code: "94105",
country: "US",
},
taxId: "911144442",
});import com.apollodeploy.billing.sdk.PolarBillingAddressInput
import com.apollodeploy.billing.sdk.UpdateCustomerBillingInfoRequest
billing.billingCustomer.updateCustomerBillingInfo(
UpdateCustomerBillingInfoRequest(
orgId = "org_123",
email = "billing@example.com",
billingName = "Apollo Signal Ltd",
billingAddress = PolarBillingAddressInput(
line1 = "123 Market St",
city = "San Francisco",
state = "CA",
postal_code = "94105",
country = "US",
),
taxId = "911144442",
),
)List saved payment methods:
const methods = await billing.billingCustomer.listCustomerPaymentMethods({
orgId: "org_123",
page: 1,
limit: 10,
});
// methods.paymentMethods.items[].is_default marks the default cardDelete a saved payment method:
await billing.billingCustomer.deleteCustomerPaymentMethod("pm_123", {
orgId: "org_123",
});Polar may reject deleting a payment method that is used by active subscriptions. Surface that as a customer-facing "set another payment method first" action. Do not log full billing profile payloads, tax IDs, or payment method details.
Every internal backend should wrap the generated SDK in a small product-level billing module. The wrapper should set:
- base URL from config,
- short-lived service JWT through the SDK config,
- request timeout,
- structured logging,
- metrics for latency/error/blocked decisions,
- request IDs for trace correlation.
Recommended timeouts:
- enforcement: 300 ms to 800 ms inside latency-sensitive requests,
- entitlements: 1 s to 2 s for dashboard reads,
- checkout: 2 s to 5 s,
- usage ingest: 300 ms to 1 s or async outbox.
TypeScript wrapper shape:
import {
SDKError,
createApolloBillingClient,
} from "@apollo-deploy/billing-sdk";
type BillingCheck = {
type: "quota" | "feature" | "meter";
resource?: string;
limitKey?: string;
feature?: string;
meterKey?: string;
needed?: number;
};
class BillingClient {
constructor(private readonly appSlug: string) {}
private sdk(): ReturnType<typeof createApolloBillingClient> {
return createApolloBillingClient({
baseUrl: process.env.BILLING_API_BASE_URL ?? "https://billing.apollodeploy.com",
defaultHeaders: {
Authorization: `Bearer ${createBillingServiceJwt()}`,
},
});
}
async enforce(orgId: string, check: BillingCheck): Promise<void> {
try {
const decision = await this.sdk().billingEnforcement.enforceBillingCheck({
orgId,
appSlug: this.appSlug,
check,
});
if (!decision.allowed) {
throw new BillingBlockedError("Billing blocked this operation");
}
} catch (error) {
if (error instanceof SDKError) {
if (error.status === 402) throw BillingBlockedError.from(error);
if (error.status === 404) throw new BillingNoSubscriptionError(error);
throw new BillingUnavailableError(error.status, error);
}
throw error;
}
}
async reportUsage(
orgId: string,
eventKey: string,
quantity = 1,
metadata: Record<string, unknown> = {},
): Promise<void> {
await this.sdk().billingUsage.ingestBillingUsage({
orgId,
eventKey,
quantity,
metadata,
});
}
async getEntitlements(orgId: string) {
return this.sdk().billingEntitlements.getBillingEntitlements(
this.appSlug,
orgId,
);
}
}Then expose product-level helpers in TypeScript:
class SignalBilling {
constructor(private readonly client: BillingClient) {}
requireProjectCreate(orgId: string) {
return this.client.enforce(orgId, {
type: "quota",
resource: "project",
limitKey: "maxProjects",
});
}
requireAutomationRuns(orgId: string, needed: number) {
return this.client.enforce(orgId, {
type: "meter",
meterKey: "automationRunBalance",
needed,
});
}
requireAiCredits(orgId: string, needed: number) {
return this.client.enforce(orgId, {
type: "meter",
meterKey: "aiCreditBalance",
needed,
});
}
reportAutomationRuns(orgId: string, quantity: number, requestId: string) {
return this.client.reportUsage(orgId, "signal.automation.run", quantity, {
requestId,
});
}
reportAiCredits(orgId: string, quantity: number, requestId: string) {
return this.client.reportUsage(orgId, "signal.ai.credit.used", quantity, {
requestId,
});
}
}Kotlin wrapper shape:
import com.apollodeploy.billing.sdk.ApolloBillingClient
import com.apollodeploy.billing.sdk.ClientConfig
import com.apollodeploy.billing.sdk.EnforceRequest
import com.apollodeploy.billing.sdk.SdkException
import com.apollodeploy.billing.sdk.UsageIngestRequest
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
class BillingClient(
private val appSlug: String,
) {
private fun sdk(): ApolloBillingClient =
ApolloBillingClient(
ClientConfig(
baseUrl = System.getenv("BILLING_API_BASE_URL") ?: "https://billing.apollodeploy.com",
serviceToken = createBillingServiceJwt(),
),
)
suspend fun enforce(orgId: String, check: JsonElement) {
try {
val decision = sdk().billingEnforcement.enforceBillingCheck(
EnforceRequest(
orgId = orgId,
appSlug = appSlug,
check = check,
),
)
if (!decision.allowed) {
throw BillingBlockedError("Billing blocked this operation")
}
} catch (error: SdkException) {
when (error.status) {
402 -> throw BillingBlockedError(error.message)
404 -> throw BillingNoSubscriptionError(error.message)
else -> throw BillingUnavailableError(error.status, error)
}
}
}
suspend fun reportUsage(
orgId: String,
eventKey: String,
quantity: Int = 1,
metadata: JsonObject? = null,
) {
sdk().billingUsage.ingestBillingUsage(
UsageIngestRequest(
orgId = orgId,
eventKey = eventKey,
quantity = quantity,
metadata = metadata,
),
)
}
suspend fun getEntitlements(orgId: String) =
sdk().billingEntitlements.getBillingEntitlements(
appSlug = appSlug,
orgId = orgId,
)
}
}Then expose product-level helpers in Kotlin:
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class SignalBilling(private val client: BillingClient) {
suspend fun requireProjectCreate(orgId: String) {
client.enforce(
orgId,
buildJsonObject {
put("type", "quota")
put("resource", "project")
put("limitKey", "maxProjects")
},
)
}
suspend fun requireAutomationRuns(orgId: String, needed: Int) {
client.enforce(
orgId,
buildJsonObject {
put("type", "meter")
put("meterKey", "automationRunBalance")
put("needed", needed)
},
)
}
suspend fun requireAiCredits(orgId: String, needed: Int) {
client.enforce(
orgId,
buildJsonObject {
put("type", "meter")
put("meterKey", "aiCreditBalance")
put("needed", needed)
},
)
}
suspend fun reportAutomationRuns(orgId: String, quantity: Int, requestId: String) {
client.reportUsage(
orgId = orgId,
eventKey = "signal.automation.run",
quantity = quantity,
metadata = buildJsonObject {
put("requestId", requestId)
},
)
}
suspend fun reportAiCredits(orgId: String, quantity: Int, requestId: String) {
client.reportUsage(
orgId = orgId,
eventKey = "signal.ai.credit.used",
quantity = quantity,
metadata = buildJsonObject {
put("requestId", requestId)
},
)
}
}Route handlers should call SignalBilling, not BillingClient directly.
Use explicit policy per operation.
Fail closed when allowing the operation could cause material cost, security risk, or contractual exposure:
- paid AI generation,
- build minutes,
- email sends at scale,
- data exports,
- premium compliance features,
- destructive provisioning actions,
- one-time paid unlocks.
If billing is unavailable, return a retryable product error:
{
"code": "billing_unavailable",
"message": "Billing checks are temporarily unavailable. Try again shortly."
}Fail open only for low-cost, read-only, or customer-support-sensitive paths:
- viewing dashboards,
- listing resources,
- reading settings,
- webhook delivery retries,
- low-cost background refreshes.
Log and meter these decisions. Fail-open should be visible in observability, not hidden.
404 billing.no_subscription is not the same as 200 allowed.
Recommended policy:
- If the app has a real free tier, route the operation through free-tier checks or allow only operations that are free-tier safe.
- If the operation requires a paid plan, block and return an upgrade-required response.
- If the app is invitation-only or enterprise-only, treat no subscription as a provisioning error.
Put checks as close as possible to the operation that consumes the entitlement.
Good:
ProjectService.createProject()
requireProjectCreate()
insert project
Risky:
GET /settings page checks quota
POST /projects trusts that the page checked quota earlier
Use route middleware only for broad feature gates. Use service-level guards for quota and usage-sensitive work.
Recommended patterns:
- Check feature gates before showing or running premium paths.
- Check quota before creating persistent resources.
- Check meter balance before expensive metered work.
- Report usage after successful work.
- Use database constraints or transactions for app-owned usage where races can exceed a quota.
Central billing checks are not a substitute for app-level concurrency control.
Example: if an org has 9 projects and a limit of 10, two concurrent
createProject calls can both pass the quota check before either insert
commits.
For hard quotas, combine billing checks with app-side locking or transactional rechecks:
begin transaction
lock organization quota row
count current projects
billing require maxProjects
insert project
commit
For soft quotas, allow small overshoot and reconcile through periodic jobs.
Use hard quotas for:
- resource counts,
- irreversible provisioning,
- abuse-sensitive features.
Use soft quotas for:
- analytics events,
- low-cost counters,
- eventually consistent usage summaries.
For metered operations, distinguish estimated cost from actual cost.
Recommended flow:
- Estimate cost.
- Require enough meter balance for the estimate.
- Run the operation.
- Calculate actual cost.
- Report actual usage.
Example:
const estimatedRuns = estimateAutomationRuns(workflow);
await billing.requireAutomationRuns(orgId, estimatedRuns);
const result = await runAutomation(workflow);
await saveAutomationResult(orgId, result);
const actualRuns = calculateAutomationRuns(result.usage);
await billing.reportAutomationRuns(orgId, actualRuns, requestId);If actual usage can exceed the estimate materially, either add a buffer or break the work into smaller chunks with repeated checks.
Workers need billing checks too. Treat them like product APIs:
- include org ID in every job payload,
- check feature/quota/meter before expensive work,
- stop the job with a blocked status if billing blocks it,
- report usage only after successful completion,
- include job ID in usage metadata.
For scheduled jobs, check billing at execution time, not only when the schedule is created.
Map billing failures to product errors consistently:
| Billing code | Product response |
|---|---|
billing.quota_exceeded |
403 or 402 with upgrade CTA and current/limit values. |
billing.feature_unavailable |
403 or 402 with required feature/plan message. |
billing.no_subscription |
Free-tier fallback, upgrade CTA, or provisioning error. |
billing.unauthenticated |
Internal server error; fix service-token configuration. |
billing.unknown_app |
Internal server error; fix deployment/configuration. |
billing.internal_error |
Retryable billing unavailable error unless operation is fail-open. |
Avoid leaking raw billing internals directly to public clients. Translate them into app-specific messages while preserving a machine-readable code.
Every backend should emit metrics around billing decisions:
billing.enforce.allowedbilling.enforce.blockedbilling.enforce.errorbilling.enforce.latency_msbilling.usage.report.acceptedbilling.usage.report.failedbilling.fail_open.countbilling.checkout.createdbilling.checkout.failed
Log fields:
orgIdappSlugcheckTypelimitKey,feature, ormeterKeybillingStatusbillingCoderequestIdjobIdwhen applicable
Do not log secrets, checkout session tokens, or full customer billing details.
- Keep
INTERNAL_SERVICE_JWT_SECRETserver-side only. - Never call billing internal endpoints from frontend code.
- Never call Polar directly for billing actions that Apollo Billing owns.
- Never hardcode product IDs, meter IDs, prices, or entitlement limits in product apps.
- Use the app backend to create checkout sessions.
- Validate the authenticated user has access to
orgIdbefore calling billing. - Do not accept
appSlugfrom public clients for enforcement. It should be a constant in the app backend. - Do not let users choose arbitrary
eventKeyvalues. Product code should map operations to known event keys. - Use short token TTLs. The billing API defaults to 300 seconds max token age.
- Rotate
INTERNAL_SERVICE_JWT_SECRETthrough deployment config, not code.
- Install and use the generated billing SDK.
- Create a typed product-level billing wrapper around the SDK.
- Make
appSluga constant. - Add product-level guard helpers.
- Add product-level usage reporter helpers.
- Define fail-open/fail-closed policy per operation.
- Add billing checks to write paths and expensive background jobs.
- Add post-commit usage reporting.
- Add an outbox for usage events that must be retried exactly.
- Map billing errors to product API errors.
- Add metrics and structured logs.
- Add integration tests with mocked billing responses:
200allowed,402blocked,404no subscription,500fail-open/fail-closed behavior,- usage ingest accepted/unavailable.
export async function createProject(req: Request) {
const user = await requireUser(req);
const orgId = await requireOrgAccess(user, req.params.orgId);
await signalBilling.requireProjectCreate(orgId);
const project = await projects.create({
orgId,
name: req.body.name,
});
return Response.json(project);
}export async function generateEmail(req: Request) {
const user = await requireUser(req);
const orgId = await requireOrgAccess(user, req.params.orgId);
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const estimatedRuns = 1;
await signalBilling.requireAutomationRuns(orgId, estimatedRuns);
const result = await automations.run(req.body);
await automations.saveRunResult(orgId, result);
await signalBilling.reportAutomationRuns(orgId, result.runsUsed, requestId);
return Response.json(result);
}This is the operating rule: enforce before you spend or create, meter after you successfully spend or create, and keep all billing behavior behind one local backend module per app.