Adding a team plan to EverCV: one owner, four teammates, shared Pro access
EverCV's individual tiers are $8 (Pro) and $15 (Team). The Team tier is for job-hunting squads — a lead (the owner) pays once, and their teammates get full Pro access without paying themselves. Bootcamp cohorts, college roommates, engineering friends doing a synchronized job search.
Here's how I built it.
The data model
No new table. Team membership is two fields on the existing user record:
user.team_owner_id = "usr_abc123" # set when invited to a team
user.team_joined_at = "2026-06-01T00:00:00Z"
When someone is on a team, their feature access is inherited from the owner. The get_effective_plan() helper encapsulates this:
def get_effective_plan(user: dict) -> str:
if user.get("plan") == "team":
return "team"
owner_id = user.get("team_owner_id")
if owner_id:
owner = get_user(owner_id)
if owner and owner.get("plan") == "team":
return "team"
return user.get("plan", "free")
Every Pro-gated feature calls get_effective_plan(user) instead of checking user["plan"] directly. Team members inherit "team" plan access. If the owner downgrades, the inheritance chain breaks on the next call — no cron needed, no propagation.
get_team_members() is a DynamoDB scan filtered on team_owner_id. It's not the most efficient call (a full scan), but the table is small and it's called only when the owner views their team page. If the product ever has thousands of team owners, an inverted GSI fixes this in a day.
Invite flow
POST /api/team/invite with { "email": "colleague@company.com" }:
- Check the owner has room (max 5 seats: owner + 4).
- Find or create the invitee's account (
create_user()if not found). - Check they're not already on another team — reject with 409 if so.
- Set
team_owner_id+team_joined_aton the invitee. - Send an invite email via SES.
The "find or create" step means the invitee doesn't need to sign up first. They get an email, click the login link, and land in a dashboard that already has Pro features unlocked. No friction at all — the owner does the work, the teammate just shows up.
Usage counters
The team owner wants to know which teammates are actually using the product and which are just warming a seat. So every time a Pro feature completes successfully, it bumps a counter on the user's record:
def bump_usage(user_id: str, field: str) -> None:
_ddb().Table(USERS_TABLE).update_item(
Key={"user_id": user_id},
UpdateExpression="ADD #cnt :one SET last_active = :ts",
ExpressionAttributeNames={"#cnt": field},
ExpressionAttributeValues={":one": 1, ":ts": now_iso()},
)
ADD in DynamoDB is an atomic increment — if two concurrent requests hit the same user, they can't race each other down to 1. The field (cv_tailored_count, gap_analysis_count, interview_prep_count) is passed as a string to keep the helper generic without branching.
Each of the three Pro feature handlers calls bump_usage after a successful response, wrapped in a bare try/except so a DynamoDB hiccup never blocks the user:
try:
db.bump_usage(user_id, "cv_tailored_count")
except Exception:
pass
return _json(200, result)
Team analytics endpoint
GET /api/team/analytics returns the full picture for the team owner:
{
"members": [
{
"user_id": "...",
"email": "owner@co.com",
"display_name": "Chester",
"joined_at": "2026-01-01T00:00:00Z",
"last_active": "2026-06-09T08:00:00Z",
"cv_tailored_count": 5,
"gap_analysis_count": 3,
"interview_prep_count": 2,
"is_owner": true
},
{ ... }
],
"seats_used": 3,
"seats_total": 5,
"totals": {
"cv_tailored": 7,
"gap_analyses": 4,
"interview_preps": 2
}
}
The owner row is included in the members list and flagged is_owner: true. The dashboard renders this as a table with a totals footer — CVs, Gaps, Prep counts per person, plus the date they were last active.
This gives the owner a clear picture: if two seats show zero activity after two weeks, those invites probably bounced or the teammates aren't engaged. That's useful signal for a bootcamp running this for a cohort.
What I left out
No per-action granularity (which job descriptions were tailored, which companies the gap analyses were for). That data lives in the feature responses, not stored anywhere. I could add a DynamoDB table for it later — for now, counts are enough to answer "is this team actually using it."
No admin view across all teams. That's a v2 thing when there are enough teams to care about.
No seat billing yet — the Team plan is flat $15/mo regardless of seats used (1–5). If the usage analytics show most teams filling all 5 seats, the per-seat model is an obvious upgrade. If most teams use 2 seats, the flat model is right. The data will tell me.
EverCV is at evercv.io. Pro is $8/mo; Team is $15/mo for up to 5 people.