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

# Implement Member-Based Seat Billing

> A complete walkthrough for building team products with seat-based pricing

export const RubaSocials = () => {
  if (typeof document === "undefined") {
    return null;
  }
  const inject = () => {
    const footer = document.getElementById("footer");
    if (!footer || document.getElementById("ruba-socials")) {
      return false;
    }
    const isDark = document.querySelector("html.dark") || document.querySelector('html[class*="dark"]');
    const lineColor = isDark ? "#383838" : "#e8e8ed";
    const iconColor = isDark ? "#8e8e93" : "#86868b";
    const markColor = isDark ? "#ffffff" : "#08080c";
    const hoverColor = "#0071e3";
    const div = document.createElement("div");
    div.id = "ruba-socials";
    div.style.cssText = `display:flex;align-items:center;gap:20px;padding-top:20px;margin-top:20px;border-top:1px solid ${lineColor};width:100%`;
    const logo = `<a href="https://getruba.com" style="display:inline-flex;height:24px;align-items:center;gap:8px;text-decoration:none;color:${markColor};line-height:1;"><svg width="24" height="24" viewBox="0 0 120 120" aria-hidden="true" style="display:block;flex:none;"><g transform="translate(2.5 0)"><path d="M19 25h59L65.43 47H19a4 4 0 0 1-4-4V29a4 4 0 0 1 4-4Z" fill="${markColor}"/><path d="M19 73h31.57L38 95H19a4 4 0 0 1-4-4V77a4 4 0 0 1 4-4Z" fill="${markColor}"/><path d="M85 25h25L70 95H45Z" fill="#007AFF"/></g></svg><span style="display:inline-flex;height:24px;align-items:center;font-weight:600;font-size:15px;color:${markColor};letter-spacing:-0.01em;line-height:1;">Ruba</span></a>`;
    const spacer = `<div style="flex:1"></div>`;
    const ig = `<a href="https://instagram.com/getruba" target="_blank" rel="noopener" aria-label="Instagram" style="display:flex;align-items:center;color:${iconColor};transition:color 0.15s ease;" onmouseover="this.style.color='${hoverColor}'" onmouseout="this.style.color='${iconColor}'"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"/></svg></a>`;
    const li = `<a href="https://linkedin.com/company/getruba" target="_blank" rel="noopener" aria-label="LinkedIn" style="display:flex;align-items:center;color:${iconColor};transition:color 0.15s ease;" onmouseover="this.style.color='${hoverColor}'" onmouseout="this.style.color='${iconColor}'"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg></a>`;
    const x = `<a href="https://x.com/getruba" target="_blank" rel="noopener" aria-label="X" style="display:flex;align-items:center;color:${iconColor};transition:color 0.15s ease;" onmouseover="this.style.color='${hoverColor}'" onmouseout="this.style.color='${iconColor}'"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg></a>`;
    const github = `<a href="https://github.com/rubadot" target="_blank" rel="noopener" aria-label="GitHub" style="display:flex;align-items:center;color:${iconColor};transition:color 0.15s ease;" onmouseover="this.style.color='${hoverColor}'" onmouseout="this.style.color='${iconColor}'"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .7a11.5 11.5 0 0 0-3.64 22.4c.58.1.79-.25.79-.56v-2.24c-3.22.7-3.9-1.37-3.9-1.37-.53-1.34-1.29-1.7-1.29-1.7-1.05-.72.08-.71.08-.71 1.16.08 1.78 1.2 1.78 1.2 1.04 1.77 2.72 1.26 3.38.96.1-.75.4-1.26.74-1.55-2.57-.3-5.27-1.29-5.27-5.69 0-1.26.45-2.29 1.19-3.09-.12-.29-.52-1.47.11-3.05 0 0 .97-.31 3.16 1.18A10.96 10.96 0 0 1 12 6.09c.98 0 1.94.13 2.86.39 2.2-1.49 3.16-1.18 3.16-1.18.63 1.58.23 2.76.11 3.05.74.8 1.19 1.83 1.19 3.09 0 4.42-2.7 5.39-5.28 5.68.42.36.79 1.06.79 2.14v3.28c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .7Z"/></svg></a>`;
    div.innerHTML = logo + spacer + ig + li + x + github;
    footer.appendChild(div);
    return true;
  };
  if (!document.documentElement.dataset.rubaSocialsObserver) {
    document.documentElement.dataset.rubaSocialsObserver = "true";
    const observer = new MutationObserver(() => {
      if (!document.getElementById("ruba-socials")) {
        inject();
      }
    });
    observer.observe(document.body, {
      childList: true,
      subtree: true
    });
  }
  setTimeout(() => {
    if (!inject()) {
      const interval = setInterval(() => {
        if (inject()) {
          clearInterval(interval);
        }
      }, 500);
      setTimeout(() => clearInterval(interval), 10000);
    }
  }, 300);
  return null;
};

<link rel="stylesheet" href="/style.css" />

<RubaSocials />

Seat-based pricing lets you sell products where one person buys seats and hands them out to their team. Each seat holder gets their own benefits: license keys, Discord roles, file downloads, or anything else you offer.

## Before you build

* Ruba organization with seat-based pricing enabled
* Ruba SDK installed (`npm install @getruba/sdk` or `pip install ruba-sdk`), make sure it's the latest version
* Basic grasp of Ruba products and subscriptions

## Build sequence

<Info>
  This guide covers both **subscription** and **one-time purchase** seat-based products. The flow is the same; the only difference is in scaling and billing.
</Info>

### Step 1: Create a seat-based product

<Steps>
  <Step title="Navigate to Products">
    Open the dashboard and [create a new product](https://getruba.com/to/dashboard/products/new).
  </Step>

  <Step title="Configure pricing">
    Under **Pricing**:

    * **Product type**: Subscription (recurring) or One-time (perpetual licenses)
    * **Pricing type**: Seat-based
    * **Tiering model**: Choose how seats are priced (see below)

    **Fixed price per seat** (default): every seat costs the same flat rate. Enter a single price per seat.

    **Graduated**: seats are priced per tier range independently. Each tier's seats are billed at that tier's rate and the totals are summed. Example with 1–10 seats at \$10 and 11+ at \$8: buying 14 seats = 10 × \$10 + 4 × \$8 = \$132.

    **Volume discounts**: the per-seat price is set by the total seats purchased, and that rate applies to all seats. Example with the same tiers: buying 14 seats = 14 × \$8 = \$112 (all seats at the 11+ rate).

    | Tier | Max Seats | Price per Seat |
    | ---- | --------- | -------------- |
    | 1    | 4         | \$10/month     |
    | 2    | 9         | \$9/month      |
    | 3    | Unlimited | \$8/month      |
  </Step>

  <Step title="Add benefits">
    Configure benefits that seat holders will receive (license keys, file downloads, Discord roles, etc.). Remember: benefits are granted on seat claim, not at purchase.
  </Step>
</Steps>

### Step 2: Checkout

```typescript theme={"system"}
const checkout = await ruba.checkouts.create({
  products: ["prod_123"],
  seats: 5,
  success_url: "https://your-app.com/success",
  customer_email: "billing@company.com"
});

// Redirect to checkout.url
```

The checkout automatically prices things based on your tiers.

### Step 3: Seat management

After purchase, the billing manager can assign and manage seats right from the **Customer Portal**, with no custom UI needed. The portal offers:

* **Assign seats** to team members by email
* **Revoke seats** to strip access and free up seats for reassignment
* **Resend invitations** for pending seats
* **Adjust seat count** on subscriptions (add or reduce)

Ruba automatically fires invitation emails to assigned team members with a secure claim link. The moment a member claims their seat, benefits are granted immediately.

<Info>
  Invitation emails go out automatically when seats are assigned. The claim flow is fully managed by Ruba: members click the link, claim their seat, and get immediate access to benefits.
</Info>

#### API-driven seat assignment

If you want to manage seat assignment programmatically (e.g., auto-assigning seats when users sign up), use the [Customer Seats API](/api-reference/customer-seats/assign):

```typescript theme={"system"}
const seat = await ruba.customerSeats.assign({
  subscription_id: subscriptionId,
  email: "engineer@company.com",
  immediate_claim: true  // Skip invitation email, grant benefits immediately
});
```

<Info>
  Use `immediate_claim: true` when you manage your own user authentication and want to bypass the email invitation flow. Benefits are granted immediately without the member needing to click a claim link.
</Info>

### Step 4: Handle benefit grants

Listen for benefit webhooks to sync access in your system. Remember: use `grant.member` to identify the recipient, not `grant.customer_id` (which is the buyer).

<Warning>
  Always [verify webhook signatures](/integrate/webhooks/endpoints#verify-signature) before processing events. The example below omits verification for brevity.
</Warning>

```typescript theme={"system"}
app.post('/webhooks/ruba', async (req, res) => {
  // Verify webhook signature first — see webhook docs
  const event = req.body;

  if (event.type === 'benefit_grant.created') {
    const grant = event.data;
    const recipient = grant.member;
    await grantAccess(recipient.id, recipient.email, grant.benefit);
  }

  if (event.type === 'benefit_grant.revoked') {
    const grant = event.data;
    await revokeAccess(grant.member.id, grant.benefit);
  }

  res.sendStatus(200);
});
```

### Step 5: Scale seats

**Subscriptions**: modify the seat count:

```typescript theme={"system"}
await ruba.subscriptions.update({
  id: subscriptionId,
  seats: newTotal  // Cannot be less than currently assigned seats (pending + claimed)
});
```

<Warning>
  You cannot reduce seats below the number of currently assigned seats (both pending and claimed). Revoke seats first before reducing the count.
</Warning>

**One-time purchases**: buy a new order:

```typescript theme={"system"}
const checkout = await ruba.checkouts.create({
  products: [productId],
  seats: additionalSeats,
  success_url: "https://your-app.com/success"
});
// Each order has its own independent seat pool
```

## Operating rules

* **Use `grant.member` everywhere** (not `grant.customer_id`) to identify who has access
* **Use seat metadata** to store department, role, or cost center for your own tracking
* **Communicate clearly** to billing managers about seat assignment: if using the default Ruba confirmation page, their seat is auto-claimed; if using a custom success URL, they'll need to assign themselves a seat through the portal

## Understanding the model

Before writing any code, there are three entities to grasp. They reshape how you think about every API call, webhook, and portal flow.

### Customer, Member, and CustomerSeat

With standard Ruba products, one person buys and one person uses, they are the same person. With seat-based products, buying and using are separate concerns, modeled through three distinct entities:

* A **Customer** is the billing entity, who pays. They own subscriptions, orders, and payment methods. On the first seat-based purchase, the customer is permanently upgraded to `type: "team"`, which turns on members and team management.
* A **Member** is a person under a customer, who uses. Each member has their own email, role (`owner`, `billing_manager`, or `member`), and receives benefit grants independently. The person who purchases the product is created as an `owner` member. Both `owner` and `billing_manager` roles can manage seats, update or cancel the subscription, and manage payment methods. The `owner` role may pick up additional management capabilities down the road.
* A **CustomerSeat** is the link between a product and a member. It tracks assignment status (`pending`, `claimed`, `revoked`), holds the invitation token, and carries optional metadata.

```
Customer (Jane — purchaser, type: "team")
  ├── Member: Jane (role: owner)          → manages team, can self-assign a seat for benefits
  ├── Member: Alice (role: member)        → gets benefits via seat
  └── Member: Bob (role: member)          → gets benefits via seat

Subscription (Team Pro — 3 seats)
  ├── CustomerSeat → Alice (claimed)      → benefits granted
  ├── CustomerSeat → Bob (claimed)        → benefits granted
  └── CustomerSeat → unassigned           → available
```

### Why this split matters

This three-entity model gives you flexibility that a plain "buyer = user" model can't:

* **Members persist across products.** The same member can hold seats from different subscriptions or orders under the same customer.
* **Roles control portal access.** Owners and billing managers see full team management. Regular members see only their own benefits.
* **Benefits track to people, not purchases.** Always use `grant.member` to identify who has access: `grant.customer_id` is always the billing entity, not the end user.

### What this means for your integration

* **Benefit grants** reference a `member`, not just a `customer`. Always use `grant.member` to identify who received the benefit.
* **Webhooks** include a `member` object on seat and grant events. Use it instead of `customer_id` to identify the end user.
* **Benefits are not granted at purchase time.** Seats must be assigned and claimed before benefits are granted. (Exception: when using the default Ruba confirmation page, the buyer's seat is auto-claimed.)

```
Purchase → Assign seats → Members claim → Benefits granted
```

## Member sessions and portal

To give a member access to the customer portal, create a customer session with a `member_id` to scope the view:

```typescript theme={"system"}
const session = await ruba.customerSessions.create({
  customer_id: "cust_123",
  member_id: "mem_789"
});
// Redirect to session.customer_portal_url
```

* **Billing managers** (owner/billing\_manager role) see full team management: assigning seats, managing members, and viewing seat utilization.
* **Members** see only their own benefits and account details.

## Webhook events

| Event                    | When                           | Key fields                             |
| ------------------------ | ------------------------------ | -------------------------------------- |
| `order.paid`             | Customer completes purchase    | `customer_id`, `product`               |
| `subscription.updated`   | Seat count changes             | `customer_id`, `seats`                 |
| `customer_seat.assigned` | Seat assigned, invitation sent | `member`, `email`, `status: "pending"` |
| `customer_seat.claimed`  | Member claims their seat       | `member`, `status: "claimed"`          |
| `benefit_grant.created`  | Benefit granted to member      | `member`, `customer_id` (buyer)        |
| `customer_seat.revoked`  | Seat revoked                   | `member`, `status: "revoked"`          |
| `benefit_grant.revoked`  | Benefit removed from member    | `member`, `customer_id` (buyer)        |

<Info>
  On subscription cancellation, each seat is revoked individually. A subscription with 5 seats and 3 benefits produces 1 + 5 + 15 = 21 webhook events. Make sure your handlers are idempotent.
</Info>

## Subscriptions vs one-time purchases

|                   | Subscriptions              | One-Time Purchases  |
| ----------------- | -------------------------- | ------------------- |
| **Payment**       | Recurring (monthly/yearly) | Single payment      |
| **Seat duration** | While subscribed           | Perpetual           |
| **Adding seats**  | Modify subscription        | Purchase new order  |
| **Benefits**      | While subscription active  | Forever after claim |

## Current boundaries

* Seats must be assigned individually (use API for bulk)
* Claim links expire after 24 hours
* Maximum 1,000 seats per subscription
* Metadata limited to 10 keys and 1KB per seat

## Diagnose the integration

| Problem             | Solution                                                                                             |
| ------------------- | ---------------------------------------------------------------------------------------------------- |
| Cannot reduce seats | Revoke assigned seats first. You can't go below the combined pending + claimed count.                |
| Claim link expired  | Tokens expire after 24 hours. Resend via the portal or API: `ruba.customerSeats.resend({ seat_id })` |

## Continue the build

* [Seat-Based Pricing Feature Documentation](/features/seat-based-pricing)
* [Customer Seats API Reference](/api-reference/customer-seats/assign)
* [Webhook setup](/integrate/webhooks/endpoints)
* [Customer Portal customization](/features/customer-portal)
