Teams
Share one plan, one monthly quota, and one rate limit across multiple api_keys.
Overview
Teams let multiple api_keys share one plan, one monthly quota, and one rate-limit window. Every teammate keeps their own key (no shared secrets), and usage tracking still logs individual users so you can attribute captures internally. When someone joins a team, their key's effective plan and limits are hoisted onto the team's config — and detached back to personal defaults when they leave or the team is dissolved.
Teams are the recommended way to give an agency, a marketing department, or a group of contractors shared access to one Business plan instead of buying multiple Pro plans.
How the shared pool works
Each account has a personal api_key.id that identifies the caller for
auth. Each key optionally has a team_id pointer. The auth middleware
computes a billing_owner for every request:
billing_owner_id = api_key.team_id ?? api_key.id;
effective_plan = teams.plan ?? api_keys.plan;
effective_quota = teams.monthly_limit ?? api_keys.monthly_limit;
effective_rate = teams.rate_limit_per_min ?? api_keys.rate_limit_per_min;
Then monthly_usage is keyed by billing_owner_id (not
api_key.id), and the Redis rate-limit bucket key is
ratelimit:{billing_owner_id}. So all teammates draw from the same monthly
counter and share the same per-minute burst allowance.
Individual capture history is retained per member, so an owner or admin can always
answer "who used our team's quota this week?" via
GET /v1/usage/log. Rows identify the member by their public handle
(pk_…), never by their API key.
Roles & permissions
| Action | Owner | Admin | Member |
|---|---|---|---|
| Capture screenshots / use shared quota | Yes | Yes | Yes |
| View team members | Yes | Yes | Yes |
| View team's shared usage | Yes | Yes | Yes |
| Invite new members (any role) | Yes | Yes | No |
| Leave team | No* | Yes | Yes |
| Transfer ownership | Yes | No | No |
| Dissolve team | Yes | No | No |
*Owners cannot leave their own team directly. They must transfer ownership to another member first, then leave; or dissolve the team entirely.
Team lifecycle
- Owner creates the team via
POST /v1/teams. Their current plan (starter/pro/business) and limits are hoisted onto the team. Their in-progress monthly usage is copied over so the team starts with an accurate counter, not a fresh zero. - Owner or admin invites a member by email. Invitee receives a signed accept link with a 7-day TTL.
- Invitee accepts via
POST /v1/teams/accept. Their api_key now points at the team; their next capture draws from the shared pool. - Members work. All captures aggregate under the team's counter.
- Someone leaves (or is removed — feature coming). Their api_key detaches; they fall back to the free plan unless they have their own paid sub.
- Ownership changes if the owner is leaving the company. Existing
owner promotes another member via
POST /v1/teams/:id/transfer-ownership, then optionally uses/leaveto depart. - Owner dissolves the team via
DELETE /v1/teams/:idwhen it's no longer needed. Every attached api_key detaches back to personal, invites are revoked, the team record is deleted.
API reference
All endpoints require the standard X-API-Key header. Team endpoints are
tagged Teams in the
Swagger UI.
POST /v1/teams — Create a team
Creates a new team, hoists the caller's current plan onto it, and attaches the caller as owner. The caller's current-month usage is rolled over so quota tracking stays accurate.
Body
| Field | Type | Description |
|---|---|---|
name | string, 1–60 chars | Team display name. |
curl -X POST https://api.getsnap.dev/v1/teams \
-H "X-API-Key: sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Acme Marketing"}'
Response (200)
{
"id": "team_kQuqGxZmKEEseMWM",
"name": "Acme Marketing",
"plan": "pro",
"monthly_limit": 25000,
"rate_limit_per_min": 60,
"owner_user_id": "user_fyT-mQNPrEp78d4Y",
"created_at": "2026-09-20T16:34:17.974Z",
"role": "owner",
"member_count": 1
}
Errors
400 already_on_team— caller is already on a team; leave first.402 free_plan_not_supported— teams require a paid plan.
GET /v1/teams — List your teams
Returns every team the caller is a member of, with their role in each. In practice a user is usually on just one team, but multi-team membership is supported.
curl https://api.getsnap.dev/v1/teams \
-H "X-API-Key: sk_live_YOUR_KEY"
Response (200)
{
"teams": [
{
"id": "team_yAgFMd2wPYESPc8z",
"name": "E2E Test Team",
"plan": "pro",
"role": "owner",
"member_count": 3,
"monthly_limit": 25000,
"rate_limit_per_min": 60,
"joined_at": "2026-09-14T09:12:00Z"
}
]
}
GET /v1/teams/:id/members — List members
Any member of the team can call this. Returns everyone's role, email, and join timestamp.
curl https://api.getsnap.dev/v1/teams/team_ABC123/members \
-H "X-API-Key: sk_live_YOUR_KEY"
Response (200)
{
"team_id": "team_ABC123",
"members": [
{ "user_id": "user_...", "email": "alice@acme.com", "role": "owner", "joined_at": "..." },
{ "user_id": "user_...", "email": "bob@acme.com", "role": "admin", "joined_at": "..." },
{ "user_id": "user_...", "email": "carol@acme.com", "role": "member", "joined_at": "..." }
]
}
POST /v1/teams/:id/invites — Invite a member
Owner or admin only. Creates a signed invite token with a 7-day TTL and sends the invitee a magic-link email. If the email address is already on the team, the response is idempotent — no duplicate row, no role escalation.
Body
| Field | Type | Description |
|---|---|---|
email | string | The invitee's account email. |
role | enum | "admin" or "member". Defaults to "member". Cannot be "owner" at invite time. |
curl -X POST https://api.getsnap.dev/v1/teams/team_ABC123/invites \
-H "X-API-Key: sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "bob@acme.com", "role": "admin"}'
Response (200)
{
"invite_token": "inv_KJH...",
"email": "bob@acme.com",
"role": "admin",
"expires_at": "2026-09-27T16:34:17Z"
}
The invitee receives an email with a one-click accept link like
https://getsnap.dev/team-accept.html?token=inv_KJH.... The token is also
returned in the response so admins can copy-paste it into chat if the email is
delayed.
POST /v1/teams/accept — Accept an invite
The invitee calls this from their own API key. The invitee's account email must match the email the invite was sent to (case-insensitive).
Body
| Field | Type | Description |
|---|---|---|
token | string | The invite token from the accept URL or the invite response. |
curl -X POST https://api.getsnap.dev/v1/teams/accept \
-H "X-API-Key: sk_live_INVITEE_KEY" \
-H "Content-Type: application/json" \
-d '{"token": "inv_KJH..."}'
Response (200)
{
"joined_team_id": "team_ABC123",
"team_name": "Acme Marketing",
"role": "admin"
}
Errors
404 invite_not_found— token is invalid, revoked, or already accepted.410 invite_expired— token TTL passed. Ask the sender for a new one.403 email_mismatch— invitee's account email doesn't match the invite's email.409 already_on_team— invitee is already on this team, or on a different team. Leave first.
POST /v1/teams/:id/leave — Leave a team
Any admin or member can call this. Detaches the caller's api_key from the team — they fall back to whatever personal plan / limits they had before joining.
Owners cannot use this endpoint. They must first transfer ownership to another member, or dissolve the team.
curl -X POST https://api.getsnap.dev/v1/teams/team_ABC123/leave \
-H "X-API-Key: sk_live_YOUR_KEY"
Response (200)
{
"left_team_id": "team_ABC123"
}
Errors
403 owner_cannot_leave— use transfer-ownership or dissolve instead.404 not_found— caller isn't on this team.
POST /v1/teams/:id/transfer-ownership — Transfer ownership
Owner-only. Promotes an existing member to owner and demotes the caller to admin. Every team has exactly one owner at all times, so this endpoint is the only way to change ownership without dissolving the team.
Body
| Field | Type | Description |
|---|---|---|
new_owner_user_id | string | The user_id of an existing team member. Retrieve via GET /members. |
curl -X POST https://api.getsnap.dev/v1/teams/team_ABC123/transfer-ownership \
-H "X-API-Key: sk_live_OWNER_KEY" \
-H "Content-Type: application/json" \
-d '{"new_owner_user_id": "user_bob"}'
Response (200)
{
"team_id": "team_ABC123",
"previous_owner": "user_alice",
"new_owner": "user_bob"
}
Errors
400 already_owner— you're transferring to yourself.403 forbidden— only the current owner can call this.404 not_a_member— the proposed new owner isn't on the team. Invite them first.
DELETE /v1/teams/:id — Dissolve a team
Owner-only. Permanently deletes the team, revokes all outstanding invites, and detaches
every member's api_key so they fall back to personal plan and limits. Team usage
history in usage_log is preserved (tied to individual api_key.id) but
the team's aggregated monthly_usage row is dropped. One-way action, no undo.
curl -X DELETE https://api.getsnap.dev/v1/teams/team_ABC123 \
-H "X-API-Key: sk_live_OWNER_KEY"
Response (200)
{
"dissolved_team_id": "team_ABC123",
"detached_key_count": 5,
"invites_revoked": 3
}
Errors
403 forbidden— only the owner can dissolve.404 not_found— caller isn't on this team.
SDKs
Node.js
import { getSnap } from "getsnap";
const snap = getSnap({ apiKey: process.env.GETSNAP_API_KEY });
const team = await snap.teams.create({ name: "Acme Marketing" });
const list = await snap.teams.list();
const members = await snap.teams.members(team.id);
await snap.teams.invite(team.id, { email: "bob@acme.com", role: "admin" });
await snap.teams.accept({ token: "inv_..." }); // called by invitee
await snap.teams.leave(team.id);
await snap.teams.transferOwnership(team.id, { new_owner_user_id: "user_bob" });
await snap.teams.dissolve(team.id);
// Team billing
await snap.teams.checkout(team.id, { plan: "pro" }); // returns { url, type }
await snap.teams.portal(team.id); // returns { url }
await snap.teams.billing(team.id); // returns status + limits
Python
from getsnap import GetSnap
snap = GetSnap(api_key=os.environ["GETSNAP_API_KEY"])
team = snap.create_team(name="Acme Marketing")
teams = snap.list_teams()
members = snap.list_team_members(team_id=team["id"])
snap.invite_team_member(team_id=team["id"], email="bob@acme.com", role="admin")
snap.accept_team_invite(token="inv_...") # called by invitee
snap.leave_team(team_id=team["id"])
snap.transfer_team_ownership(team_id=team["id"], new_owner_user_id="user_bob")
snap.dissolve_team(team_id=team["id"])
# Team billing
snap.create_team_checkout(team_id=team["id"], plan="pro")
snap.get_team_billing_portal(team_id=team["id"])
snap.get_team_billing(team_id=team["id"])
CLI
export GETSNAP_API_KEY=sk_live_YOUR_KEY
getsnap teams list
getsnap teams create "Acme Marketing"
getsnap teams members team_ABC123
getsnap teams invite team_ABC123 bob@acme.com --role admin
getsnap teams accept inv_KJH...
getsnap teams leave team_ABC123
getsnap teams transfer team_ABC123 user_bob
getsnap teams dissolve team_ABC123 --yes
getsnap teams checkout team_ABC123 pro
getsnap teams portal team_ABC123
getsnap teams billing team_ABC123
Billing
Teams have their own Stripe subscription, independent of any individual
member's personal billing. At team creation the team inherits the creator's plan and
limits as a starting point (so brand-new teams work immediately) — but from that
moment on the team's plan is decoupled from the creator's personal Stripe sub. The owner
attaches a proper team-owned subscription by running POST /v1/teams/:id/checkout,
which creates a fresh Stripe customer keyed to the team and launches a Stripe Checkout
Session with metadata.team_id stamped on the subscription.
From then on, when Stripe fires webhook events for that subscription, our handler
checks subscription.metadata.team_id and updates the team's row directly. The
owner's personal Stripe sub (if any) is completely orthogonal — owners can have a
Pro personal plan AND own a Business team plan without either interfering.
POST /v1/teams/:id/checkout — Start Stripe Checkout
Owner-only. Creates (or reuses) the team's Stripe customer and returns a Stripe-hosted checkout URL. If the team already has an active or trialing sub, returns the billing portal URL instead so the owner can change plans there.
Body
| Field | Type | Description |
|---|---|---|
plan | enum | "starter", "pro", or "business". Same tier prices as personal subs ($9 / $29 / $79 per month). |
curl -X POST https://api.getsnap.dev/v1/teams/team_ABC123/checkout \
-H "X-API-Key: sk_live_OWNER_KEY" \
-H "Content-Type: application/json" \
-d '{"plan": "pro"}'
Response (200)
{
"url": "https://checkout.stripe.com/c/pay/cs_live_a1...",
"type": "checkout" // or "portal" if the team already has a sub
}
New team subs get a 7-day trial with missing_payment_method: cancel. The
Checkout Session stamps metadata.team_id on both the session and the resulting
subscription so every downstream Stripe webhook can be routed to the team.
Errors
403 forbidden— only the owner can start / change team billing.500 misconfigured— no Stripe price configured for the requested plan (server misconfig).
POST /v1/teams/:id/portal — Billing portal
Owner-only. Returns a Stripe billing portal URL where the owner can change plans, update
payment method, download invoices, or cancel. Requires that /checkout has
already been run at least once (to create the team's Stripe customer).
curl -X POST https://api.getsnap.dev/v1/teams/team_ABC123/portal \
-H "X-API-Key: sk_live_OWNER_KEY"
Response (200)
{ "url": "https://billing.stripe.com/session/..." }
Errors
400 no_subscription— team has no Stripe customer yet. Call/checkoutfirst.403 forbidden— only the owner can open the billing portal.
GET /v1/teams/:id/billing — Subscription status
Any team member can call this — it's read-only. Returns the team's current plan, limits, and the state of the Stripe subscription (if any). Lets the dashboard show "next invoice: YYYY-MM-DD" and "will downgrade to free on X" without a Stripe round-trip.
curl https://api.getsnap.dev/v1/teams/team_ABC123/billing \
-H "X-API-Key: sk_live_MEMBER_KEY"
Response (200)
{
"team_id": "team_ABC123",
"plan": "pro",
"monthly_limit": 25000,
"rate_limit_per_min": 60,
"subscription_status": "active",
"current_period_end": "2026-10-20T16:00:00Z",
"cancel_at_period_end": false,
"has_team_subscription": true
}
Fields
| Field | Values | Description |
|---|---|---|
subscription_status | string | null | Raw Stripe status: active, trialing, past_due, canceled, unpaid, incomplete. null if the team has no team-owned sub yet (still on the plan hoisted from the creator). |
current_period_end | ISO string | null | Next renewal date. Null for teams without a sub. |
cancel_at_period_end | boolean | True if the sub is scheduled to cancel at the current period end (owner clicked "cancel" in the billing portal but the period is still active). |
has_team_subscription | boolean | False = team is on the plan inherited from the creator at team-creation time. True = team has its own Stripe subscription and its own billing pipeline. |
Behavior & edge cases
| Event | Result |
|---|---|
| Owner completes checkout | Sub is created with status trialing (7-day trial) or active. updateTeamPlan writes plan + limits + status to the team row. Redis cache is punched for every member's api_key so they see the new limits immediately. |
| Trial ends without payment method | Stripe cancels the sub. We downgrade the team to free (100 / mo, 5 req/min) and clear stripe_subscription_id. Team members keep working but at free-tier limits. |
| Trial ending in 72 hours | We email the owner (looked up via team_members JOIN api_keys) with a billing portal URL to add a payment method. |
| Owner upgrades in portal (pro → business) | customer.subscription.updated fires with the new price. Team plan updated on the spot. All members' quotas jump to the new tier. |
| Owner cancels in portal | Two paths depending on the cancel type:
|
| Payment fails | Sub goes to past_due. Team keeps its plan (Stripe grace-period behavior). Owner should see the warning in the portal / dashboard. After Stripe's dunning cycle, sub goes to unpaid or is cancelled. |
| Team dissolve with active sub | DELETE /v1/teams/:id cancels the Stripe subscription before dropping the DB row so the owner isn't charged again. Runs fire-and-forget with error logging. |
| Ownership transfer | The Stripe subscription stays with whoever paid. If Alice ran /checkout and later transfers ownership to Bob, Alice's Stripe customer keeps getting charged. To move billing over, Bob runs /checkout himself which creates a new sub on his Stripe customer (Alice's old one is not automatically cancelled — she can do that via her personal billing portal). |
| Referral crediting | Team invoices do not trigger referral credit today. The team's fresh Stripe customer doesn't match any api_keys row, so invoice.paid exits early. Users who want to earn referrer credit should start with a personal sub first. |
Pricing
Teams pay the same tier prices as personal subs ($9 Starter, $29
Pro, $79 Business). There's no separate "per-seat" or "team-tier" pricing today
— a team just shares its plan's quota + rate limit across all members.
Quotas & rate limits
Both the monthly quota and the per-minute rate limit are shared across all teammates.
| Signal | Behavior |
|---|---|
| Monthly quota | All members' captures increment the same monthly_usage row keyed by
the team's ID. Resets on the 1st of the month UTC. |
| Rate limit | Redis sliding window keyed by ratelimit:{team_id}. If Alice fires 30
captures in a minute on a Pro team (60 req/min), Bob can only fire 30 more before
both start getting 429s. |
| Individual attribution | Each capture is still recorded against the member who made it, so an owner or
admin can answer "who used our quota this week?" via
GET /v1/usage/log. |
| Usage rollover on join | When someone creates a team, their current-month personal usage is copied into the team's counter so the shared pool reflects reality from the moment of creation. |
| Usage on leave / dissolve | Individual usage_log history stays intact. The team's aggregated
monthly_usage row is dropped on dissolve. On leave, the team keeps its
row and the departing member falls back to their personal counter. |
Anti-abuse rules
- Invitee email must match caller's account email. Prevents Alice from inviting bob@personal.com then having Carol accept from a different account.
- Duplicate invites are silent no-ops. Re-inviting an existing member returns 200 without escalating their role.
- Users cannot self-promote. Only the owner can transfer ownership. Only owners and admins can invite.
-
One owner per team, always. Enforced by transactional swap in
transfer-ownership. -
Free plans cannot create teams. Returns
402 free_plan_not_supported. - Free users can accept invites if the team is on a paid plan — their key gets hoisted onto the team's plan for the duration of their membership.
Data model
-- Teams table
teams(
id TEXT PRIMARY KEY, -- 'team_' + nanoid(16)
name TEXT,
plan TEXT, -- 'starter' | 'pro' | 'business'
monthly_limit INTEGER,
rate_limit_per_min INTEGER,
owner_user_id TEXT, -- FK-ish to api_keys.user_id
created_at TEXT
)
-- Members table (one row per (team, user) pair)
team_members(
team_id TEXT,
user_id TEXT,
role TEXT, -- 'owner' | 'admin' | 'member'
joined_at TEXT,
PRIMARY KEY (team_id, user_id)
)
-- Pending invites
team_invites(
id INTEGER PRIMARY KEY,
team_id TEXT,
invited_email TEXT,
role TEXT,
token TEXT UNIQUE,
expires_at TEXT,
accepted_at TEXT -- null while pending
)
-- Attachment: which team am I currently drawing quota from?
api_keys.team_id TEXT NULL -- FK to teams.id, INDEX
Redis cache apikey:{api_key.id} holds the auth middleware's resolved
ApiKeyRecord including team_id, billing_owner_id,
and the effective plan / limits. It's invalidated on every team lifecycle change so a
user never sees stale limits.
FAQ
How many members can a team have?
No hard cap enforced in code. Realistically the plan tier's quota is the constraint — 100k / mo across 100 members is 1,000 captures each which is often plenty. Reach out if you need a custom higher tier.
Can I be on multiple teams?
You can be a member of multiple teams (rows in team_members), but
your api_key can only draw quota from one team at a time — that's the
team pointed to by api_keys.team_id. Accepting a new invite switches your active
team. Leaving a team drops you back to personal.
What plan do I need to create a team?
Any paid plan (Starter, Pro, or Business). Free-plan users can accept invites into teams on paid plans but cannot create teams themselves.
Who pays for the team?
Currently the team creator (owner). Their Stripe subscription drives the team's plan and limits. See Billing above for details and future plans.
How fast does an invite take to accept?
Instant. The moment the invitee POSTs /v1/teams/accept, the auth cache is
punched and their next capture draws from the team pool. No polling, no delay.
Can I see team-level analytics?
Yes — GET /v1/usage called with any team member's key returns the team's
shared counter. GET /v1/usage/log returns individual captures, newest first,
with limit and offset for paging.
Each row carries member, the account's public handle
(pk_…), which is what you group by. It is deliberately not the API key:
that value is the live secret, and returning it would hand every owner their members'
credentials.
Scope follows our privacy policy rather than team membership
alone. Owners and admins get every member's rows ("scope": "team"); anyone else
gets only their own ("scope": "self"). A shared quota is not consent to be
watched by a peer.
Can I rename a team?
Not currently self-service. Reach out to support@getsnap.dev and we'll rename it for you. This is a Phase-2 enhancement on our list.
What happens to my API key when the team dissolves?
Nothing to your key itself — you keep it. But api_keys.team_id is nulled
so you fall back to your personal plan and limits. If you were on a free plan before joining
the team, you're back on free (100 / mo, 5 req/min). Any personal Stripe subscription you
may have kept alive resumes control.
Can I revoke a pending invite?
Not self-service yet. Pending invites expire on their own after 7 days. Reach out if you need one revoked immediately.
Programmatic access
Every endpoint above is a plain HTTP call with your X-API-Key. All SDKs
(Node, Python, CLI) mirror the shape. See the
Swagger UI for
request / response schemas exactly as the server sees them.
Related reading
- How team billing was built — the design decisions behind shared subscriptions
- Billing and payment flow — what a plan change does to your account