Waitlist
Let invitees queue for fully-booked slots and get promoted automatically when someone cancels.
A waitlist holds invitees who want a slot that is already taken. When a booking for that slot is cancelled, Astrocal promotes the first matching entry into a real booking automatically.
Prerequisites
- An event type with
waitlist_enabledset totrue(Event Types) - An API key for reading and listing entries (Authentication)
Joining and cancelling need no API key.
Enabling the waitlist
Waitlists are configured per event type:
| Field | Type | Default | Description |
|---|---|---|---|
waitlist_enabled | boolean | false | Whether invitees may join the waitlist |
waitlist_max | number | 50 | Maximum entries in waiting status, 1-500 |
waitlist_ttl_days | number | 7 | Days before an unpromoted entry expires, 1-30 |
curl -X PATCH https://api.astrocal.dev/v1/event-types/YOUR_EVENT_TYPE_ID \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"waitlist_enabled": true,
"waitlist_max": 25,
"waitlist_ttl_days": 14
}'const response = await fetch(
"https://api.astrocal.dev/v1/event-types/YOUR_EVENT_TYPE_ID",
{
method: "PATCH",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
waitlist_enabled: true,
waitlist_max: 25,
waitlist_ttl_days: 14,
}),
}
);
const data = await response.json();Try it in the API playground →
waitlist_max counts every entry in waiting status across the whole event type, not per slot.
Joining a waitlist
Send a POST to /v1/waitlist. This is a public endpoint with no authentication, so your end users can join directly.
curl -X POST https://api.astrocal.dev/v1/waitlist \
-H "Content-Type: application/json" \
-d '{
"event_type_id": "660e8400-e29b-41d4-a716-446655440000",
"start_time": "2026-03-15T14:00:00Z",
"invitee_name": "Jane Doe",
"invitee_email": "jane@example.com",
"invitee_timezone": "America/Los_Angeles",
"notes": "Happy to take a short-notice slot"
}'const response = await fetch("https://api.astrocal.dev/v1/waitlist", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
event_type_id: "660e8400-e29b-41d4-a716-446655440000",
start_time: "2026-03-15T14:00:00Z",
invitee_name: "Jane Doe",
invitee_email: "jane@example.com",
invitee_timezone: "America/Los_Angeles",
notes: "Happy to take a short-notice slot",
}),
});
const data = await response.json();Try it in the API playground →
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
event_type_id | string | Yes | The event type to queue for |
invitee_name | string | Yes | Invitee name, 1-200 characters |
invitee_email | string | Yes | Invitee email. Stored lowercased. |
start_time | string | No | ISO 8601 slot to wait for. Omit to wait for any slot. |
duration | number | No | Meeting length in minutes, 5-480. Use with duration_options. |
invitee_timezone | string | No | IANA timezone. Defaults to UTC. |
notes | string | No | Free text, max 1000 characters |
metadata | object | No | Your own data. Same limits as booking metadata. |
Response
{
"id": "wl_abc123",
"event_type_id": "660e8400-e29b-41d4-a716-446655440000",
"start_time": "2026-03-15T14:00:00.000Z",
"duration_minutes": null,
"status": "waiting",
"position": 3,
"invitee_name": "Jane Doe",
"invitee_email": "jane@example.com",
"invitee_timezone": "America/Los_Angeles",
"notes": "Happy to take a short-notice slot",
"metadata": {},
"cancel_token": "0Vv7yQ...",
"booking_id": null,
"promoted_at": null,
"expires_at": "2026-03-22T09:12:00.000Z",
"is_test": false,
"created_at": "2026-03-08T09:12:00.000Z"
}Store the cancel_token. It lets the invitee cancel without an API key.
expires_at is the creation time plus the event type's waitlist_ttl_days.
Specific slot or any slot
- Specific slot: pass
start_time. The entry is only considered when that exact slot frees up. - Any slot: omit
start_time. The entry is considered whenever any slot on that event type frees up, but always after the specific-slot entries for it.
Position
position is 1-based and computed on every read:
- It counts the
waitingentries created before this one. - For a specific-slot entry, only entries for that same slot and any-slot entries are counted.
- Entries that are not
waitingalways report position0.
Positions shift as entries ahead are promoted, cancelled, or expired. Read the entry again rather than caching the number.
To show a position before anyone joins, call availability. When an event type is capped and has waitlists enabled, the response returns waitlist_available: true and waitlist_position — the position the next entry would take.
Checking an entry
Fetch a single entry by ID with an API key:
curl https://api.astrocal.dev/v1/waitlist/wl_abc123 \
-H "Authorization: Bearer ac_live_..."const response = await fetch(
"https://api.astrocal.dev/v1/waitlist/wl_abc123",
{
headers: {
Authorization: "Bearer ac_live_...",
},
}
);
const data = await response.json();Try it in the API playground →
Listing entries
GET /v1/waitlist lists entries for one event type. event_type_id is required:
curl "https://api.astrocal.dev/v1/waitlist?event_type_id=660e8400-e29b-41d4-a716-446655440000&status=waiting&limit=20" \
-H "Authorization: Bearer ac_live_..."const response = await fetch(
"https://api.astrocal.dev/v1/waitlist?event_type_id=660e8400-e29b-41d4-a716-446655440000&status=waiting&limit=20",
{
headers: {
Authorization: "Bearer ac_live_...",
},
}
);
const data = await response.json();Try it in the API playground →
| Parameter | Type | Description |
|---|---|---|
event_type_id | string | Required. The event type to list entries for. |
status | string | waiting, promoted, expired, or cancelled |
start_time | string | Only entries queued for this exact ISO 8601 slot |
limit | number | Page size, 1-100. Defaults to 20. |
starting_after | string | Cursor for the next page |
Entries come back oldest first, which is the order they are considered for promotion.
Cancelling an entry
DELETE /v1/waitlist/{id} accepts either a cancel token or an API key, and returns 204 No Content.
With a cancel token (invitee self-service)
curl -X DELETE "https://api.astrocal.dev/v1/waitlist/wl_abc123?token=0Vv7yQ..."await fetch("https://api.astrocal.dev/v1/waitlist/wl_abc123?token=0Vv7yQ...", {
method: "DELETE",
});With an API key (developer)
curl -X DELETE https://api.astrocal.dev/v1/waitlist/wl_abc123 \
-H "Authorization: Bearer ac_live_..."await fetch("https://api.astrocal.dev/v1/waitlist/wl_abc123", {
method: "DELETE",
headers: {
Authorization: "Bearer ac_live_...",
},
});Try it in the API playground →
Cancellation behaviour:
- The entry's status becomes
cancelledand it stops being considered for promotion. - Cancellation is idempotent. Cancelling an already-cancelled entry returns
204. - An entry that belongs to another organization returns
404, not403. - An invalid or missing token returns
401.
When a slot frees up
Promotion runs when a booking is cancelled, and only when the event type has waitlist_enabled. Astrocal:
- Collects
waitingentries for that event type whosestart_timematches the cancelled slot, plus any-slot entries. - Orders them specific-slot first, then any-slot, oldest first.
- Creates a booking for the first candidate through the normal booking flow — availability checks, calendar sync, confirmation emails, and a
booking.createdwebhook all apply. - Sets the entry's status to
promoted, and fills inbooking_idandpromoted_at.
The entry's duration_minutes, notes, metadata, and invitee_timezone carry over to the new booking. The invitee receives the standard booking confirmation email.
One entry is promoted per cancellation. If the booking fails — for example the slot was taken again in the meantime — Astrocal tries the next candidate instead.
Poll the entry, or watch for the booking.created webhook, to find out that a promotion happened.
Entry statuses
| Status | Meaning |
|---|---|
waiting | In the queue and eligible for promotion |
promoted | Converted into a booking. booking_id and promoted_at are set. |
expired | Passed expires_at without being promoted |
cancelled | Cancelled by the invitee or by an API key |
Expiry is swept by a background worker every five minutes, so an entry can stay waiting for a few minutes past its expires_at.
Authentication summary
| Endpoint | Auth required | Methods |
|---|---|---|
POST /v1/waitlist | None (public) | - |
DELETE /v1/waitlist/:id | Cancel token OR API key | ?token= or Bearer |
GET /v1/waitlist | API key | Bearer |
GET /v1/waitlist/:id | API key | Bearer |
Error handling
| Status | Error code | details.code | Description |
|---|---|---|---|
| 400 | validation_error | - | Invalid input, for example a non-UUID event_type_id or a malformed start_time |
| 400 | bad_request | - | Waitlist is not enabled for this event type |
| 401 | unauthorized | - | Invalid or missing cancel token / API key |
| 404 | not_found | - | Event type or waitlist entry does not exist |
| 409 | conflict | already_on_waitlist | This email is already waiting for the same slot |
| 409 | conflict | waitlist_full | The event type already has waitlist_max entries |
Both 409s return the outer code conflict, so branch on details.code:
{
"error": {
"code": "conflict",
"message": "Already on waitlist for this slot",
"details": {
"code": "already_on_waitlist"
}
}
}A duplicate is the same email, event type, and start_time in waiting status. Cancelled, expired, and promoted entries do not block a rejoin.
Next steps
- Bookings -- The booking a promotion creates
- Availability -- Check
waitlist_availablebefore offering the queue - Event Types -- Booking caps, the usual reason a waitlist opens
- Errors -- Full error code reference
- API Reference -- Full endpoint documentation