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
Navigate to Products
In the Polar dashboard, go to Products and click Create Product.
Configure basic settings
Set your product name, description, and media. For example:
- Name: Team Pro Plan
- Description: Professional features for your entire team
Select seat-based pricing
Under Pricing:
- Billing cycle: Monthly or Yearly
- Pricing type: Seat-based
- Min seats: 1 (or your minimum team size)
Configure pricing tiers
Define your volume-based pricing:Tier | Max Seats | Price per Seat |
---|
1 | 4 | $10/month |
2 | 9 | $9/month |
3 | Unlimited | $8/month |
Example: A team purchasing 6 seats pays 6 × 9=54/month. 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,totaling45.
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>
);
}
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.
Claim link expired
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.