Launch EOI
An off-plan launch needed every buyer's interest captured, documented, and pushed into a CRM whose API wasn't finished yet. We shipped against the API that existed and let the rest switch on as it arrived.
Design + Build - strategy, full-stack engineering, integration architecture · Next.js 16 (App Router) · Postgres · private object storage · pg_cron · zod · libphonenumber
Commercial project - delivered under NDA, client name withheld.
The launch couldn't wait for the integration
A UAE developer was opening a new phase of an off-plan residential project. Before any unit is sold, interest is collected as an Expression of Interest: a non-binding application with the buyer's identity, the unit type they want, a budget, and their documents. Half of the applications arrive through brokers, who submit on the buyer's behalf and need to be credited for it.
Every one of those applications had to end up in the developer's CRM - buyer, agency, broker, enquiry, documents - because that is where sales works. The problem: the CRM's API was still being built. Fields were missing, lookups were limited, some endpoints returned errors in production. The launch date did not move.
The usual answers are both bad. Wait for the API, and the launch slips. Collect into a spreadsheet and re-key it later, and the sales team spends the first week of a launch typing.
One line carries the whole design:
The database owns the truth. The CRM gets a copy. Every application is saved first and synced second - so nothing the CRM can't accept yet is ever lost, only parked.
A form that does the checking, not the sales team
Six sections, two of which appear only when they apply: broker details for broker submissions, Emirates ID for residents. Around forty fields, every one validated the same way in the browser and on the server, so a buyer sees every problem at once instead of one per submit.
- Phone numbers are parsed per country and stored in one international format.
- Passports and Emirates IDs are checked for shape and expiry before anything is uploaded.
- Broker submissions carry the agency's licence numbers and an authority declaration.
- Every declaration is stored with the version of the wording the buyer actually saw, and when they accepted it.
- A soft duplicate flag catches the same passport applying for the same unit type twice - flagged for a human, not rejected by a rule.
Reference numbers are random, not sequential: a buyer's confirmation page shouldn't tell a competitor how many applications came in.
Identity documents are the dangerous part
Passports and IDs are the most sensitive thing the system touches, so they never pass through a public route. The browser uploads straight into a private bucket through a one-off signed URL. On submit, the uploads are claimed atomically - a double click can't attach the same file to two applications - and any failure rolls the whole thing back. Staff open a document through a link that lives sixty seconds, and every view is written to an audit log.
Three staff roles, one password each, a password change ends that role's sessions.
Sales: the list, the detail card, documents, payment status, notes, manual CRM retry, exports.
Marketing: aggregates only - no documents, no per-agent breakdown.
Admin: everything above, plus switching the CRM environment between test and production
with a live connection check. Anonymous database access is revoked outright; the public
form reaches the database only through the server.A sync that can be retried forever and never duplicates
The CRM chain is five steps: find or create the buyer, attach their documents, find or create the agency by licence, find or create the broker inside it, create the enquiry. Any of them can fail, and a retry that creates a second buyer is worse than no sync at all.
So every CRM id is stored the moment it comes back, and a step that already has one is skipped. Documents go right after the buyer, so they arrive even if a later step fails. Before creating an enquiry the system searches the buyer's existing ones for the reference - in case an earlier attempt succeeded and its answer got lost. Only one worker can hold an application at a time; a run stuck for ten minutes is taken over.
Backoff: 1m · 5m · 15m · 1h · 3h · 6h · 12h · 24h - eight attempts, then a human
retries from the admin card. Outcomes: synced, partial, pending, failed; every CRM call
logged. A failed lookup is never read as "no such customer" - that is how duplicates are
born - and phone numbers are also matched on their last nine digits, because the CRM
stored them with trunk prefixes the form never sees.Shipping against an API that didn't exist yet
This is the part we'd build the same way again. Every field the CRM couldn't accept became a capability flag - nineteen of them, off by default, set separately for test and production. While a flag is off, its value is written into a structured block in the enquiry's description, readable by a person, and the application records which native fields are still pending. Alongside, we wrote the CRM team a change request: ten items, five needed before launch, five after, each mapped to its flag.
When the CRM ships a field, the flag goes on and a backfill pushes just that field into every application already synced - a dry run first, then for real. No re-keying, no "old" and "new" records.
The real API added its own surprises once we hit it: access only from an allow-listed IP, one document slot per group (so both sides of an ID are merged into one PDF), a field mapped to a column that didn't exist, a phone search that answered with server errors. Each one became a flag, a fallback, or a proxy - not a delay to the launch.
Results
- Core form, document handling, CRM sync and admin built in one evening; about eleven days from first commit to hardened against the live CRM
- Database-first sync with stored ids, takeover of stuck runs, and an eight-step backoff - retries can't create duplicates
- 19 capability flags and a backfill: the integration grows as the CRM ships, with nothing re-entered by hand
- Private document storage with atomic claiming, sixty-second links and an audited view log
- Three role-scoped staff views, CSV/XLSX exports, and enquiry status polled back from the CRM
- 83 automated test cases, 26 of them on the sync alone
Next.js 16 (App Router) + TypeScript · Postgres with a private storage bucket · pg_cron
every five minutes with a daily platform cron as a safety net · zod schemas shared by
client and server · libphonenumber · optional bot challenge plus honeypot and per-IP limits.