Skip to content

Upgrading, the billing portal and the Stripe webhook

Two endpoints, and neither of them charges anybody. POST /v1/billing/checkout returns a link to a Stripe-hosted page; POST /v1/billing/portal returns a link to Stripe’s customer portal. The card, the plan change and the cancellation all happen on Stripe. Galley finds out what happened from a signed webhook, and that webhook is the only thing in the product that can change what an account is entitled to.

This is not squeamishness. Galley’s API keys have no write access to subscriptions, refunds, coupons or payouts, so the API could not cancel your plan if it were asked to. An agent holding your key can offer you an upgrade; it cannot buy one, cancel one, or talk itself into a discount.

POST /v1/billing/checkout
curl -sS https://api.galleyrender.com/v1/billing/checkout \
-H "authorization: Bearer $GALLEY_API_KEY" \
-H "content-type: application/json" \
-d '{ "plan": "solo", "interval": "month" }'
200 OK
{
"object": "checkout_session",
"id": "cs_test_a1b2c3",
"url": "https://checkout.stripe.com/c/pay/cs_test_a1b2c3",
"expires_at": "2026-09-17T18:40:11.000Z",
"account": "acct_hj8kpw14xk1v",
"plan": "solo",
"interval": "month",
"price_usd": 5,
"included_renders": 500,
"overage_usd_per_render": 0.006,
"current_plan": "free",
"next_step": "Open `url` in a browser and complete the checkout. …"
}

included_renders and overage_usd_per_render keep the names they have always had and count what Stripe’s meter counts, where an image is one and a PDF page is 2.5: Solo’s 500 is 200 PDF pages or 500 images, and its $0.006 is the price of one image past them (a page is $0.015).

Field
plansolo, starter, growth or scale. There is no free — see Downgrading.
intervalmonth (default) or year. Annual is ten months for twelve.
success_url, cancel_urlOptional. Must be https on galleyrender.com; anything else is refused, because an open redirect off a payment page is a ready-made phishing step.

The session carries two prices: the plan’s flat price and its metered overage price on the same interval. Stripe Tax is calculated on the page and a billing address is collected. No promotion-code box is offered on any Galley checkout, ever.

Your plan has not changed when this returns. It changes when Stripe delivers checkout.session.completed and customer.subscription.created to the webhook below, a second or two after someone finishes on that page. Poll GET /v1/account and read subscription.status.

The account needs a verified email address. Invoices, receipts and every dunning message go to it, so a keyless trial — whose address is synthetic — gets a 403 permission_error naming the call that fixes it:

403 Forbidden
{
"error": {
"type": "permission_error",
"message": "This account has not verified an email address, so it cannot be billed yet.",
"details": {
"trial": true,
"next_step": "Call the `create_account` MCP tool with an email address … then try again."
}
}
}
POST /v1/billing/portal
curl -sS https://api.galleyrender.com/v1/billing/portal \
-H "authorization: Bearer $GALLEY_API_KEY" \
-X POST

Returns a single-use, short-lived url to Stripe’s customer portal, where a human can switch between Solo, Starter, Growth and Scale, update a card, download invoices and cancel at period end. Fetch a fresh link each time rather than storing one.

Before the first checkout there is no Stripe customer and therefore no portal, and the call returns 404 not_found pointing at /v1/billing/checkout.

Both happen in the portal and nowhere else. plan: "free" is not a valid checkout, and the API has no cancel endpoint to call. A cancellation is scheduled for the end of the period you have paid for: subscription.cancel_at_period_end reads true, the plan keeps working, and the account drops to Free on subscription.current_period_end.

Two tools wrap these, for an agent that has just been refused:

Tool
upgrade{ "plan": "solo", "interval": "month" } → a Checkout URL to hand to a human
billing_portal{} → a portal URL to hand to a human

Both return a link and say so. Neither can complete a purchase, and billing_portal is the only answer an agent has to “cancel my plan”, by construction.

POST /v1/stripe/webhook is Stripe’s endpoint, not yours. It is the only unauthenticated write path in the API, and the Stripe-Signature header stands in for authentication: the raw body is verified against the endpoint’s signing secret, with a five-minute timestamp tolerance, before a byte of it is parsed. An unsigned, wrongly-signed or stale delivery is refused and changes nothing.

EventWhat it sets
checkout.session.completedBinds the Stripe customer to the account (client_reference_id is your account id) and records the subscription the checkout created.
customer.subscription.created / .updatedplan, plan_interval, included units (twelve months’ worth on an annual plan), retention days, subscription_status, the billing period (current_period_start and current_period_end), cancel_at_period_end. The plan is taken from the subscription’s flat price; the metered price names no tier. The billing period is what the spend cap and GET /v1/usage count over.
customer.subscription.deletedDrops to Free and clears the entitlement.
invoice.paidClears a past_due back to active.
invoice.payment_failedRecords past_due.

Three properties worth knowing:

  • It is idempotent on the Stripe event id. Stripe delivers at least once and orders nothing; a redelivery is recorded and ignored.
  • past_due does not cut you off. While Stripe is still retrying a card, the plan keeps working. Only unpaid — where Stripe has given up — and a cancellation drop an account to Free.
  • It never calls Stripe back. The handler’s entire output is an update to one row of our database. Nothing in Galley can cancel, refund or modify a subscription.
GET /v1/account (excerpt)
{
"plan": "solo",
"subscription": {
"plan": "solo",
"plan_name": "Solo",
"interval": "month",
"status": "active",
"included_units": 500,
"retention_days": 30,
"current_period_end": "2026-10-17T18:40:11.000Z",
"cancel_at_period_end": false,
"has_stripe_customer": true,
"features": { "webhooks": true, "cloud_delivery": true }
}
}

included_units is the same quantity as included_renders above on a monthly plan — Solo’s 500 is 200 PDF pages or 500 images — and twelve times it on an annual plan, whose billing period is a year. status is Stripe’s own vocabulary, unchanged: active, trialing, past_due, unpaid, canceled, incomplete, incomplete_expired, paused. null means the account has never subscribed.

A Free account asking for a paid feature — a webhook_url, or a cloud delivery option — is refused with 403 plan_required rather than rendering something that quietly does not deliver:

403 Forbidden
{
"error": {
"type": "plan_required",
"message": "The Free plan does not include webhooks. `webhook_url` needs Solo ($5.00/month) or above.",
"details": {
"feature": "webhooks",
"plan": "free",
"required_plan": "solo",
"required_plan_price_usd": 5,
"next_step": "Remove `webhook_url` from the request to render on Free, or upgrade at https://galleyrender.com/pricing."
}
}
}

required_plan is always the cheapest plan that carries the feature, so an agent can act on it without a pricing table. There are two ways out and the error names both: drop the option, or upgrade. Running out of renders is a different error — quota_exceeded, covered with the spend cap in Spend caps and limits.

/dashboard reads all of the above from a key you paste into it, and has buttons for checkout, the portal and rotating your webhook secret. It is a static page: the key is kept in your own browser and sent only to api.galleyrender.com.