Proposal · Dashboard URL identity

Nicer Customer URLs.
Slug, ref, or hybrid?

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.

June 2026
Today …/customers/f47ac10b-58cc-4372-a567-0e02b2c3d479/production/payments
Goal …/customers/acme-pay/production/payments
01 — Why

Why do we want to do this?

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.

👀

Readability & orientation

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.

🔗

Shareability

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.

Product polish

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 model already fits

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 timing window is open — and closing

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.

02 — Options

What are our options?

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

Keep the UUID

Status quo
/customers/f47ac10b-58cc-4372-a567-…/production
CloudflareAWS
  • Zero work, zero risk
  • Unguessable — layers on the uniform-404 rule
  • Rename-proof forever
  • Unreadable, unshareable, unmemorable
  • Reads as unfinished next to every peer product
Caveat: "do nothing" still costs brand impression with exactly the audience this dashboard serves.
B

Short opaque ref

Stripe / Supabase-project style
/customers/org_x7k2f9qm4t/production
Stripe acct_…Supabase project ref
  • Short, copy-friendly, debug-friendly (org_ prefix)
  • Reveals nothing about the customer
  • Immutable — no rename or uniqueness machinery
  • Still meaningless to humans
  • Doesn't answer "which org is this tab?"
  • Second identifier column to keep in sync
Caveat: pick the format once (alphabet, length, prefix) — changing it later is another migration.
D

Slug + ID suffix

Notion-style hybrid
/customers/acme-pay-x7k2f9/production
NotionFigmaLinear issue titles
  • Rename-proof with no redirect infra — resolver ignores the slug part
  • No global slug uniqueness needed
  • Uglier than a pure slug — the suffix is permanent noise
  • Parsing convention needed (slugs and UUIDs contain hyphens)
  • Still needs a short-unique-ID column + canonical-redirect logic
Caveat: built for user-mutable free-text titles renamed constantly (Notion pages). An admin-assigned org slug isn't that — this likely over-engineers our case. Better fit later for resource-level deep links (merchants, webhooks).
03 — Side by side

Comparison

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)
04 — Decide

Decision flow

Two questions settle it. Each terminal outcome lists its caveats and the downstream questions we'd still need to answer before building.

Q1 · Privacy / threat model

Can the customer's company name appear in dashboard URLs?

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.

No

BShort opaque ref

Fix length and copy-friendliness without exposing identity. Skip slugs entirely — don't pay slug costs for a hidden name.

Downstream questions
  • Format: org_ prefix? nanoid alphabet & length?
  • New column, or new PK format for new rows only?
  • Does the OpenAPI param stay partnerOrgId?
  • Backfill strategy for existing orgs?
Yes
Continue to Q2 ↓
Q2 · Slug lifecycle

Will slugs be admin-assigned and rarely change?

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.

Yes

CPure slug, immutable in v1 ★

The recommended path. Admin assigns (auto-suggested from name) at creation; renames are a later feature with a redirect table if ever needed.

Downstream questions
  • Company rebrand escape hatch: support-driven manual rename with comms, or hard no?
  • Who owns the reserved-word list (default, new, all, admin, …)?
  • Full unique index (never reissue a deleted org's slug) — confirm vs. partial-index convention?
  • API boundary: param becomes partnerOrgSlug, or dashboard-only resolution?
  • Add a threat-model note on guessable customer URLs?
No

C+/DMutable slug or hybrid

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).

Downstream questions
  • Redirect lifetime: forever, or time-boxed?
  • Squatting rule: are released slugs ever reissuable?
  • Hybrid parsing: separator convention for the ID suffix?
  • Canonical redirect on stale slug — 308 from middleware or page loader?

★ Recommended path

Q1 · Name in URL → accept Q2 · Admin-assigned, immutable → yes

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.

05 — Codebase caveats

Complexities specific to us

These apply to any slug-shaped outcome (C, C+, D) and follow from how the current routing model works.

1

Reserved words become real

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.

2

Anti-enumeration does all the work alone

/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.

3

Soft-delete vs. slug reuse

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.

4

Where slug→ID resolution lives

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.

5

Contract updates

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.

06 — Scope

What we'd build (Option C)

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.

1

Database

slug column on partner_organization, lower(slug) full unique index, format + reserved-word validation; backfill migration slugifying name with -2-style collision suffixes.

2

Slug utility

Slugify + validate + reserved-list module in the Merchant API service, unit-tested.

3

Admin surface

Slug shown and assignable on customer create (auto-suggested from name); immutable after creation in v1.

4

Merchant API

Path-resolver middlewares look up by slug; route-order tests extended with reserved words; customers/default + context endpoints return partnerOrg.slug; OpenAPI param rename.

5

Dashboard

Rename [partnerOrgId] route folders; update the route-params helper, route-context provider, link builders, and default-org redirect; mechanical Playwright path updates.

6

Tests

Integration: slug resolution, unknown-slug anti-enumeration, reserved-word rejection, route ordering. Dashboard: param parsing + link builders.