Teams

Share one plan, one monthly quota, and one rate limit across multiple api_keys.

How it works. Every team has an owner and any number of admins and members. All api_keys attached to the team share the team's plan and quota. Each teammate keeps their own key (no shared secrets); usage tracking still logs individual users. Invite by email — the invitee gets a one-click accept link with a 7-day TTL.
Your key is stored only in this browser's localStorage. Never sent anywhere except getsnap.dev.

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 quotaYesYesYes
View team membersYesYesYes
View team's shared usageYesYesYes
Invite new members (any role)YesYesNo
Leave teamNo*YesYes
Transfer ownershipYesNoNo
Dissolve teamYesNoNo

*Owners cannot leave their own team directly. They must transfer ownership to another member first, then leave; or dissolve the team entirely.

Team lifecycle

  1. 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.
  2. Owner or admin invites a member by email. Invitee receives a signed accept link with a 7-day TTL.
  3. Invitee accepts via POST /v1/teams/accept. Their api_key now points at the team; their next capture draws from the shared pool.
  4. Members work. All captures aggregate under the team's counter.
  5. 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.
  6. Ownership changes if the owner is leaving the company. Existing owner promotes another member via POST /v1/teams/:id/transfer-ownership, then optionally uses /leave to depart.
  7. Owner dissolves the team via DELETE /v1/teams/:id when 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

FieldTypeDescription
namestring, 1–60 charsTeam 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

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

FieldTypeDescription
emailstringThe invitee's account email.
roleenum"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

FieldTypeDescription
tokenstringThe 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

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

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

FieldTypeDescription
new_owner_user_idstringThe 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

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

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

FieldTypeDescription
planenum"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

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

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

FieldValuesDescription
subscription_statusstring | nullRaw 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_endISO string | nullNext renewal date. Null for teams without a sub.
cancel_at_period_endbooleanTrue 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_subscriptionbooleanFalse = 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

EventResult
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:
  • "Cancel now" → Sub deleted → team plan → free.
  • "Cancel at period end" → Sub stays active until current_period_end, then downgrades. cancel_at_period_end flag shows true in the meantime so the dashboard can warn members.
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.

SignalBehavior
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

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