Waitlist Public API
The Waitlist Public API provides two unauthenticated endpoints consumed by the Selgeo marketing landing page. These endpoints run in a dedicated service (apps/public-api, port 4002) and are separate from the main Selgeo API (apps/api, port 4000).
These endpoints do not require an API key, bearer token, or any form of authentication. They are rate-limited per IP address and protected by a self-hosted honeypot + time-to-submit anti-spam check (no third-party service).
Base URL
Production: https://app.selgeo.com
Staging: https://staging.selgeo.com
Routes are served under the /api/public/ path prefix.
Endpoints
POST /api/public/waitlist
Add an email address to the pre-launch waitlist.
The submitted email is normalised (lowercased and trimmed) before persistence. The response body is intentionally empty and byte-identical for both a new signup and a duplicate submission — callers cannot distinguish between the two (privacy by design, per FR-009).
The request must include two anti-spam fields alongside the email: a hidden website honeypot field (the landing page renders it hidden so humans never fill it; naive bots do) and an elapsedMs counter measuring milliseconds between form mount and submit. The server rejects requests where the honeypot is non-empty or elapsedMs is outside [1500, 1_800_000].
Request body:
{
"email": "alice@example.com",
"website": "",
"elapsedMs": 2000
}
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address. Normalised to lowercase. Max 320 characters. |
website | string | No (default "") | Honeypot. MUST be empty. Any non-empty value is rejected. |
elapsedMs | integer | Yes | Milliseconds between form mount and submit. Must be in [1500, 1_800_000]. |
Responses:
| Status | Description |
|---|---|
201 Created | Empty body. Returned for both new signups and duplicates. |
400 Bad Request | Validation failure (INVALID_EMAIL) or anti-spam rejection (ANTI_SPAM_FAILED). |
429 Too Many Requests | Rate limit exceeded. Includes Retry-After header (seconds). |
Error codes:
| Code | Description |
|---|---|
INVALID_EMAIL | Email is missing or not a valid email format. |
ANTI_SPAM_FAILED | Honeypot (website) was non-empty, or elapsedMs was missing / out of range. |
RATE_LIMITED | IP address exceeded the per-window rate limit. |
Example (curl):
curl -sS -D- -X POST https://app.selgeo.com/api/public/waitlist \
-H 'Content-Type: application/json' \
-d '{"email": "alice@example.com", "website": "", "elapsedMs": 2000}'
GET /api/public/waitlist/waitlist-count
Get the displayed waitlist counter for the landing page.
Returns the number of signups taken and the configured total cap. The counter is cached in Redis (default 45-second TTL) so this endpoint is safe to call on every page load. The Cache-Control: public, max-age=30 response header allows CDN and browser caching.
The taken value includes an optional WAITLIST_BASE offset configured by the marketing team — it does not reflect the exact number of database rows.
Response body:
{
"taken": 27,
"total": 50
}
| Field | Type | Description |
|---|---|---|
taken | integer | Displayed number of spots taken (DB count + WAITLIST_BASE, clamped to total). |
total | integer | Configured total cap (WAITLIST_TOTAL). |
Example (curl):
curl -sS https://app.selgeo.com/api/public/waitlist/waitlist-count
Privacy
- Raw email addresses are never logged. Structured log events carry an
email_hash(HMAC-SHA256 withIP_HASH_PEPPER) instead. Client IPs are similarly logged asip_hash. - Deduplication uses a unique constraint on the normalised
emailcolumn — the plaintext email is stored in the database (not the hash) so the operator CLI can service GDPR Art. 15/17 requests by email. - Email addresses are retained for 12 months from signup date (FR-024). Rows older than 12 months are purged by a daily cleanup job.
- GDPR Art. 15 (access) and Art. 17 (erasure) requests are handled via the operator CLI (
pnpm --filter @affiliate/public-api ops:access/ops:erasure).
OpenAPI Specification
The full OpenAPI 3.1 specification for this service is available at /public-api-openapi.yaml. You can import this file into Postman, Insomnia, or any OpenAPI-compatible client.