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.
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.
Three separate problems, three separate mechanisms
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.
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.
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?"
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 full flow, GHL webhook to GHL response
Config & validation on the left, the new-registrant and VIP-upgrade branches in the middle, owner assignment and tagging on the right.
Published and running in production for AKVS's event registrations, splitting leads evenly across two reps per tier with no manual reconciliation needed afterward.