Billing
The Billing OpenAPI tag covers catalog plans, per-organization subscription surfaces (/orgs/{id}/billing/...), operator quota overrides (/admin/quota_overrides), and provider webhooks (POST /billing/webhooks/{provider} with application/octet-stream raw bodies). Shapes include PlansResponse, BillingPlanResponse, QuotaReport, CheckoutRequest / SubscriptionRef, PortalRequest / PortalResponse, QuotaOverrideRequest, BillingWebhookResponse — see the API reference.
Auth: Many routes omit an explicit security block in the published bundle; assume bearer_auth where your gateway applies API keys, and treat /admin/... as operator-only in production. Webhook posts are usually verified by provider signatures at the edge, not end-user SDK keys.
Org context: Org-scoped billing routes encode the organization id in the path (/orgs/{id}/billing/...); they do not use X-Org in the canonical YAML (unlike Devices lists).
Operations vs wire routes
Section titled “Operations vs wire routes”| Concern | HTTP | operationId | Notes |
|---|---|---|---|
| Plan catalog | GET /plans | get_plans | PlansResponse. |
| Current plan | GET /orgs/{id}/billing/plan | get_org_billing_plan | BillingPlanResponse. |
| Upgrade options | GET /orgs/{id}/billing/upgrade-options | get_org_billing_upgrade_options | PlansResponse. |
| Usage / quotas | GET /orgs/{id}/billing/usage | get_org_billing_usage | QuotaReport. |
| Checkout session | POST /orgs/{id}/billing/checkout | post_org_billing_checkout | Body CheckoutRequest → SubscriptionRef. |
| Customer portal URL | POST /orgs/{id}/billing/portal | post_org_billing_portal | Body PortalRequest → PortalResponse. 501 if the provider has no portal. |
| Quota overrides (admin) | GET / POST /admin/quota_overrides | get_admin_quota_overrides, post_admin_quota_override | QuotaOverrideRequest on POST. |
| Provider webhook | POST /billing/webhooks/{provider} | post_billing_webhook | Raw application/octet-stream body; BillingWebhookResponse. Normally called by the billing provider, not app code. |
SDK coverage
Section titled “SDK coverage”| Surface | Python / Rust (openapp_sdk) | Go | TypeScript (AsyncClient) |
|---|---|---|---|
| Named sub-client | Not shipped — call paths via the core bridge (AsyncClient._request in Python, transport() + RequestSpec in Rust) | Full BillingAPIService | Not on façade |
Go returns typed JSON for most billing calls; GetAdminQuotaOverrides / PostAdminQuotaOverride return raw http.Response (decode yourself). PostBillingWebhook expects an os.File (or compatible stream) for the octet-stream body.
Typical errors
Section titled “Typical errors”401 / 403 when the caller lacks billing or org permissions. post_org_billing_portal may return 501. Provider errors follow ApiErrorResponse where returned — see Errors & retries.
Examples
Section titled “Examples”List public plans (GET /plans)
Section titled “List public plans (GET /plans)”There is no client.billing yet — use the internal JSON dispatcher on AsyncClient (same transport as sub-clients):
plans = await client._request("GET", "/plans")GetPlans returns PlansResponse.
import ( "context"
openapiclient "github.com/tomers/openapp-sdk/go")
plans, httpResp, err := client.BillingAPI.GetPlans(context.Background()).Execute()if err != nil { return err}defer httpResp.Body.Close()_ = plansuse openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;use serde_json::Value;
let client = Client::builder() .api_key("https://api.openapp.house/api/v1_openapp_YOUR_SECRET") .build()?;
let plans: Value = client .transport() .request_json(RequestSpec { method: Method::GET, path: "/plans", ..Default::default() }) .await?;const plans = await fetch("https://api.openapp.house/api/v1/plans", { headers: { authorization: "Bearer v1_openapp_YOUR_SECRET" },}).then((r) => r.json());/plans is the public catalog — the bundle omits a security block, but include the API key when the gateway requires authentication. Replace with AsyncClient.getPlans once the Node façade exposes it.
Get an organization’s billing plan
Section titled “Get an organization’s billing plan”plan = await client._request("GET", f"/orgs/{org_id}/billing/plan")plan, httpResp, err := client.BillingAPI.GetOrgBillingPlan(context.Background(), orgId).Execute()if err != nil { return err}defer httpResp.Body.Close()_ = planuse openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;use serde_json::Value;
let path = format!("/orgs/{org_id}/billing/plan");let plan: Value = client .transport() .request_json(RequestSpec { method: Method::GET, path: &path, ..Default::default() }) .await?;const plan = await fetch( `https://api.openapp.house/api/v1/orgs/${orgId}/billing/plan`, { headers: { authorization: "Bearer v1_openapp_YOUR_SECRET" } },).then((r) => r.json() as Promise<{ tier_slug: string; status: string }>);The org id rides in the path — billing routes do not consume X-Org in the canonical bundle. Replace with AsyncClient.getOrgBillingPlan once the Node façade exposes it.
Org billing usage (GET /orgs/{id}/billing/usage)
Section titled “Org billing usage (GET /orgs/{id}/billing/usage)”Returns a QuotaReport (per-key usage vs limits for the org).
usage = await client._request("GET", f"/orgs/{org_id}/billing/usage")GetOrgBillingUsage returns QuotaReport.
import ( "context"
openapiclient "github.com/tomers/openapp-sdk/go")
usage, httpResp, err := client.BillingAPI.GetOrgBillingUsage(context.Background(), orgId).Execute()if err != nil { return err}defer httpResp.Body.Close()_ = usageuse openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;use serde_json::Value;
let client = Client::builder() .api_key("https://api.openapp.house/api/v1_openapp_YOUR_SECRET") .build()?;
let path = format!("/orgs/{org_id}/billing/usage");let usage: Value = client .transport() .request_json(RequestSpec { method: Method::GET, path: &path, ..Default::default() }) .await?;const usage = await fetch( `https://api.openapp.house/api/v1/orgs/${orgId}/billing/usage`, { headers: { authorization: "Bearer v1_openapp_YOUR_SECRET" } },).then((r) => r.json());Body matches QuotaReport — keys are QuotaKey values (e.g. scripting_executions). Replace with AsyncClient.getOrgBillingUsage once the Node façade exposes it.
Org upgrade options (GET /orgs/{id}/billing/upgrade-options)
Section titled “Org upgrade options (GET /orgs/{id}/billing/upgrade-options)”Eligible upgrade tiers for the org (same PlansResponse shape as the public catalog).
options = await client._request("GET", f"/orgs/{org_id}/billing/upgrade-options")GetOrgBillingUpgradeOptions returns PlansResponse.
plans, httpResp, err := client.BillingAPI.GetOrgBillingUpgradeOptions(context.Background(), orgId).Execute()if err != nil { return err}defer httpResp.Body.Close()_ = plansuse openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;use serde_json::Value;
let client = Client::builder() .api_key("https://api.openapp.house/api/v1_openapp_YOUR_SECRET") .build()?;
let path = format!("/orgs/{org_id}/billing/upgrade-options");let options: Value = client .transport() .request_json(RequestSpec { method: Method::GET, path: &path, ..Default::default() }) .await?;const options = await fetch( `https://api.openapp.house/api/v1/orgs/${orgId}/billing/upgrade-options`, { headers: { authorization: "Bearer v1_openapp_YOUR_SECRET" } },).then((r) => r.json());Same PlansResponse shape as /plans — the difference is the server filters to tiers the org is eligible to upgrade to. Replace with AsyncClient.getOrgBillingUpgradeOptions once the Node façade exposes it.
Start checkout (POST /orgs/{id}/billing/checkout)
Section titled “Start checkout (POST /orgs/{id}/billing/checkout)”Body CheckoutRequest — required tier_slug (see OpenAPI). Response SubscriptionRef.
sub = await client._request( "POST", f"/orgs/{org_id}/billing/checkout", {"tier_slug": "pro"},)import ( "context"
openapiclient "github.com/tomers/openapp-sdk/go")
ref, httpResp, err := client.BillingAPI.PostOrgBillingCheckout(context.Background(), orgId). CheckoutRequest(openapiclient.CheckoutRequest{TierSlug: "pro"}). Execute()if err != nil { return err}defer httpResp.Body.Close()_ = refuse openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;use serde_json::{json, Value};
let client = Client::builder() .api_key("https://api.openapp.house/api/v1_openapp_YOUR_SECRET") .build()?;
let path = format!("/orgs/{org_id}/billing/checkout");let body = json!({ "tier_slug": "pro" });let sub: Value = client .transport() .request_json(RequestSpec { method: Method::POST, path: &path, body: Some(&body), ..Default::default() }) .await?;const sub = await fetch( `https://api.openapp.house/api/v1/orgs/${orgId}/billing/checkout`, { method: "POST", headers: { authorization: "Bearer v1_openapp_YOUR_SECRET", "content-type": "application/json", }, body: JSON.stringify({ tier_slug: "pro" }), },).then((r) => r.json() as Promise<{ subscription_id: string; checkout_url?: string }>);Body matches CheckoutRequest; the response is SubscriptionRef — when the provider returns a hosted-checkout URL, redirect the browser to sub.checkout_url. Replace with AsyncClient.postOrgBillingCheckout once the Node façade exposes it.
Customer billing portal URL (POST /orgs/{id}/billing/portal)
Section titled “Customer billing portal URL (POST /orgs/{id}/billing/portal)”Body PortalRequest with return_url. Response PortalResponse (url). 501 when the provider exposes no self-service portal — see Typical errors.
portal = await client._request( "POST", f"/orgs/{org_id}/billing/portal", {"return_url": "https://myapp.example/billing-return"},)import ( "context"
openapiclient "github.com/tomers/openapp-sdk/go")
portal, httpResp, err := client.BillingAPI.PostOrgBillingPortal(context.Background(), orgId). PortalRequest(openapiclient.PortalRequest{ ReturnUrl: "https://myapp.example/billing-return", }). Execute()if err != nil { return err}defer httpResp.Body.Close()_ = portaluse openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;use serde_json::{json, Value};
let client = Client::builder() .api_key("https://api.openapp.house/api/v1_openapp_YOUR_SECRET") .build()?;
let path = format!("/orgs/{org_id}/billing/portal");let body = json!({ "return_url": "https://myapp.example/billing-return" });let portal: Value = client .transport() .request_json(RequestSpec { method: Method::POST, path: &path, body: Some(&body), ..Default::default() }) .await?;const res = await fetch( `https://api.openapp.house/api/v1/orgs/${orgId}/billing/portal`, { method: "POST", headers: { authorization: "Bearer v1_openapp_YOUR_SECRET", "content-type": "application/json", }, body: JSON.stringify({ return_url: "https://myapp.example/billing-return" }), },);
if (res.status === 501) { // Provider does not expose a self-service portal. return;}const portal = (await res.json()) as { url: string };window.location.assign(portal.url);Watch for 501 when the provider has no portal — surface it to the user instead of treating it as a transport error. Replace with AsyncClient.postOrgBillingPortal once the Node façade exposes it.
List quota overrides (operator) (GET /admin/quota_overrides)
Section titled “List quota overrides (operator) (GET /admin/quota_overrides)”Operator-only — requires privileges your deployment maps to this route. The published 200 response has no fixed schema in the bundle; treat the body as JSON and decode it in your process.
overrides = await client._request("GET", "/admin/quota_overrides")GetAdminQuotaOverrides returns a raw http.Response — read and json.Unmarshal the body yourself.
import ( "context" "encoding/json" "io"
openapiclient "github.com/tomers/openapp-sdk/go")
resp, err := client.BillingAPI.GetAdminQuotaOverrides(context.Background()).Execute()if err != nil { return err}defer resp.Body.Close()body, err := io.ReadAll(resp.Body)if err != nil { return err}var decoded anyif err := json.Unmarshal(body, &decoded); err != nil { return err}_ = decodeduse openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;use serde_json::Value;
let client = Client::builder() .api_key("https://api.openapp.house/api/v1_openapp_YOUR_SECRET") .build()?;
let overrides: Value = client .transport() .request_json(RequestSpec { method: Method::GET, path: "/admin/quota_overrides", ..Default::default() }) .await?;const overrides = await fetch( "https://api.openapp.house/api/v1/admin/quota_overrides", { headers: { authorization: "Bearer v1_openapp_OPERATOR_SECRET" } },).then((r) => r.json());Use an operator-scoped API key — gateway policy must permit /admin/.... The bundle does not pin a response schema; treat overrides as unknown and validate at the call site. Replace with AsyncClient.getAdminQuotaOverrides once the Node façade exposes it.
Create or update quota override (operator) (POST /admin/quota_overrides)
Section titled “Create or update quota override (operator) (POST /admin/quota_overrides)”Body QuotaOverrideRequest — subject_type, subject_id, quota_key, limit_value are required; expires_at, period, reason optional (see OpenAPI).
await client._request( "POST", "/admin/quota_overrides", { "subject_type": "org", "subject_id": org_id, "quota_key": "scripting_executions", "limit_value": 10_000, "reason": "support escalation", },)PostAdminQuotaOverride returns a raw http.Response — read the body and json.Unmarshal when your deployment returns JSON (mirror the GetAdminQuotaOverrides example earlier on this page).
import ( "context" "encoding/json" "io"
openapiclient "github.com/tomers/openapp-sdk/go")
orgID := "01HORG00000000000000000000"req := openapiclient.NewQuotaOverrideRequest( 10_000, "scripting_executions", orgID, "org",)req.SetReason("support escalation")
httpResp, err := client.BillingAPI.PostAdminQuotaOverride(context.Background()). QuotaOverrideRequest(*req). Execute()if err != nil { return err}defer httpResp.Body.Close()
body, err := io.ReadAll(httpResp.Body)if err != nil { return err}var decoded anyif err := json.Unmarshal(body, &decoded); err != nil { return err}_ = decodeduse openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;use serde_json::{json, Value};
let client = Client::builder() .api_key("https://api.openapp.house/api/v1_openapp_YOUR_SECRET") .build()?;
let org_id = "01HORG00000000000000000000";let body = json!({ "subject_type": "org", "subject_id": org_id, "quota_key": "scripting_executions", "limit_value": 10000_i64, "reason": "support escalation",});let _out: Value = client .transport() .request_json(RequestSpec { method: Method::POST, path: "/admin/quota_overrides", body: Some(&body), ..Default::default() }) .await?;await fetch("https://api.openapp.house/api/v1/admin/quota_overrides", { method: "POST", headers: { authorization: "Bearer v1_openapp_OPERATOR_SECRET", "content-type": "application/json", }, body: JSON.stringify({ subject_type: "org", subject_id: orgId, quota_key: "scripting_executions", limit_value: 10_000, reason: "support escalation", }),});Body matches QuotaOverrideRequest — required fields are subject_type, subject_id, quota_key, limit_value; optional expires_at, period, reason. Replace with AsyncClient.postAdminQuotaOverride once the Node façade exposes it.
Provider webhook (POST /billing/webhooks/{provider})
Section titled “Provider webhook (POST /billing/webhooks/{provider})”Normally invoked by the billing provider, not your application SDK — verify signatures at the edge. Payload is raw application/octet-stream.
Prefer the provider’s HTTP target in infrastructure. If you must forward bytes through core-backed HTTP, use httpx / aiohttp with your API base URL, Authorization, Content-Type: application/octet-stream, and the raw webhook body — do not run this on the public SDK façade unless you accept binary handling limits.
PostBillingWebhook takes a stream via Body(*os.File). Point it at a file containing the exact provider payload bytes (for integration tests or a sidecar relay).
import ( "context" "os"
openapiclient "github.com/tomers/openapp-sdk/go")
f, err := os.Open("/path/to/captured-provider-payload.bin")if err != nil { return err}defer f.Close()
events, httpResp, err := client.BillingAPI.PostBillingWebhook(context.Background(), "stripe"). Body(f). Execute()if err != nil { return err}defer httpResp.Body.Close()_ = eventsThere is no high-level billing webhook helper on openapp_sdk today. Forward the verified raw bytes with reqwest (or your edge) at POST /api/v1/billing/webhooks/{provider} with the same auth the gateway expects for operator/provider routes.
import { readFile } from "node:fs/promises";
const payload = await readFile("/path/to/captured-provider-payload.bin");const events = await fetch( "https://api.openapp.house/api/v1/billing/webhooks/stripe", { method: "POST", headers: { authorization: "Bearer v1_openapp_OPERATOR_SECRET", "content-type": "application/octet-stream", }, body: payload, },).then((r) => r.json());For a Web Fetch body in browsers/edge runtimes, use new Blob([bytes]) or pass the ReadableStream directly — the request must keep the provider’s exact bytes for signature verification at the edge. Almost always drive this path from a sidecar or relay rather than the public AsyncClient façade; the helper is intentionally not on the roadmap.
Related
Section titled “Related”Org CRUD and membership are documented under Organizations. For quota keys referenced in usage reports, see QuotaKey in OpenAPI (for example scripting_executions on Scripting).