Marketplace / Docs / Organizations

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
Email 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):

  1. /pricing → click Team → land on /register?plan=team.
  2. Submit signup with Commercial Use Terms accepted + a desired org slug.
  3. Receive the verification email (sender Singularity <[email protected]>).
  4. Click the magic link → /verify?token=...&plan=team&slug=<picked>.
  5. The verify page auto-POSTs to /api/v1/billing/checkout/team-org and redirects to Stripe Checkout.
  6. Complete payment → Stripe fires checkout.session.completed → registry provisions the org (Phases 2 + 5).
  7. 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):

  1. Click the invite magic link → /orgs/{slug}/accept?token=....
  2. Page detects no session → redirects to /register?invitation_token=....
  3. Sign up with the invited email; verify; the verify endpoint sees the stored pending_invitation_token and auto-accepts the invitation.
  4. Stripe subscription quantity increments via the seat-sync task (Phase 3 A3.7).
  5. 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/organizations endpoint returns 410 Gone when the LICENSE_GATED_ORG_CREATION flag is enabled (production default). The CLI command snippbot marketplace org create is 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 export family 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/bulk API 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 createdeprecated, 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 matching singularity.json fields.

Billing commands

  • snippbot marketplace plans [--json]
  • snippbot marketplace billing <org-slug> [--json]