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. Let the studio resolve it — a retried create is a second lead, but not a second person: when the name matches and every channel you send is already on file, the same contact is attached to both leads. Studios see the requests grouped under that person in the lead pool, and likely duplicate people are offered for review and merge in the console. See Contacts → Matching.

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.