Facebook Marketplace is where used cars actually move in this market, and it has no vehicle-listing API. There is no endpoint a dealership can post a car to, so the honest shape of this product is not an integration. It is a dashboard that owns the inventory and the posting record, a Chrome extension that fills Marketplace's own create-vehicle form from the salesperson's own Facebook account, and a person who always clicks Publish. Relist never publishes anything by itself.
That one constraint decides everything downstream. Because a human publishes from a personal account, the job is not throughput, it is not looking like a dealer bot. And because there is no API to break, the thing that breaks is Facebook's form, so selling this means owning that maintenance for every customer.
I built it first for 604 Motors, a dealership whose site I also built.
Most dealers cannot hand you a feed. They can hand you a URL. So the connector takes the page that lists the cars and works backwards.
It looks for pages the polite way first: robots.txt for a Sitemap: line, then the sitemap index, up to 5 sitemap documents and 200 same-host URLs. Only when that finds nothing does it crawl links from the page it was given, 2 requests in flight, 800 ms apart. Every fetch is https only, capped at 1.5 MB, and re-checked against private address ranges on each redirect hop, so a pasted URL cannot be aimed back at our own network. Forty detail pages is the ceiling, and a sync runs as 60-second steps the client re-posts until the run reports done.
Per page, schema.org JSON-LD comes first: exact, free, and already published by most dealer platforms. Claude Opus 5 at low effort is the floor under that, not the plan. At most 6 pages per sync, 60,000 characters each, a 30-second timeout, never on the cron, roughly 15 to 20 cents on a sync that needs it. Running it on every page of every crawl was priced and rejected: thousands a month, and nothing gained on a site that already emits JSON-LD.
What comes back is filtered, not trusted. A row missing year, make, model, price or mileage is dropped and counted back to the owner, because a half-filled car cannot be published anyway. Zero kilometres with no VIN reads as a brochure, not a car. And the sync will not sell the lot on a bad read: an empty source skips the sold-diff entirely, and a run that would mark more than half the available cars sold is refused and logged.
When a site cannot be read at all, nobody is stranded: the cars step offers "Have Relist connect it", which files a connect request I pick up by hand and unlocks Continue.
A dealer bot is not caught by what it posts. It is caught by rhythm. So pacing is the thing protecting the account, and it runs on the server where every device gets the same answer.
Two numbers do the work: posts per hour, between 5 and 10, and a daily cap between 3 and 7. The cooldown is derived from the hourly rate, then jittered plus or minus 20 percent so posts never land on a metronome. It is drawn once when a posting is recorded and stored on that posting as nextAllowedAt, so the gate is deterministic afterwards. Redrawing it on every check was the rejected alternative: one person, two machines, two different answers.
The daily cap is the part that actually matters. An hourly rate on its own still lets somebody put forty cars up in a day.
The gate keys on the pair of member and Facebook account, not the member alone. A salesperson may post from more than one account, the content script reads which account is signed in at fill time rather than scraping a profile, and each account gets its own hour and its own day.
The dashboard hands the extension its token directly through externally_connectable. No URL, no copy and paste, no options page. The page checks the extension is reachable before minting, because minting revokes the token already out there, and races the handshake against three seconds; if it loses, it says the token was issued but not delivered instead of showing a green light.
"Connected" means the extension called home. The badge is stamped by that member's first authenticated GET /api/ext/next, never by the page that minted the token.
externally_connectable restricts who may message the extension, not what they send, so the service worker also requires sender.origin to sit in an allow-list and the apiBase it is handed to equal it. Pending posts are tab-bound and expire, so browsing a stranger's listing can never be attributed to an abandoned fill.
Packaging for the Chrome Web Store adds two facts. The store rejects any manifest carrying a key, so the pack script strips it and the store assigns its own ID, different from the pinned unpacked one. And at cutover both the manifest's externally_connectable.matches and the background script's origin allow-list need the production origin. Update only one and you ship a connect button that silently does nothing.
Next.js App Router, TypeScript and Tailwind, with Firebase Auth and Firestore behind the Admin SDK on the server, on Vercel. Lists keep their server render and hand it to a client island that subscribes with onSnapshot under the rules that already existed, so live updates opened nothing new. The extension is plain-JavaScript Manifest V3 with no build step, and it opens as a Chrome side panel, with a floating-window fallback for browsers that expose chrome.sidePanel and never render it.