← back to work.mountaintopvirtual.com
Case study · concurrency-safe round-robin assignment, n8n + Postgres

A 50/50 split that stays 50/50 when two people register at the same second.

An event-registration funnel needed to alternate leads evenly between two reps, per pricing tier, without ever double-booking a contact or re-shuffling someone who'd already been assigned. The obvious way to count "whose turn is it" breaks the moment two webhooks land at once — so the count doesn't live in the workflow at all.

Client — AKVS Stack — n8n, PostgreSQL, GoHighLevel/LeadConnector Role — architecture, backend logic, GHL integration

The problem

Why a workflow variable can't be the one keeping score

Two reps split event registrants 50/50, separately for the general-admission tier and the VIP tier. That's a round-robin counter — trivial to write badly. A number sitting in n8n memory, incremented each time a registration comes in, works fine until two people submit the registration form within the same second: both executions read the counter before either writes it back, both land on the same slot, and the split silently breaks. Nobody notices until someone audits the rep assignments weeks later and the numbers don't add up.

On top of the split, the funnel also had to not double-book a contact who resubmits the form, and handle a specific business wrinkle: a general-admission registrant who later buys the VIP upgrade should move pipeline stages without getting reassigned to a different rep — they already have a relationship with whoever first reached out.

What I built

Three separate problems, three separate mechanisms

The counter lives in Postgres, not in the workflow

Slot assignment runs through a dedicated database function, rr_assign(regKey, counterKey, tier), called once per registration with its own counter key per tier — so general and VIP alternate independently. Because the increment-and-return happens as a single database call rather than a read-then-write in n8n, two simultaneous registrations can't both land on the same slot the way they could with an in-memory counter. Odd slot numbers go to rep A, even to rep B.

Idempotent by construction, not by a duplicate check bolted on after

Every registration carries a key built from contactId:eventKey. Before anything is created, a search against GoHighLevel's opportunities API checks whether this contact already has one in this pipeline; a dedicated node computes whether the result is a brand-new registration, an upgrade, or a resubmission of something that already exists — and resubmitting the same form twice is a no-op, not a second slot burned.

Upgrading tiers moves the stage, not the rep

When an existing general registrant buys VIP, the workflow doesn't run them back through slot assignment. It only updates the opportunity's pipeline stage; the originally assigned rep stays assigned. That rule lives in one line of the decision logic, but it's the kind of detail that's invisible until a rep asks "wait, why did I lose my lead?"

Reserve, then confirm — not just assign and hope

A second database call finalizes what the first one reserved

Reserving a slot and creating the GoHighLevel opportunity are two separate steps, on purpose. rr_assign reserves the slot; a second function, rr_mark_done(regKey, oppId), only runs after GoHighLevel has confirmed the opportunity was actually created — and a dedicated validation step rejects GoHighLevel's response outright if it comes back without a usable opportunity ID, rather than trusting it blindly. That split matters because a registration key that's already been assigned a slot stays assigned to that same slot on any retry, instead of pulling a fresh number from the counter, so a failed GoHighLevel call doesn't quietly throw off the 50/50 balance the next time it runs.

What I verified, and how: the reserve/confirm split, the tier-scoped counters, and the stage-only VIP upgrade path are all directly visible in the node configuration — the parameterized queries, the branch conditions, and the validation code that throws on a bad response. I didn't inspect the SQL inside rr_assign itself, so I'm not claiming to have confirmed its internal locking behavior — only that the design calls a database function to do the counting instead of trusting workflow state, which is the part that actually matters for correctness under concurrent requests.

The result

The full flow, GHL webhook to GHL response

n8n canvas showing the GHL Event Registration workflow: webhook, config validation, existing-opportunity search, a branch for new registrants (slot assignment, user pick, opportunity creation) and one for VIP upgrades (stage move only), merging into owner assignment, tagging, and a JSON response.

Config & validation on the left, the new-registrant and VIP-upgrade branches in the middle, owner assignment and tagging on the right.

2
independent 50/50 counters, one per pricing tier
0
duplicate opportunities on a resubmitted form
15
nodes, from webhook to response, every API call behind defensive validation
Postgres function-based assignment reserve/confirm write pattern idempotent registration key GoHighLevel Opportunities API

Where it stands

Published and running in production for AKVS's event registrations, splitting leads evenly across two reps per tier with no manual reconciliation needed afterward.