The new dashboard routes ship with a raw UUID as the customer segment. This proposal walks through why we should replace it, the four realistic options, and a decision flow to pick one.
partner_organization.id is a gen_random_uuid() text primary key.
It is correct as an identifier — but as a
URL surface shown to B2B customers every day, it has real costs.
36 characters of hex noise dominate the address bar. A customer with two orgs (or our team, with many) can't tell which tab, bookmark, or support screenshot belongs to which org.
URLs get pasted into Slack, tickets, and docs constantly. /customers/acme-pay/production/payments is self-describing; the UUID version needs a lookup to mean anything.
Every org-shaped peer product — GitHub, Vercel, Linear, Sentry, Supabase, PlanetScale — puts a human identifier in the org segment. A raw UUID reads as unfinished next to them.
The hard routing work is done: /customers/{partnerOrgId}/{environment}/… is live, with centralized param parsing, link builders, and path-resolver middleware. Only the format of one segment changes.
The new UUID URLs are barely live. Switching the segment early, before customers bookmark and embed UUID URLs, means zero legacy-URL handling. Wait too long, and we owe UUID→slug redirects forever.
Four patterns exist in the wild. Each card shows the URL shape, who uses it, and the trade-offs as they apply to our codebase — admin-created, low-volume B2B customer organizations behind a uniform-404 anti-enumeration rule.
| A · UUID | B · Opaque ref | C · Pure slug ★ | D · Hybrid | |
|---|---|---|---|---|
| URL legibility | None | Compact | Excellent | Good-ish |
| Build cost | None | Small | Medium | Medium+ |
| Rename safety | N/A | Immutable | Policy choice | Free |
| Uniqueness machinery | None | Column only | Global + reserved words | Suffix only |
| Customer identity in URL | Hidden | Hidden | Exposed | Exposed |
| Org-segment precedent | Cloudflare, AWS | Stripe, Supabase | GitHub, Vercel, Sentry, Linear | Notion, Figma (pages/files) |
Two questions settle it. Each terminal outcome lists its caveats and the downstream questions we'd still need to answer before building.
Slugs leak who our customers are via screenshots, browser history, support tickets, and analytics. Today's UUID leaks nothing. For a payments product this is an explicit threat-model call, not a default.
Fix length and copy-friendliness without exposing identity. Skip slugs entirely — don't pay slug costs for a hidden name.
org_ prefix? nanoid alphabet & length?partnerOrgId?Customer organizations are created by our admins, not self-serve signup. If the slug is assigned once at creation, the expensive parts of slugs — renames, redirects, squatting — never activate. Sentry's no-redirect rename pain and GitHub's namespace-retirement system are what "no" costs at scale.
The recommended path. Admin assigns (auto-suggested from name) at creation; renames are a later feature with a redirect table if ever needed.
default, new, all, admin, …)?partnerOrgSlug, or dashboard-only resolution?If customers will rename themselves or renames will be frequent: either C plus a redirect table (GitHub model — redirects break if the old slug is reissued), or D where the trailing ID makes renames free and the slug is cosmetic (Notion model).
Option C: admin-assigned, immutable-in-v1 slug, shipped as a follow-up — early enough that no legacy URL handling is needed. If Q1 fails the privacy call, fall back to Option B — everything else about the route model stays identical.
These apply to any slug-shaped outcome (C, C+, D) and follow from how the current routing model works.
UUIDs can never collide with the static GET /_dashboard/v1/customers/default route, so no reserved-word system exists today. A human slug can collide. Slugs need a format regex, a reserved list, and a test asserting the list covers every static segment under /customers.
/customers/stripe/production is guessable where a UUID wasn't. The uniform not_found rule still prevents existence confirmation — but it's now the only layer. Worth one sentence in the threat model.
The org table uses deleted_at with partial unique indexes. Slugs should get a full unique index so a deleted org's slug is never silently reissued to a different company — the GitHub-squatting lesson in miniature.
Preferred: rename the boundary param to partnerOrgSlug and resolve in the two path-resolver middlewares, keeping dashboard and API URL trees as mirror images. The alternative (dashboard-only slug, UUID at the API) breaks the mirror and makes the route-context provider carry two identifiers.
OpenAPI path params rename (operation IDs like PartnerOrgEnvironment* are unaffected); the /customers/default and context endpoints return the slug so the dashboard can build links; the slug becomes a first-class domain term.
Param parsing, link building, and access resolution are already centralized by design — so the touch points are few. The schema/backfill and admin UI are the bulk of it.
slug column on partner_organization, lower(slug) full unique index, format + reserved-word validation; backfill migration slugifying name with -2-style collision suffixes.
Slugify + validate + reserved-list module in the Merchant API service, unit-tested.
Slug shown and assignable on customer create (auto-suggested from name); immutable after creation in v1.
Path-resolver middlewares look up by slug; route-order tests extended with reserved words; customers/default + context endpoints return partnerOrg.slug; OpenAPI param rename.
Rename [partnerOrgId] route folders; update the route-params helper, route-context provider, link builders, and default-org redirect; mechanical Playwright path updates.
Integration: slug resolution, unknown-slug anti-enumeration, reserved-word rejection, route ordering. Dashboard: param parsing + link builders.