Architecture¶
Overview¶
frontend (React/Vite) ---> backend (FastAPI) ---> PostgreSQL
|
+--> Redis <--- Celery worker + beat
|
+--> tracking providers (InPost, Poste Italiane, 17TRACK, ...)
The backend is the only thing that talks to PostgreSQL, Redis, or any external tracking provider. The frontend only ever talks to the backend's REST API.
Data model¶
- User — an account on this instance.
is_admingates user management endpoints. - Carrier — a shipping company (BRT, GLS, Poste Italiane, InPost, ...). Seeded from
backend/app/seed/carriers.yaml. - Shop — a marketplace/retailer (AliExpress, Amazon, ...), used only to suggest likely
carriers in the add-tracker UI. Seeded from
backend/app/seed/shops.yaml; the suggestions themselves come fromShopCarrierHintrows seeded fromshop_carrier_hints.yaml. - Provider — a concrete way of fetching tracking data for one or more carriers: an official
API, a scraper, or a third-party aggregator. Seeded from
backend/app/seed/providers.yaml, which is also where a provider'ssetup_guide(rendered by the frontend's guided "Connections" screen) lives. - ProviderCarrierSupport / ProviderCarrierExclusion — which carriers a provider covers.
A provider with
supports_all_carriers=True(an aggregator) needs no rows here at all; carriers it explicitly does not cover go inProviderCarrierExclusioninstead. A provider that only covers a handful of carriers (an official API or scraper) lists them explicitly inProviderCarrierSupport. The query for "which providers can track carrier X" is a union of both cases — seelist_providers_for_carrierinbackend/app/api/routers/carriers.py. - Package — a tracked shipment: the tracking number, the chosen carrier/provider/shop, the
normalized
status, and the polling schedule (next_check_at,check_interval_seconds,failure_count). - TrackingEvent — one status update in a package's history, deduplicated on
(occurred_at, description)so re-fetching the same data never creates duplicates. - UserProviderCredential — a user's own credentials for a provider that needs them (e.g. an
aggregator API key), encrypted at rest with a server-side Fernet key
(
TAP_CREDENTIAL_ENCRYPTION_KEY) and never returned decrypted by the API.
The provider plugin system¶
Every concrete way of fetching tracking data implements TrackingProvider
(backend/app/providers/base.py):
class TrackingProvider(ABC):
code: ClassVar[str]
kind: ClassVar[ProviderKind] # official_api | scraper | aggregator
supports_all_carriers: ClassVar[bool] = False
supported_carrier_codes: ClassVar[frozenset[str]] = frozenset()
requires_credentials: ClassVar[bool] = False
async def fetch(self, tracking_number, carrier_code, credentials) -> TrackingResult: ...
async def test_credentials(self, credentials) -> bool: ... # only if requires_credentials
Each provider lives in its own package under backend/app/providers/<code>/ and registers itself
with a class decorator on import:
@register_provider
class InPostProvider(TrackingProvider):
code = "inpost_official"
...
backend/app/providers/__init__.py imports every provider module explicitly — a deliberate
choice over dynamic plugin discovery (scanning directories, entry points): the set of active
providers stays explicit, type-checkable, and easy to trace. See
ADDING_A_PROVIDER.md for the full walkthrough of adding one.
Three real providers ship today, one per ProviderKind:
| Provider | Kind | Carrier(s) | Credentials |
|---|---|---|---|
inpost_official |
official_api |
InPost | none |
poste_it_scraper |
scraper |
Poste Italiane | none |
aggregator_17track |
aggregator |
any (fallback) | user's own 17TRACK API key |
Request flow: adding and refreshing a package¶
GET /api/shops→ the add-tracker wizard shows shop options and, once one is picked, the carriers that shop'sShopCarrierHintrows suggest (purely a UX hint, never enforced).GET /api/carriers/{code}/providers→ once a carrier is picked, the wizard lists every provider that covers it, so the user can choose official API vs. scraper vs. aggregator.POST /api/packages→ validates the chosen provider actually covers the chosen carrier (_provider_supports_carrierinbackend/app/api/routers/packages.py) before creating the row.POST /api/packages/{id}/refresh→ looks up the provider implementation in the registry, loads the user's decrypted credentials if the provider needs them, callsfetch(), and applies the result viaapply_tracking_result(backend/app/services/tracking.py) — the same function the Celery task uses, so on-demand and scheduled refreshes behave identically.
Provider errors (backend/app/providers/base.py) map to HTTP statuses through a single exception
handler (backend/app/api/errors.py) rather than per-endpoint try/except:
ProviderInvalidTrackingNumberError → 404, ProviderNotConfiguredError → 409,
ProviderRateLimitedError → 429, ProviderAuthenticationError → 424, anything else → 502.
Scheduled polling (Celery)¶
backend/app/workers/tasks.py has two tasks:
enqueue_due_packages— runs every 5 minutes (see the beat schedule inbackend/app/core/celery_app.py). Selects packages whosenext_check_athas passed (or is stillNULL, i.e. never checked) and dispatchescheck_package_statusfor each.check_package_status— does the actual fetch for one package, then:- on success, calls
apply_tracking_result, which speeds up polling to every 5 minutes while a package isout_for_deliveryand stops polling entirely once it'sdelivered; - on a permanent error (invalid tracking number, bad/missing credentials), calls
record_fetch_failure(exponential backoff, capped at 24h) and does not retry; - on a transient error or a rate limit, calls
record_fetch_failureand re-raises, so Celery'sautoretry_forhandles the retry with exponential backoff.
Per-provider request-rate limiting (respecting, say, an aggregator's free-tier quota) is enforced
inside check_package_status itself, via a Redis fixed-window counter keyed by provider code
and configured through Provider.config["max_requests_per_minute"] — not via dedicated Celery
queues. This keeps the provider plugin system the one place that needs to know about a given
provider's limits, and means a single worker process handles every provider regardless of how
many are installed.
Guided onboarding¶
Two flows exist specifically because this is meant to be self-hosted by people who are not necessarily the ones who built it:
- First-run setup:
GET /api/auth/setup-statusreports whether anyUserexists yet. If not, the frontend shows a "create the admin account" screen instead of a login form (frontend/src/features/setup/FirstRunSetup.tsx).POST /api/auth/setuponly succeeds once, ever, per instance. - Guided connections: for providers with
requires_credentials=True,Provider.setup_guide(seeded JSON: an intro, numbered steps with optional links, and the credential fields to collect) drivesfrontend/src/features/settings/ConnectionGuideCard.tsx, which renders the instructions, lets the user test their credentials against the real provider (POST /api/providers/{code}/test-credential, backed byTrackingProvider.test_credentials) before saving anything, and shows whether a connection is currently configured.