When the website launched, the backend was deliberately narrowed to one job: deliver contact enquiries into CRM reliably. The endpoint is small — but small is not the same as incomplete. Every failure path needs a definite answer. Here is the full checklist, in the order a request travels.
Processing order: decide how to refuse at every step
A submission moves through fixed, tightening layers:
- Resolve the real client IP (from forwarded headers behind a proxy — provided the deployment passes them correctly);
- Per-IP rate limiting: over the threshold gets a 429 with retry timing;
- Body size and JSON validation: oversize or unparsable is refused;
- Field allow-list validation (table below);
- Human verification: Turnstile checked server-side against an action name bound to this endpoint;
- Global quota: absorbs distributed spikes;
- Idempotency and filing: a unique constraint settles concurrency;
- Reference number: a sequence produces a human-readable ID sales can quote;
- Notification: sent asynchronously, after commit.
The order is itself the design: cheap checks before expensive ones (rate limit before verification), refuse before writing (no failed validation ever creates data), and notify only after success (no "notification arrived, record missing" class of bug).
Field allow-list: one extra field is a rejection
| Field | Constraint | Notes |
|---|---|---|
| Name (required) | Max 80 | |
| Phone (required) | Max 80 | |
| Company | Max 120 | |
| Regex-validated | Rejected if invalid | |
| Message | Max 3000 | |
| Topic | Fixed set (business/tech/media/careers/other) | Routes inside CRM |
| Submission key | UUID | Idempotency |
| Source page | On-site path pattern | Attribution |
| Consent checkbox | Must be true | Compliance |
The strict mode — any field outside the list is refused outright — is defence in depth for injection surfaces and makes protocol changes explicit: when the frontend adds a field the endpoint does not know, an error surfaces earlier than a silent drop.
Idempotency semantics: three paths, three answers
The client generates a submission key per entry; the endpoint answers each case deterministically:
- Same key, same content, retried → the same reference number comes back — idempotent, retries always safe;
- Same key, different content → an explicit 409 conflict, never a silent overwrite;
- Concurrent duplicates → the database's unique constraint decides — "who arrived first" stops mattering, both get the same outcome.
One engineering detail: the content hash is computed over normalised text (consistent whitespace and case rules) — otherwise two submissions that "look identical" get branded a conflict.
Quotas and verification: different jobs
Rate limiting runs two levels for different purposes: per-IP stops a single source flooding (20/hour is our deliberately generous read of normal business behaviour); global absorbs distributed spikes. Human verification adds a third layer — none of the three assumes malice; together they make automation cost more than it earns.
One rule we learned the hard way: counter commits are independent of the business transaction — otherwise a business failure "refunds" consumed quota and the limiter becomes decoration. The counter's own concurrency correctness (upsert conflicts and retries) has its own article.
Filing and privacy: promises land in behaviour
Leads reach CRM tagged with source page, language and topic for channel reviews. As important as what the endpoint does are two things it must not do: no secondary marketing use, and no third-party data enrichment — suppressed at the model layer with a flag and guarded by a test assertion. A privacy promise on a policy page is text; in code behaviour it is a promise.
Notifications: failures must not bite back
New enquiries notify the team under two constraints: only after the record commits, and failures are logged into a bounded retry queue (cleared on success) — never affecting the success the user sees. From the user's side, success depends on exactly one thing: the data really landed in CRM.
Error-code semantics
Error codes stay enumerable and explainable: 400 (field problem, naming the field), 403 (human verification failed), 409 (same key, different content), 429 (rate limited, with retry timing), 5xx (server-side, retryable). The frontend maps codes to copy; it never guesses.
Takeaways
A small endpoint is complete when every failure path has been considered: extra fields, quota, replays, conflicts, notification outage, backend unreachable. The list is short — and it covers exactly what really happens in production. Design the failures as part of the interface, and only then does "small" deserve the word "reliable".
