Three carriers. One Django app. A test suite that grew 16 β†’ 23 β†’ 27 β†’ 33 β†’ 40 as the feature set did. And 31 static pages feeding it traffic.

On October 5 and 7, 2026, a private, single-carrier FedEx tracker β€” dormant since June, tracking packages for exactly one person β€” became track.trackingsucks.com: a multi-tenant app for FedEx, UPS, and USPS where every tenant supplies their own carrier API keys.

Here's the architecture, in the order we built it.

Multi-tenancy in one migration

The old app had no ownership concept because it never had a second user. Retrofitting one took a single migration, four steps:

  1. Add a nullable owner FK to Package and SavedReference.
  2. Backfill existing rows to the oldest superuser.
  3. Drop the global unique constraints.
  4. Add per-owner constraints: (owner, tracking_number) and (owner, reference_value).

Step 4 is the one that matters. With per-owner uniques, two tenants can track the same physical package independently β€” their own nicknames, notes, and refresh history. A global unique constraint would have handed permanent ownership of a tracking number to whoever added it first. When your pitch is "your private list," that's a product bug disguised as a schema constraint.

The migration also forced an audit the single-user app never needed: the home view got @login_required β€” it hadn't had one, because there was never anyone else to keep out. If your app has only ever had one user, you have unscoped views you've never had to notice.

Registration is invite-gated from day one: /register/ reads REGISTER_INVITE_CODE from the environment, and an empty code means registration is closed. Closed by default is the right default.

Shipped at 16/16 tests.

BYOK: the tenant owns the carrier relationship

The obvious architecture β€” you hold one set of FedEx/UPS/USPS credentials and track on everyone's behalf β€” is the wrong one. Carrier terms, rate limits, and blast radius all point the same way: bring your own keys.

The model, in full:

  • CarrierCredential: owner FK, carrier (fedex | ups | usps)
  • api_key_enc / secret_key_enc β€” Fernet-encrypted at rest, key derived from the Django SECRET_KEY
  • account_number in plain text (an identifier, not a secret), plus an optional base_url override
  • UniqueConstraint(owner, carrier) β€” one credential set per carrier per tenant

The paste-keys dashboard matches each carrier's actual credential shape, because they genuinely differ: FedEx wants an API key + secret + optional account number; UPS wants a Client ID + secret; USPS wants a single X-API-Key. Saved values render masked (first4β€’β€’β€’β€’last4), raw keys are never rendered again after save, and every query filters owner=request.user.

33/33 tests by the time the dashboard shipped.

Three carrier APIs, one shape

Then the wire reality. All three carriers authenticate differently:

  • UPS: OAuth first β€” Basic auth with client ID and secret, grant_type=client_id, POST /security/v1/oauth/token β€” then GET /api/track/v1/details/{tracking}
  • USPS: GET /shipping/v3/tracking with an X-API-Key header
  • FedEx: API key + secret against apis.fedex.com

The fix for that variety is an adapter, not a framework. Each client module ends in normalize_result, which emits the same FedEx-trackResult-shaped dict the app already understood β€” so services.upsert, the one function every carrier feeds, didn't change a single line. Adding carrier #4 means writing a client and a normalizer; storage stays untouched.

Credential resolution runs through one function: resolve_carrier_credentials(owner, carrier) returns the tenant's decrypted keys or None, with an env-var fallback preserved for the legacy account. The refresh command fetches per (owner, carrier) with a short cache, updates every tenant's copy of a package, and degrades gracefully β€” a tenant with no saved keys gets skipped with a note, not a crash.

One caveat, stated at ship time: the UPS and USPS wire shapes were doc-based and fixture-tested, not live-verified β€” no such keys existed on the box yet. The first real key is the live proof; if a field differs, it's a one-line normalizer tweak, and the fixtures say which line.

40/40 tests.

CSV import that detects the carrier

Nobody types 40 tracking numbers by hand. The importer takes a multi-carrier CSV and detects the carrier per row:

  • starts with 1Z β†’ UPS
  • 20–23 digits starting 92–95 β†’ USPS
  • 12 or 15 digits, or a 96 prefix β†’ FedEx
  • a Carrier or Service column overrides the pattern β€” necessary, because FedEx Ground Economy rides the USPS last mile and arrives with a USPS-format number

Import is a per-owner upsert with update-only-if-newer semantics, so re-importing an old export never clobbers fresher status. The two-owners-same-file test and the stale-row-no-clobber test are the two worth copying verbatim into your own project.

23/23 at ship (7 new tests for the importer).

31 static pages, one generator, one funnel

The marketing layer is a single build.py (~32 KB) that generates 31 pages: a homepage, three carrier hubs, and 27 status explainers β€” ten FedEx status pages (including "No Scheduled Delivery Date Available"), nine UPS (including Access Point), eight USPS (including "In Transit to Next Facility").

Every status page answers the same four questions: what the status means, why packages get stuck there, how long the dwell usually runs, and what to do about it β€” marked up with FAQPage JSON-LD so it's rich-result eligible. The carrier hubs carry escalation ladders with real phone numbers (FedEx 1-800-463-3339, UPS 1-800-742-5877, USPS 1-800-275-8777).

Trademark rules were decided before the first page rendered: carrier names in paths and content are nominative use and fine; logos and trade dress are not; no carrier subdomains; a not-affiliated disclaimer on every page.

And every one of the 31 pages ends with the same call to action pointing at the live tracker. Sitemap: 31 URLs. IndexNow accepted all 31 on first submit.

What bit us

  • cryptography was missing from requirements.txt. The Fernet import only crashes on a fresh install β€” the dev box had the package transitively, so everything looked green until a clean clone fell over. A dependency you import directly belongs in your requirements file, always.
  • Doc-based adapters need the caveat attached. Fixture tests prove your normalizer matches your fixtures; they don't prove the carrier matches your fixtures. Say so at ship time, then close the gap the moment the first live key arrives.
  • The test count is the commit message. 16 β†’ 23 β†’ 27 β†’ 33 β†’ 40, one green run per feature, in the same session as the feature. Per-owner constraints and update-only-if-newer semantics are exactly the subtle behaviors that tests pin down before a second tenant ever shows up.

The BYOK punchline

Bring-your-own-keys isn't a privacy feature bolted on at the end β€” it reshapes the whole product. You never hold a hundred tenants' carrier secrets. A tenant's rate limits are their own. One account getting flagged at a carrier takes out one tenant, not the app. Your job narrows to what it should be: storage, normalization, import, and UI.

Every tenant brings their own carrier relationship. You provide the plumbing. Turns out that's the whole product story β€” and the whole deploy story too, because nothing you run needs a secret that isn't yours.

More build notes like this one are on the blog. The builder and deploy pipeline that shipped this app β€” git-based, one box, systemd services β€” are documented at /docs/.