> ## Documentation Index
> Fetch the complete documentation index at: https://docs.custral.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Bill from your product

> Put an Upgrade button in your own app: create a quote in code, send the customer to its hosted page, and hear back when they pay.

This guide wires billing into **your own product**. Your app's Upgrade button creates a quote through the `/v1` API, sends the customer to Custral's hosted quote page, and learns the outcome by webhook. The customer's card or bank details go to Stripe's page, the subscription runs on your workspace's Stripe account, and the quote, invoices and payments land on the customer's record in Custral.

No endpoint charges a card, bills an invoice or changes a live subscription. Money moves only when the customer accepts or pays.

## Before you start

<Steps>
  <Step title="Set up payments">
    An admin completes [Settings → Payments](/revenue/payments): a Products object, a Customers object, Stripe connected, and payment defaults. Connect a Stripe **test-mode** account while you build, so Stripe's test cards work and nobody is charged.
  </Step>

  <Step title="Pick a customer key">
    On the **Developers** tab of **Settings → Payments**, set **Customer key** to the field on your Customers object that holds your app's own customer id (an `Account ID`, say). Then you can name customers by your id instead of a Custral record id.
  </Step>

  <Step title="Allow your return domain">
    Add your app's host (for example `app.example.com`) to **Allowed return domains**. A `returnUrl` on any other host is refused.
  </Step>

  <Step title="Create an API key">
    In **Settings → Applications**, create a secret key with the `quotes:write` and `billing:read` scopes. Add `billing:portal` if you'll open billing pages (step 6).
  </Step>

  <Step title="Register a webhook endpoint">
    Subscribe it to the [quote and billing events](/dev/webhooks/events/quotes-and-billing) you need. See the [webhooks quickstart](/dev/webhooks/quickstart).
  </Step>
</Steps>

## 1. Create the quote

When the customer clicks Upgrade, your server creates a quote with [`POST /v1/quotes`](/dev/api-reference/quotes#create-a-quote):

```ts theme={null}
import {Custral} from "@custral/sdk";

const custral = new Custral({
  apiKey: process.env.CUSTRAL_API_KEY!,
  webhookSecret: process.env.CUSTRAL_WEBHOOK_SECRET!,
});

app.post("/upgrade", async (req, res) => {
  const quote = await custral.quotes.create({
    customer: req.user.accountId, // your own id, e.g. "acct_123"
    createCustomer: {name: req.user.companyName, email: req.user.email},
    products: [{product: "Pro plan", quantity: req.body.seats}],
    contactName: req.user.name,
    contactEmail: req.user.email,
    returnUrl: "https://app.example.com/billing",
    send: true,
  });

  res.redirect(303, quote.url);
});
```

* `customer` is your app's id (matched through the customer key) or a Custral record id (`rec_…`).
* `createCustomer` creates the customer record when none matches, with your id as its key. Without it, an unknown customer is refused with `customer_not_found`.
* `products` names catalog products by record id or exact name. `unitPrice` sets a custom price for this deal, in **major** units (`49.5` is \$49.50).
* `send: true` marks the quote sent in the same call, so its `url` opens right away. **Sending emails nobody.** You hand the customer the link yourself.
* A `returnUrl` must be `https` (`http` only for `localhost`), and its host must be in Allowed return domains. A refused URL is checked before anything is written, so no quote is left behind.

Discounts, way to pay and expiry are optional; see the [full parameter list](/dev/api-reference/quotes#create-a-quote).

## 2. Send the customer to the hosted page

Redirect to `quote.url`. The page is branded with your workspace and needs no sign-in. The customer reviews the lines and what they'll be charged when, types their name and email, and accepts.

* **Autopay** quotes hand over to Stripe's hosted page, where the customer adds a card or bank account. The quote is accepted only once it's saved.
* **Send invoices** quotes are accepted straight away, and Stripe emails each invoice with a pay link.

## 3. The customer comes back

After accepting, the page shows "Quote accepted" with a **Continue** button. It takes the customer to your `returnUrl` with two parameters added:

```
https://app.example.com/billing?quote=Q-0007&status=accepted
```

Any query string or fragment already on your `returnUrl` is kept. Use the return to show a page ("Thanks, you're on Pro"), not to grant access: a customer can close the tab before they click Continue.

## 4. Hear the outcome by webhook

Grant access when the webhook arrives. Custral starts the subscription on Stripe right after acceptance, and the events follow:

```ts theme={null}
custral.on("quote.accepted", async (event) => {
  // {quoteId, quoteNumber, status, recordId, url, contactName, contactEmail}
  await markUpgradePending(event.data.recordId);
});

custral.on("subscription.started", async (event) => {
  // {recordId, plan, status, seats, mrr, currency, interval, trialEnd, cancelAtPeriodEnd}
  await activatePlan(event.data.recordId, event.data.plan);
});

custral.on("payment.failed", async (event) => {
  // {recordId, invoiceNumber, amountDue, currency, payUrl, attemptCount, ...}
  await showPaymentBanner(event.data.recordId, event.data.payUrl);
});

app.post("/webhooks/custral", express.raw({type: "application/json"}), custral.webhooks.express());
```

Delivery is at-least-once and events don't arrive in a guaranteed order, so make listeners idempotent. Events carry the Custral `recordId`, not your own id; keep the mapping, or read billing back by your id as below.

## 5. Read billing back

Whenever your app needs the current state, read it live from Stripe with [`GET /v1/customers/{customer}/billing`](/dev/api-reference/billing#a-customers-billing):

```ts theme={null}
const billing = await custral.billing.get({customer: "acct_123"});

const active = billing.subscriptions.find((s) => s.status === "active");
const overdue = billing.invoices.filter((i) => i.overdue);
```

It returns the customer's quotes, subscriptions (with MRR, discount and way to pay), invoices (with each one's `hostedUrl` pay link) and upcoming instalments. For the workspace as a whole, `custral.billing.invoices({period: "30d"})` returns receivables with totals per currency, and `custral.billing.metrics()` returns MRR, ARR and net revenue retention.

## 6. Give customers a billing page

Once a customer is paying, they need somewhere to see their plan, change their card, read invoices, switch plans and cancel. Custral hosts that page; your server opens it for one customer at a time.

Add `billing:portal` to your key, then open a session when the customer clicks Billing in your app:

```ts theme={null}
app.get("/billing", async (req, res) => {
  const session = await custral.billing.session({
    customer: req.user.accountId,
    returnUrl: "https://app.example.com/settings",
  });
  res.redirect(303, session.url);
});
```

The session returns `url`, `token` and `expiresAt`. It lasts **15 minutes**, so open one per visit rather than storing it. The page shows what you turned on in **Revenue › Billing page** (or with [`PATCH /v1/billing/page`](/dev/api-reference/billing#billing-page-settings)), and its **Back** link goes to `returnUrl`.

### Or draw it inside your app

Pass the token to `CustralBilling.mount` from [`@custral/js`](/dev/sdks/browser) instead of redirecting:

```ts theme={null}
import {CustralBilling} from "@custral/js";

const billing = CustralBilling.mount("#billing", {
  token: session.token,
  onTokenExpired: async () => (await fetch("/billing/token").then((r) => r.json())).token,
});
```

The frame grows to fit the page, so your app shows no scrollbar inside it. When the customer adds a card or chooses a plan, Stripe's page opens at the top level and sends them back to the page that mounted the widget, which confirms the change. `onTokenExpired` is optional. With it, the widget swaps in the fresh token and reloads the frame; without it, the page says the session has ended.

### What the customer can do there

| Action | What happens |
| - | - |
| Change plan | Shows the exact prorated amount first. An upgrade is charged now. A downgrade starts at the period end unless **Downgrade timing** is Immediately. Moving between monthly and yearly is always charged now. |
| Choose a plan | A customer with no subscription goes through Stripe Checkout and comes back subscribed. |
| Update card | Opens Stripe's page in setup mode. The card becomes the default for every subscription. |
| Cancel | Ends at the period end. Nothing is refunded, and the reason (if you ask for one) is recorded in Stripe. |

Your server can cancel or keep a plan too, without the page:

```ts theme={null}
await custral.billing.cancel({customer: "acct_123", reason: "too_expensive"});
await custral.billing.resume({customer: "acct_123"});
```

## SDK methods at a glance

| SDK | Route | Scope |
| - | - | - |
| `quotes.create(params)` | `POST /v1/quotes` | `quotes:write` |
| `quotes.list({customer?, status?})` | `GET /v1/quotes` | `billing:read` |
| `quotes.retrieve(id)` | `GET /v1/quotes/{id}` | `billing:read` |
| `quotes.update(id, params)` | `PATCH /v1/quotes/{id}` (drafts only) | `quotes:write` |
| `quotes.send(id)` | `POST /v1/quotes/{id}/send` | `quotes:write` |
| `quotes.void(id)` | `POST /v1/quotes/{id}/void` | `quotes:write` |
| `billing.get({customer})` | `GET /v1/customers/{customer}/billing` | `billing:read` |
| `billing.invoices(params)` | `GET /v1/invoices` | `billing:read` |
| `billing.subscriptions({customer})` | `GET /v1/subscriptions` | `billing:read` |
| `billing.metrics()` | `GET /v1/revenue/metrics` | `billing:read` |
| `billing.session({customer, returnUrl?})` | `POST /v1/customers/{customer}/billing-sessions` | `billing:portal` |
| `billing.cancel({customer, reason?, comment?})` | `POST /v1/customers/{customer}/subscriptions/cancel` | `billing:portal` |
| `billing.resume({customer})` | `POST /v1/customers/{customer}/subscriptions/resume` | `billing:portal` |

## Common refusals

| Code | Fix |
| - | - |
| `return_url_not_allowed` | Add the host to Settings → Payments → Developers → Allowed return domains, exactly as it appears in the URL. |
| `customer_key_not_set` | Pick a customer key on the Developers tab of Settings → Payments before using `createCustomer`. |
| `stripe_not_connected` | An autopay quote can't be sent until Stripe is connected. |
| `quote_product_not_found` | The product isn't in the catalog, or two products share the name. Pass the record id. |

See the [Quote object](/dev/api-reference/quotes#errors) for the full list.

To set up Stripe, the catalog and the billing page itself from code, see [Set up billing from code](/dev/guides/set-up-billing-from-code).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.