Skip to main content
This guide walks you through implementing seat-based pricing for team subscriptions, from creating the product to handling seat assignments and claims.

What you’ll build

By the end of this guide, you’ll have:
  • A seat-based product with tiered pricing
  • Checkout flow for purchasing seats
  • Seat assignment and management interface
  • Claim flow for team members

Prerequisites

  • Polar organization with seat_based_pricing_enabled feature flag
  • Polar SDK installed (npm install @polar-sh/sdk or pip install polar-sdk)
  • Basic understanding of Polar products and subscriptions
Seat-based pricing is controlled by a feature flag. Contact support to enable it for your organization.

Step 1: Create a seat-based product

1

Navigate to Products

In the Polar dashboard, go to Products and click Create Product.
2

Configure basic settings

Set your product name, description, and media. For example:
  • Name: Team Pro Plan
  • Description: Professional features for your entire team
3

Select seat-based pricing

Under Pricing:
  • Billing cycle: Monthly or Yearly
  • Pricing type: Seat-based
  • Min seats: 1 (or your minimum team size)
4

Configure pricing tiers

Define your volume-based pricing:
TierMax SeatsPrice per Seat
14$10/month
29$9/month
3Unlimited$8/month
Example: A team purchasing 6 seats pays 6 × 9=9 = 54/month.
5

Add benefits

Configure benefits that seat holders will receive:
  • License Keys
  • File Downloads
  • Discord roles
  • Custom benefits
Benefits are granted when seats are claimed, not at purchase time.
You can also create seat-based products via API:
const product = await polar.products.create({
  name: "Team Pro Plan",
  organization_id: "org_123",
  prices: [{
    amount_type: "seat_based",
    price_currency: "usd",
    seat_tiers: [
      { min_seats: 1, max_seats: 4, price_per_seat: 1000 },   // $10
      { min_seats: 5, max_seats: 9, price_per_seat: 900 },    // $9
      { min_seats: 10, max_seats: null, price_per_seat: 800 } // $8
    ]
  }]
});

Step 2: Implement checkout flow

Create a checkout session that allows customers to select seat quantity:
const checkout = await polar.checkouts.create({
  product_price_id: "price_123",
  seats: 5, // Customer selects quantity
  success_url: "https://yourapp.com/success",
  customer_email: "billing@company.com"
});

// Redirect to checkout.url
The checkout displays:
  • Price per seat based on quantity
  • Total amount
  • Clear indication this is for team access
The checkout automatically calculates pricing based on your tiers. A customer selecting 5 seats will see the 9/seatprice,totaling9/seat price, totaling 45.

Step 3: Handle post-purchase webhook

Listen for the subscription.created webhook to know when a customer purchases seats:
// Webhook handler
app.post('/webhooks/polar', async (req, res) => {
  const event = req.body;

  if (event.type === 'subscription.created') {
    const subscription = event.data;

    if (subscription.product.has_seat_based_price) {
      // Redirect billing manager to seat management
      await notifyBillingManager(subscription.customer_id, {
        message: `Your ${subscription.seats}-seat subscription is active!`,
        manage_seats_url: `https://yourapp.com/seats/${subscription.id}`
      });
    }
  }

  res.sendStatus(200);
});

Step 4: Build seat management interface

Create an interface for billing managers to assign seats:
// List available seats
async function getSeatInfo(subscriptionId: string) {
  const { seats, available_seats, total_seats } =
    await polar.customerSeats.list({
      subscription_id: subscriptionId
    });

  return {
    seats,
    available: available_seats,
    total: total_seats,
    canAssign: available_seats > 0
  };
}

// Assign a seat
async function assignSeat(
  subscriptionId: string,
  email: string,
  metadata?: Record<string, any>
) {
  try {
    const seat = await polar.customerSeats.assign({
      subscription_id: subscriptionId,
      email: email,
      metadata: metadata // e.g., { department: "Engineering" }
    });

    return {
      success: true,
      seat: seat,
      message: `Invitation sent to ${email}`
    };
  } catch (error) {
    if (error.status === 400) {
      return {
        success: false,
        error: "No seats available or customer already has a seat"
      };
    }
    throw error;
  }
}
Example UI component (React):
function SeatManagement({ subscriptionId }: { subscriptionId: string }) {
  const [seatInfo, setSeatInfo] = useState(null);
  const [email, setEmail] = useState("");

  useEffect(() => {
    loadSeats();
  }, [subscriptionId]);

  async function loadSeats() {
    const info = await getSeatInfo(subscriptionId);
    setSeatInfo(info);
  }

  async function handleAssign() {
    const result = await assignSeat(subscriptionId, email, {
      role: "Developer"
    });

    if (result.success) {
      setEmail("");
      loadSeats(); // Refresh list
      toast.success("Invitation sent!");
    }
  }

  return (
    <div>
      <h2>Seat Management</h2>
      <p>{seatInfo?.available} of {seatInfo?.total} seats available</p>

      {/* Assign new seat */}
      <div>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="team-member@company.com"
        />
        <button
          onClick={handleAssign}
          disabled={!seatInfo?.canAssign}
        >
          Assign Seat
        </button>
      </div>

      {/* List existing seats */}
      <table>
        <thead>
          <tr>
            <th>Email</th>
            <th>Status</th>
            <th>Role</th>
            <th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {seatInfo?.seats.map(seat => (
            <tr key={seat.id}>
              <td>{seat.customer_email}</td>
              <td>
                <SeatStatusBadge status={seat.status} />
              </td>
              <td>{seat.seat_metadata?.role}</td>
              <td>
                {seat.status === 'pending' && (
                  <button onClick={() => resendInvitation(seat.id)}>
                    Resend
                  </button>
                )}
                {seat.status === 'claimed' && (
                  <button onClick={() => revokeSeat(seat.id)}>
                    Revoke
                  </button>
                )}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Step 5: Implement seat claim flow

When a team member receives an invitation email, they’ll click a link with the invitation token. Build a claim page:
// Claim page route: /claim?token=abc123...

async function handleClaimPage(token: string) {
  // Get claim information (no auth required)
  const claimInfo = await polar.customerSeats.getClaimInfo({
    invitation_token: token
  });

  if (!claimInfo.can_claim) {
    return {
      error: "This invitation has expired or already been claimed"
    };
  }

  return {
    product: claimInfo.product_name,
    organization: claimInfo.organization_name,
    email: claimInfo.customer_email
  };
}

async function claimSeat(token: string) {
  const { seat, customer_session_token } =
    await polar.customerSeats.claim({
      invitation_token: token
    });

  // Store the customer session token
  // This allows immediate portal access
  localStorage.setItem('polar_session', customer_session_token);

  return {
    success: true,
    seat: seat,
    sessionToken: customer_session_token
  };
}
Example claim page (React):
function ClaimPage() {
  const [token] = useSearchParams();
  const [claimInfo, setClaimInfo] = useState(null);
  const [claiming, setClaiming] = useState(false);

  useEffect(() => {
    loadClaimInfo();
  }, [token]);

  async function loadClaimInfo() {
    const info = await handleClaimPage(token.get('token'));
    setClaimInfo(info);
  }

  async function handleClaim() {
    setClaiming(true);
    try {
      const result = await claimSeat(token.get('token'));

      // Redirect to customer portal
      window.location.href = `/portal?session=${result.sessionToken}`;
    } catch (error) {
      toast.error("Failed to claim seat");
      setClaiming(false);
    }
  }

  if (claimInfo?.error) {
    return <div>Error: {claimInfo.error}</div>;
  }

  return (
    <div>
      <h1>You've been invited!</h1>
      <p>
        Join {claimInfo?.organization}'s {claimInfo?.product} plan
      </p>
      <p>Email: {claimInfo?.email}</p>

      <button onClick={handleClaim} disabled={claiming}>
        {claiming ? "Claiming..." : "Claim My Seat"}
      </button>
    </div>
  );
}

Step 6: Handle benefit granting

After a seat is claimed, benefits are granted automatically via background jobs. Listen for webhooks to track this:
app.post('/webhooks/polar', async (req, res) => {
  const event = req.body;

  if (event.type === 'benefit_grant.created') {
    const grant = event.data;

    // A team member received their benefits
    console.log(`Benefit ${grant.benefit_id} granted to ${grant.customer_id}`);

    // Update your app (e.g., create license, grant access)
    await grantAccess(grant.customer_id, grant.benefit);
  }

  if (event.type === 'benefit_grant.revoked') {
    const grant = event.data;

    // A seat was revoked
    await revokeAccess(grant.customer_id, grant.benefit);
  }

  res.sendStatus(200);
});

Step 7: Implement seat revocation

Allow billing managers to revoke seats:
async function revokeSeat(seatId: string) {
  const revokedSeat = await polar.customerSeats.revoke({
    seat_id: seatId
  });

  // Benefits are automatically revoked via webhook
  return {
    success: true,
    seat: revokedSeat,
    message: "Seat revoked successfully"
  };
}
Revoking a seat immediately removes access but does not issue a refund. The billing manager continues to pay for all purchased seats.

Step 8: Handle subscription scaling

Allow billing managers to add or reduce seats:
async function addSeats(subscriptionId: string, newTotal: number) {
  // Update subscription seat count
  const subscription = await polar.subscriptions.update({
    id: subscriptionId,
    seats: newTotal
  });

  // New seats are immediately available for assignment
  return subscription;
}

async function reduceSeats(subscriptionId: string, newTotal: number) {
  const { seats } = await polar.customerSeats.list({
    subscription_id: subscriptionId
  });

  const claimedCount = seats.filter(s => s.status === 'claimed').length;

  if (newTotal < claimedCount) {
    throw new Error(
      `Cannot reduce to ${newTotal} seats. ${claimedCount} seats are currently claimed. Revoke seats first.`
    );
  }

  // Update will take effect at next renewal
  const subscription = await polar.subscriptions.update({
    id: subscriptionId,
    seats: newTotal
  });

  return subscription;
}

Best Practices

1. Validate seat availability

Always check available seats before showing the assignment form:
if (available_seats === 0) {
  return (
    <div>
      All seats are assigned.
      <button onClick={upgradeSubscription}>
        Add More Seats
      </button>
    </div>
  );
}

2. Use metadata effectively

Store useful context in seat metadata:
await polar.customerSeats.assign({
  subscription_id: subId,
  email: "dev@company.com",
  metadata: {
    department: "Engineering",
    role: "Senior Developer",
    cost_center: "R&D",
    manager: "jane@company.com"
  }
});

3. Handle expired tokens gracefully

try {
  await claimSeat(token);
} catch (error) {
  if (error.status === 400) {
    // Show resend option
    return "This invitation has expired. Contact your admin to resend.";
  }
}

4. Track utilization

Monitor seat usage to identify upsell opportunities:
const { seats, available_seats, total_seats } = await getSeatInfo(subId);
const utilization = ((total_seats - available_seats) / total_seats) * 100;

if (utilization > 80) {
  // Suggest adding more seats
  showUpgradePrompt();
}

5. Clear communication

Make it clear to billing managers that:
  • They won’t receive direct access to benefits
  • Seats must be assigned to team members
  • Revocation doesn’t refund costs
  • Reducing seats requires revoking claims first

Common Patterns

Bulk seat assignment

async function assignMultipleSeats(
  subscriptionId: string,
  emails: string[]
) {
  const results = await Promise.allSettled(
    emails.map(email =>
      polar.customerSeats.assign({
        subscription_id: subscriptionId,
        email: email
      })
    )
  );

  const succeeded = results.filter(r => r.status === 'fulfilled');
  const failed = results.filter(r => r.status === 'rejected');

  return {
    succeeded: succeeded.length,
    failed: failed.length,
    errors: failed.map(f => f.reason)
  };
}

Syncing with your user system

// When a user joins your system
async function onUserSignup(email: string, organizationId: string) {
  // Check if they have a pending seat
  const subscriptions = await getOrganizationSubscriptions(organizationId);

  for (const sub of subscriptions) {
    const { seats } = await polar.customerSeats.list({
      subscription_id: sub.id
    });

    const pendingSeat = seats.find(s =>
      s.status === 'pending' && s.customer_email === email
    );

    if (pendingSeat) {
      // Auto-claim on signup
      const claimLink = `/claim?token=${pendingSeat.invitation_token}`;
      return { shouldClaim: true, claimLink };
    }
  }
}

Custom portal integration

// Display team subscriptions in your app
async function getTeamSubscriptions(customerId: string) {
  const subs = await polar.customerPortal.seats.listSubscriptions({
    customer_id: customerId
  });

  return subs.map(sub => ({
    product: sub.product.name,
    status: sub.status,
    role: sub.seat_metadata?.role,
    expires: sub.current_period_end
  }));
}

Troubleshooting

Seats not appearing

Ensure the feature flag is enabled:
seat_based_pricing_enabled: true

Benefits not granted after claim

Check webhook logs for benefit_grant.created events. Benefits are granted asynchronously via background jobs.

Cannot reduce seats

Make sure to revoke seats before reducing the subscription seat count. You cannot reduce below the currently claimed count. Invitation tokens expire after 24 hours. Have the billing manager resend the invitation:
await polar.customerSeats.resend({ seat_id: seatId });

Next Steps

Need Help?

Join our Discord community or contact support for assistance with seat-based pricing implementation.
I