Organizations & Teams
Shared publisher namespaces, member roles, private registries, and team billing.
Jump to section
How to create organizations, invite members, assign roles, publish into private registries, and manage shared billing for teams.
⚠️ Beta — subject to change. Team and Enterprise subscriptions are a beta product as of May 22, 2026. The behavior documented here reflects the current implementation and roadmap intent, but prices, Seat minimums and maximums, activation mechanics, refund proration, and feature scope may change before general availability. The binding legal terms are the Snippbot License Agreement §§3–5 and the Commercial Use Terms. Treat this document as operational guidance only; in case of conflict, the legal terms control. Existing subscribers will receive at least 30 days' notice of any material change.
Overview
An organization is a shared namespace on the marketplace — a team of publishers who collaborate on packages, share billing, and (optionally) operate one or more private registries. Use one when:
- More than one person needs to publish to the same publisher handle.
- You want centralized billing for paid packages or team-tier features.
- You're hosting private packages that should only be visible to your team.
Each organization has a globally unique URL-safe slug, a display name, a Stripe-managed subscription, and a set of members with assigned roles.
UI Status
Organization administration is now available on the web at /dashboard/organizations/. The dashboard supports the full org lifecycle: list, detail with tabbed views (Members, Registries, Billing, Settings, Audit log), invite members (single + bulk-by-paste), manage roles, create / inspect / soft-delete private registries, view billing usage, open the Stripe Customer Portal, transfer ownership, and delete the organization.
The CLI remains supported in parallel — power users and CI flows can use either surface. The web dashboard is the recommended path for ad-hoc admin work; the CLI is the recommended path for scripting and CI.
Creating an org itself is web-only (see Creating an Organization) because team-tier features are gated behind a Stripe Checkout payment flow.
Roles
Four roles exist for organization members, enforced by the registry:
owner— full control, including transferring ownership and deleting the org.admin— manage members, registries, and settings; cannot delete the org or remove the last owner.publisher— publish packages under the organization's namespace and into its private registries.member— view-only access to org-private content.
Role rank for permission checks: owner (4) > admin (3) > publisher (2) > member (1). You cannot invite anyone at or above your own level, nor change the role of anyone at or above your own level. The last owner cannot be removed or demoted — promote another member first.
Signing up
Phase 7 introduced the web signup flow that powers everything below. If you only use the CLI you can skip to Creating an Organization; if you've never used the marketplace web at singularitymarketplace.com, read this first.
Surface: /register accepts an extended-form signup. The fields are different depending on whether you're signing up as a free individual publisher or for a Team / Enterprise plan.
| Field | Free Publisher | Team / Enterprise |
|---|---|---|
| required | required | |
| Password (≥ 10 chars) | required | required |
| Display name | required | required |
| Organization slug | — | required, live-checked for availability |
| Commercial Use Terms | — | required (/commercial-use) |
| Cloudflare Turnstile | required | required |
?invitation_token=... |
preserved if present | preserved if present |
?plan=team|enterprise |
n/a | drives the conditional fields above |
Endpoints (added in Phase 7):
| Path | Method | Auth | Purpose |
|---|---|---|---|
/api/v1/auth/signup |
POST | none + Turnstile | Create a publisher row; send verification email; issue a browser session cookie. |
/api/v1/auth/verify?token=... |
GET | none | Flip email_verified_at. Auto-accepts an invitation if the publisher row has a stored pending_invitation_token. |
/api/v1/auth/logout |
POST | session cookie | Delete the web_sessions row + clear cookies. Idempotent. |
/api/v1/auth/resend-verification |
POST | none | Send a fresh verification email; throttled 1/hour/email. |
Token TTLs:
| Token | TTL | Single-use? |
|---|---|---|
| Email verification | 24h | yes |
| Password reset | 1h | yes |
| Org invitation | 7d | yes |
| Web session cookie | 14d (rolling, last_seen_at bumped on every authenticated request) |
n/a |
Password hashing: new accounts hash with argon2id at time_cost=3, memory_cost=64MiB, parallelism=4. Legacy bcrypt hashes still verify; on a successful bcrypt login, the row is transparently rehashed to argon2id.
Rate limits (sliding-window, server-enforced):
| Surface | Limit |
|---|---|
| signup | 5 / hour / IP |
| login | 10 / hour / (IP + email) |
| forgot-password | 3 / hour / email |
| resend-verification | 1 / hour / email |
Customer journey (paid path):
/pricing→ click Team → land on/register?plan=team.- Submit signup with Commercial Use Terms accepted + a desired org slug.
- Receive the verification email (sender
Singularity <[email protected]>). - Click the magic link →
/verify?token=...&plan=team&slug=<picked>. - The verify page auto-POSTs to
/api/v1/billing/checkout/team-organd redirects to Stripe Checkout. - Complete payment → Stripe fires
checkout.session.completed→ registry provisions the org (Phases 2 + 5). - Browser returns to
/dashboard/organizations/{slug}(Phase 8 ships that page; Phase 7 redirects to/dashboard?joined=<slug>as an interim target).
Joining via invite (no account yet):
- Click the invite magic link →
/orgs/{slug}/accept?token=.... - Page detects no session → redirects to
/register?invitation_token=.... - Sign up with the invited email; verify; the verify endpoint sees the stored
pending_invitation_tokenand auto-accepts the invitation. - Stripe subscription
quantityincrements via the seat-sync task (Phase 3 A3.7). - Land in the org as an active member.
Feature flag: ENABLE_PUBLIC_SIGNUP defaults to false. When false, POST /api/v1/auth/signup returns 503 with {"error":"signup_disabled"}. Flip to true only after the production launch checklist in docs/runbooks/stripe-and-turnstile-setup.md is green.
CLI users sign up through the CLI's existing /api/v1/auth/register endpoint and are not affected by ENABLE_PUBLIC_SIGNUP. The new endpoints listed above are layered on top of the existing JWT/refresh-token system — they do not replace it.
Creating an Organization
Team organizations are a paid feature. Create one on the web at marketplace.snippbot.com/pricing — pick Team (minimum 2 seats, up to 25, 14-day free trial) or Enterprise (minimum 2 seats, unlimited, sales-led). Each seat receives the full Commercial License grant for the named user; no separate Commercial license purchase is required. The binding tier definitions, prices, and seat-minimum policy are in the Snippbot License Agreement §3–§5 and the Commercial Use Terms §3 — this section is operational guidance only.
Signup → email verification → Stripe Checkout (with quantity ≥ 2 enforced) → org is provisioned automatically when the webhook confirms payment.
Slug rules (enforced server-side):
- 2–64 characters.
- Lowercase alphanumeric and hyphens.
- Cannot start or end with a hyphen.
The signing publisher becomes the org owner. Slug uniqueness is enforced globally; the reservation is held for the lifetime of the Stripe Checkout session (24 hours) so picking a slug, then completing payment a few hours later, won't fight a race. Pending reservations older than 25 hours are deleted by the hourly cleanup job.
API path (for integrations)
POST /api/v1/billing/checkout/team-org
Authorization: Bearer <token-or-api-key>
Content-Type: application/json
{
"slug": "acme",
"display_name": "Acme Corp",
"plan": "team",
"billing_email": "[email protected]"
}
Returns { "checkout_url": "https://checkout.stripe.com/...", "organization_id": "..." }. The caller redirects the user to checkout_url; the org row exists in pending_activation state until the checkout.session.completed webhook flips it to trialing (or active if no trial). The caller's email must be verified (email_verified=TRUE on the publisher row); otherwise the endpoint returns 403 {"error":"email_unverified", "next_url":"/verify"}.
A concurrent attempt to reserve the same slug from a different publisher returns 409 {"error": "...reserved by another publisher..."}. A repeat attempt from the same publisher returns the original Checkout URL idempotently.
Deprecated: The legacy
POST /api/v1/organizationsendpoint returns 410 Gone when theLICENSE_GATED_ORG_CREATIONflag is enabled (production default). The CLI commandsnippbot marketplace org createis deprecated for the same reason — it exits with code 1 and a pointer to /pricing.
Listing Your Organizations
snippbot marketplace org list
snippbot marketplace org list --json
The text output is a table with columns: Organization, Plan, Members, Role (your role in the org). JSON output returns the full organizations array with full per-org metadata.
API: GET /api/v1/organizations — returns only the orgs you belong to.
Inviting Members
Two paths exist, with different ergonomics:
| Path | When to use | API field | Server behavior |
|---|---|---|---|
| By email (new) | Recipient may or may not have a publisher account yet | email |
Creates an organization_invitations row + sends a magic-link email |
| By publisher handle (legacy) | Recipient already has a publisher account | publisher_name |
Creates a pending organization_members row directly |
Only owner and admin members can issue invites; lower-privileged callers receive a 403. The role you assign must be strictly below your own level. Inviting on an org with subscription_status of canceled, unpaid, paused, or past_due returns 402 with a renewal URL.
Invite by email (Phase 3)
snippbot marketplace org invite <slug> --email <email> --role <admin|publisher|member>
Maps to POST /api/v1/organizations/{slug}/members with a body of:
{ "email": "[email protected]", "role": "publisher" }
The response is 202 Accepted with { "id": "...", "email": "...", "expires_at": "...", "status": "pending" } — the token itself is never returned in the response (only delivered via email). Rate limits: 50 invites per org per hour, 10 per inviter per hour, 3 resends per invitation per hour. Hitting any limit returns 429 with a Retry-After header.
The invitee receives an email with a magic link that lands on https://marketplace.snippbot.com/orgs/{slug}/accept?token=.... The invitation is scoped to the invited email address — if the recipient signs up with a different email, acceptance fails with 403 {"error":"email_mismatch"}. They must sign up with the invited email or request a fresh invitation.
Invite by publisher handle (legacy)
snippbot marketplace org invite <slug> --publisher <name> --role <admin|publisher|member>
Maps to POST /api/v1/organizations/{slug}/members with { "publisher_name": "alice", "role": "publisher" }. Returns 201 Created with the (pending) membership row.
Accepting an invitation
For the email path (magic link):
POST /api/v1/organizations/{slug}/invitations/{token}/accept
Authorization: Bearer <token-or-api-key>
For the legacy publisher-handle path:
snippbot marketplace org accept <slug>
(which maps to POST /api/v1/organizations/{slug}/members/accept). Either path flips the membership to active and fires the seat-sync task — the Stripe subscription's quantity increments by 1, with proration applied at the next invoice.
Managing pending invitations
snippbot marketplace org invitations list <slug>
snippbot marketplace org invitations resend <slug> <invitation_id>
snippbot marketplace org invitations revoke <slug> <invitation_id> --yes
API:
GET /api/v1/organizations/{slug}/invitations
POST /api/v1/organizations/{slug}/invitations/{id}/resend
DELETE /api/v1/organizations/{slug}/invitations/{id}
All three require admin or owner role. Revoking a still-pending invitation marks it revoked_at and the token becomes unusable. Resending throttles to 3 per hour per invitation.
Listing and Managing Members
snippbot marketplace org members <slug>
snippbot marketplace org members <slug> --json
The table shows each member, their role, status (active or pending), and join date. JSON output returns the raw members array.
API: GET /api/v1/organizations/{slug}/members.
To change a member's role:
PATCH /api/v1/organizations/{slug}/members/{publisher_id}
{ "role": "admin" }
Role updates are admin-or-above. You cannot change the role of any member at or above your own level, and you cannot promote anyone to a level at or above your own. Demoting the last owner is rejected with a 409 — promote another member to owner first if you need to step down.
Removing Members
snippbot marketplace org remove-member <slug> <publisher_id>
snippbot marketplace org remove-member <slug> <publisher_id> --yes
The CLI prompts for confirmation; --yes skips it. API: DELETE /api/v1/organizations/{slug}/members/{publisher_id}.
Removed members lose all access immediately. Removing yourself effectively means leaving the org; the last owner cannot leave without first promoting another member to owner.
Removing a member fires the seat-sync task — the Stripe subscription's quantity decrements by 1.
Updating Organization Details
PATCH /api/v1/organizations/{slug}
{
"display_name": "Acme Robotics",
"description": "...",
"avatar_url": "...",
"website": "...",
"billing_email": "..."
}
All fields are optional; send only what you want to change. The slug is immutable after creation. Owner/admin only.
Private Registries
Organizations can host private package registries — namespaces visible only to their active members. The CLI exposes a registry group:
snippbot marketplace registry create <org-slug> # interactive prompts
snippbot marketplace registry create acme --slug internal --display-name "Internal Tools" --max-packages 250
snippbot marketplace registry list acme
snippbot marketplace registry list acme --json
snippbot marketplace registry packages acme internal
snippbot marketplace registry packages acme internal --json
API endpoints:
POST /api/v1/organizations/{slug}/registries
GET /api/v1/organizations/{slug}/registries
GET /api/v1/organizations/{slug}/registries/{registry_slug}
PATCH /api/v1/organizations/{slug}/registries/{registry_slug}
DELETE /api/v1/organizations/{slug}/registries/{registry_slug}
GET /api/v1/organizations/{slug}/registries/{registry_slug}/packages
Each registry has its own slug, a display name, and a max_packages quota (default 100). Private registries are a paid-plan feature; the Free Publisher tier (individual publishers) cannot create registries.
Creating a registry on an org with an inactive subscription (past_due, canceled, unpaid, paused, or pending_activation) returns 402 with a renewal URL.
Publishing into an Organization
snippbot marketplace publish --organization <org-slug>
snippbot marketplace publish --organization <org-slug> --registry <registry-slug>
Without --registry, the package is owned by the org but visible per its visibility setting (typically public). With --registry, the package is private and visible only to org members.
The singularity.json manifest can also declare these fields:
{
"name": "internal-tool",
"version": "1.0.0",
"organization": "acme",
"private_registry": "internal"
}
CLI flags override manifest values. The caller must hold publisher role or higher in the org, and the org's subscription must be in trialing, active, or past_due state. (past_due allows existing publishers to keep shipping while billing is recovered.)
Installing from a Private Registry
snippbot marketplace registry install acme/internal/internal-tool
snippbot marketplace registry install acme/internal/internal-tool 1.2.3
The reference format is <org-slug>/<registry-slug>/<package-name>. The daemon's marketplace client must be authenticated as an active member of the org; non-members receive a 404 (not a 403 — by design, so private package names aren't enumerable).
To list what's installable from a registry:
snippbot marketplace registry packages <org-slug> <registry-slug>
CI bots that pull from a private registry can persist their org context in ~/.snippbot/config.toml:
[marketplace.organizations.acme]
default_registry = "internal"
api_key = "snip_live_..."
Billing and Plans
snippbot marketplace plans
snippbot marketplace billing <org-slug>
snippbot marketplace billing <org-slug> --json
plans lists the available subscription plans (Team, Enterprise — Free Publisher is the individual default and isn't org-scoped). billing resolves the org slug to a UUID, then fetches the current plan + seat usage. Both commands accept --json for scripts.
Pricing (as of v3 plan):
| Plan | Price | Seat cap | Trial | Notes |
|---|---|---|---|---|
| Free Publisher | $0 | n/a | n/a | Individual, public packages only. No team features. |
| Team | $29 / seat / month | 25 | 14 days | Private registries, audit log, all role tiers. |
| Enterprise | $99 / seat / month | unlimited | none | Sales-led, priority support, custom integrations. |
Plan changes (upgrade / downgrade / cancel) are handled in the Stripe Customer Portal — there's no CLI for them. Billing-history retrieval is GET /api/v1/organizations/{org_id}/billing/history.
When a subscription goes past_due, the org can still publish (no resource increase) and remove members (resource decrease), but new invites and registry creation are blocked. canceled, unpaid, and paused block all writes; the org's data is preserved.
Audit Logs
Organizations record administrative actions in an audit log:
GET /api/v1/organizations/{slug}/audit-log
The log captures membership changes (invites, removals, role updates), invitation lifecycle (created, accepted, revoked, resent), seat-count changes, registry creation/deletion, settings changes, ownership transfers, and other privileged operations. Each entry includes action, actor_publisher_id, resource_type, resource_id, and a JSON details blob.
Free-plan orgs do not retain audit logs (audit_log_retention_days=0). Team retains 90 days; Enterprise retains 365.
There is no dedicated CLI surface for audit logs yet — query the endpoint directly with your API key. A dashboard view is part of Track B (Phase 12).
Limitations
The following capabilities are intentionally not yet shipped in the team-orgs plan. They are tracked separately and will arrive in follow-on work.
- SSO (SAML / OIDC). The schema columns (
sso_enabled,sso_config) are present but no endpoints implement them. A future plan will address SSO end-to-end. - Stripe Connect / publisher payouts. All payment flow today runs through a single platform Stripe account. Per-publisher payouts for paid private packages are out of scope.
- Org-to-org transfers. Packages cannot move between orgs. Republish to the new org if you need to.
- Granular per-package ACLs. Visibility is
public/org_only/private(registry-scoped). Per-member overrides on individual packages are not supported. - Domain-verified auto-join. "Anyone with an @acme.example address auto-joins acme" is not implemented; every join requires an explicit invitation.
- GDPR org-deletion exports. The
snippbot exportfamily is the right home for these; org-level export is not part of the team-orgs plan. - SCIM provisioning. No SCIM endpoint exists.
- Email-change flow. A publisher cannot change their email today. A future plan covers this; until then, contact support.
- Account merging. Invitations are scoped to a specific email. If a teammate signs up with a different email than the invited one, accept fails with 403 and they must register with the invited address (or request a new invitation).
- Bulk invite UI. The
POST /api/v1/organizations/{slug}/members/bulkAPI endpoint exists (Phase 3); the marketplace web UI for paste-50-emails-at-once ships with Phase 12. - i18n. English only across CLI output, emails, and dashboard. Localization is deferred.
If you need any of these and they're blocking your team, please file an issue describing the use case.
CLI Reference
For the full set of flags and subcommands, see the Snippbot CLI reference.
Organization commands
snippbot marketplace org create— deprecated, exits non-zero and points at /pricing. Team orgs are created on the web (Stripe-Checkout-mediated, paid).snippbot marketplace org list [--json]— list orgs you belong to.snippbot marketplace org members <slug> [--json]— list members.snippbot marketplace org invite <slug> --email <email> --role <admin|publisher|member>— invite by email (Phase 3).snippbot marketplace org invite <slug> --publisher <name> --role <admin|publisher|member>— invite by publisher handle (legacy).snippbot marketplace org invitations list <slug> [--include-inactive] [--json]— list pending invitations.snippbot marketplace org invitations resend <slug> <invitation_id>— re-issue the invitation email (throttled to 3/hour per invitation).snippbot marketplace org invitations revoke <slug> <invitation_id> [--yes]— revoke a pending invitation.snippbot marketplace org accept <slug>— accept a pending invitation (legacy handle-path; email path uses the magic link).snippbot marketplace org remove-member <slug> <publisher_id> [--yes]— remove a member.
Private registry commands
snippbot marketplace registry create <org-slug> --slug X --display-name Y [--max-packages N]snippbot marketplace registry list <org-slug> [--json]snippbot marketplace registry packages <org-slug> <registry-slug> [--json]— list packages in a registry.snippbot marketplace registry install <org-slug>/<registry-slug>/<package-name> [<version>]— install a private package as a member.
Publish commands
snippbot marketplace publish [--organization <slug>] [--registry <slug>]— publish; flags override the matchingsingularity.jsonfields.
Billing commands
snippbot marketplace plans [--json]snippbot marketplace billing <org-slug> [--json]