Skip to content

Race conditions & idempotency

The Connect API isn’t transactional from the consumer’s perspective — calls happen sequentially over HTTP, and state can change between them. A few specific races and how to handle them.

Between fetching availability and creating a booking, the slot can fill (someone else booked it via the form widget, the console, or another Connect call).

[Connect] GET /availability/ → slot S available
[Form widget] someone books slot S
[Connect] POST /calendar_bookings/ → 400 The selected time slot is no longer available

The booking endpoint does a transactional capacity recheck inside the DB transaction — if the slot filled in between, you get a clean 400 instead of a corrupted booking.

Recovery: refetch availability, pick a fresh slot, retry. Don’t assume the original start/end is still good.

async function bookWithRetry(input, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const response = await fetch('/connect/calendar_bookings/', { method: 'POST', headers, body: JSON.stringify(input) });
if (response.ok) return response.json();
const error = await response.json();
if (!/no longer available/.test(error.message)) throw error;
// Slot filled — refetch availability and retry
const fresh = await refetchAvailability();
input.startDateTime = fresh.startDateTime;
input.endDateTime = fresh.endDateTime;
input.resourceSlotId = fresh.resourceSlotId;
}
throw new Error('Could not book after ' + maxAttempts + ' attempts');
}

POST /connect/table_entries/ is not idempotent — every POST creates a new lead entry. There’s no built-in dedup key.

If your sender retries on transient failure (network blip, 5xx), you’ll get duplicate leads. Two ways to defend:

  1. Send-side idempotency — generate a stable key on your side (e.g., hash of email + timestamp bucket) and skip the POST if you’ve already submitted it.
  2. Receive-side dedup — automate a “find duplicate by email/phone” step in your workflow that merges or skips duplicates after the fact.

There’s no single “reschedule” endpoint. The pattern is delete + recreate:

await fetch(`/connect/calendar_bookings/${oldId}`, { method: 'DELETE', headers });
await fetch(`/connect/calendar_bookings/`, { method: 'POST', headers, body: JSON.stringify(newBooking) });

Between the two calls:

  • The old booking’s calendar.appointment.deleted event fires.
  • A flow listening to deletes might cancel the lead’s “appointment confirmed” SMS.
  • Then calendar.appointment.booked fires for the new booking and the SMS gets sent again.

If your flows aren’t idempotent on the receiving end, this can cause duplicate notifications. Either:

  • Make your flows idempotent (e.g., dedup SMS by recipient + day).
  • Order the calls carefully (recreate first if the new slot is guaranteed available, then delete).
  • Use a “rescheduling” lead state to suppress notifications during the transition.

The Connect API isn’t currently rate-limited, but you should still handle 429 and 5xx defensively:

StatusStrategy
429 Too Many RequestsExponential backoff (base 1s, cap at 60s). Honor Retry-After if present.
500 Internal Server ErrorRetry up to 3× with backoff. If it persists, log and surface to ops.
502/503/504Same as 500.
4xx (other)Don’t retry — fix the request or surface to ops.