New to APIs? Start with "Start here: what the API is". This page lists everything the API can do, with the fields each address takes, so a developer can build an integration from it.
Want us to tell your tool when something happens, instead of asking over and over? See the Webhooks article. A machine-readable copy of this reference (OpenAPI) is at https://shoalevents.com/api/v1/openapi.json.
How requests work
- The base URL is https://shoalevents.com/api/v1. Send your key in a header: Authorization: Bearer <your key>. Requests with a body also send Content-Type: application/json. Answers are JSON.
- Each key can make 300 requests a minute. After that, requests get a 429 until the minute is up.
- Every answer carries X-RateLimit-Limit (300), X-RateLimit-Remaining (requests left this minute) and X-RateLimit-Reset (seconds until the count starts over). A 429 also carries Retry-After, the seconds to wait.
- Errors come back as {"error": "..."}. 400 means something in the request needs fixing (in a batch the message names the row, like "Speaker 3: ..."). 401 means the key is missing, wrong or deleted. 404 means there is no such event in your organization. 429 means too many requests. 500 means something went wrong on our side.
- Batches take up to 500 items. Adding and updating are all or nothing: if any item is wrong, nothing is saved.
- Fields we don't know are refused, so typos are caught. Fields that only appear in answers (such as counts, logo_url, photo_url, taken, or status on notifications) can't be sent back.
- Dates and times look like 2026-11-20T09:00 and mean the event's local time. Add an offset like -06:00, or Z, for an exact time. Answers always come back in UTC (ISO 8601).
- Things are matched by id when you send "id", otherwise by name (capital letters don't matter). That makes it safe to send the same list twice, except for sessions and notifications: without an id they're always added as new. Ids come from the GET lists.
- To delete, send DELETE with a body like {"<list name>": [...]}. Each item can be an id or a name as a string, or an object like {"id": ...} or {"name": ...}. The answer is {"deleted": 2, "not_found": [...]}, where not_found lists the items you sent that weren't there. A delete never fails just because something was already gone.
- You can also delete one thing with DELETE /events/{id}/<things>/{item id}. It answers 404 if there is no such item.
Endpoints
- GET /events
- POST /events
- GET /events/{id}
- PATCH /events/{id}
- GET, POST, DELETE /events/{id}/attendees (GET takes ?status=&limit=&offset=)
- DELETE /events/{id}/attendees/{attendee id}
- GET, POST, DELETE /events/{id}/tracks
- GET, POST, DELETE /events/{id}/sponsor-tiers
- GET, POST, DELETE /events/{id}/sponsors
- GET, POST, DELETE /events/{id}/notifications
- DELETE /events/{id}/tracks/{item id}, /sponsor-tiers/{item id}, /sponsors/{item id} and /notifications/{item id}
- GET, POST /events/{id}/speakers
- GET, POST /events/{id}/sessions
- GET, POST /events/{id}/audiences
- GET, POST, DELETE /events/{id}/audience-members (GET takes ?audience=)
- GET, POST /events/{id}/pages
Speakers, sessions, audiences and pages can't be deleted through the API yet.
Events
GET /events lists your events: id, name, slug, status, visibility, starts_at, ends_at, timezone, registration_type, capacity and registered. GET /events/{id} returns one event with its questions, tracks and ticket types. POST /events creates an event (as a draft unless you send status). PATCH /events/{id} changes only the fields you send. New events start private, in the America/New_York time zone, with the waitlist and reminders on, unless you send otherwise. Starting and ending times (and the time zone) can't be changed from 5 days before the event starts, or after it; that answers 400. A new event gets its plan the same way as one made in the dashboard: the launch offer's free plan if you still have one, otherwise nobody can register until you choose a plan on the event's Plan screen.
Event fields: name (required to create), slug (the web address, made from the name if you leave it out), starts_at, ends_at, timezone (like America/Chicago), venue_name, venue_address, visibility (private, unlisted or public), registration_type (free or paid), session_mode (pick or assigned), capacity, waitlist_enabled, reminders_enabled (true or false) and status (draft, published or archived).
curl -X PATCH https://shoalevents.com/api/v1/events/EVENT_ID \
-H "Authorization: Bearer YOUR_KEY" -H "Content-Type: application/json" \
-d '{"capacity": 200, "starts_at": "2026-11-20T09:00"}'Attendees
POST /events/{id}/attendees adds or updates people, up to 500 at a time. People are matched by email: new people are added, existing people are updated, and only the fields you send change. The fields are: email (required), first_name, last_name, job_title, company, status (invited, registered or cancelled; new people are invited unless you say otherwise), table, seat, track (the track's name) and answers. The answer includes created, updated and attendees.
answers is an object keyed by the question's label, such as {"Dietary Needs": "Vegetarian"}. Tick boxes take Yes or No. An empty value clears an answer.
GET /events/{id}/attendees?status=registered&limit=100&offset=0 returns people page by page, up to 500 per page. status can be invited, pending (waiting for approval), registered, waitlisted or cancelled, or left out for everyone. The answer includes total, limit, offset and data. Each attendee has id, email, first_name, last_name, job_title, company, status, registered_at, checked_in_at, table, seat, track, ticket_type, ticket_url, answers (keyed by question label) and created_at.
DELETE /events/{id}/attendees with a body like {"attendees": [...]} deletes people. Each item is an id, an email (a string with @ in it is an email), or an object like {"id": ...} or {"email": ...}. Deleting removes the person like the trash icon on the Attendees tab: their photo, picked sessions, game points and survey answers go too. A paid order is kept but not refunded, so refund from the person's Edit pop-up first.
curl -X POST https://shoalevents.com/api/v1/events/EVENT_ID/attendees \
-H "Authorization: Bearer YOUR_KEY" -H "Content-Type: application/json" \
-d '{"attendees": [{"email": "pat@example.com", "first_name": "Pat", "track": "Engineering", "answers": {"Dietary Needs": "Vegetarian"}}]}'Tracks
GET /events/{id}/tracks returns [{id, name, position, sessions, attendees}]. sessions is a list of session titles, and attendees is the number of people in the track who aren't cancelled.
POST with {"tracks": [...]} adds or updates tracks. Fields: id, name (up to 80 characters, required for new tracks, unique in the event) and position (a whole number; new tracks go to the end). Tracks are matched by id, else by name. The answer is {created, updated, tracks}.
Put sessions in tracks with the "tracks" field on the sessions endpoint, and people in a track with the "track" field on the attendees endpoint. DELETE by id or name: the sessions stay (they just leave that track) and the people in it end up with no track.
{"tracks": [{"name": "Engineering"}, {"name": "Design", "position": 2}]}Sponsor Tiers
GET /events/{id}/sponsor-tiers returns [{id, name, position, logo_size, placements, sponsors}], where sponsors is how many sponsors are in the tier.
POST with {"tiers": [...]} adds or updates tiers. Fields: id, name (up to 40 characters, unique), position, logo_size (large, medium or small; medium if you leave it out) and placements. placements is a list of where the tier shows: event_page (the event page), app_home ("Thanks to Our Sponsors" on the app's Ticket tab), app_sponsors (the app's Sponsors tab) and app_schedule ("Presented By" above the Schedule). It defaults to ["event_page", "app_sponsors"], and an empty list hides the tier. Tiers are matched by id, else name. The answer is {created, updated, tiers}.
DELETE by id or name: its sponsors stay, without a tier. Sponsors without a tier show as medium logos on the event page and the Sponsors tab.
{"tiers": [{"name": "Gold", "logo_size": "large", "placements": ["event_page", "app_home", "app_sponsors"]}]}Sponsors
GET /events/{id}/sponsors returns [{id, name, tier, description, website, position, logo_url}], ordered by tier then position. tier is the tier's name or null, and logo_url is null when there is no logo.
POST with {"sponsors": [...]} adds or updates sponsors. Fields: id, name (up to 100 characters, required for new sponsors), tier (a tier's name; the tier must already exist, so add tiers first; null removes the tier), description (up to 500), website (https only; "example.com" becomes https://example.com; up to 500) and position. Sponsors are matched by id, else name. The answer is {created, updated, sponsors}.
Logos can't be uploaded through the API. Upload them on the event's Sponsors tab. DELETE by id or name: the sponsor also leaves any sessions it presented and the splash screen, and its logo file is deleted.
{"sponsors": [{"name": "Northwind", "tier": "Gold", "website": "northwind.example", "description": "Our venue partner."}]}Notifications
These are the notifications attendees see in the app (the Notifications screen under Messages). GET /events/{id}/notifications returns the newest first: [{id, name, message, status, send_at, audience, link_tab, link_page, created_at}]. status is sent or scheduled. audience is an audience's name, or null for everyone registered. link_page is a page's title.
POST with {"notifications": [...]} adds or updates them. Fields: id, name (up to 80 characters, required for new), message (up to 500 characters, required for new), send_at, audience, link_tab and link_page. A row with id updates that notification; a row without id is always a new one.
- send_at: left out, null or "now" sends right away. A date and time schedules it, and must not be in the past. Once a notification has been sent, its send_at can't change.
- audience: an audience's name, or null for everyone registered.
- link_tab: one of ticket, schedule, discussions, game, info, venue, seating, sponsors or profile. It is the tab the Open button goes to.
- link_page: a custom page's title or id. Send link_tab or link_page, not both.
The answer is {created, updated, notifications}. DELETE works by id only, because names can repeat. A deleted notification that was already sent disappears from the app.
{"notifications": [{"name": "Doors open", "message": "Doors are open. Come on in!", "send_at": "2026-11-20T08:30", "audience": "VIP", "link_tab": "schedule"}]}Speakers
GET and POST /events/{id}/speakers. Fields: id, name, headline, bio and position (their order). Include id to update a specific speaker (ids come from the GET list). Without an id, a speaker with the same name is updated and anything else is added. Up to 500 at a time.
Sessions
GET and POST /events/{id}/sessions. Fields: id, title, description, room (the location's name; a new name is added to Locations), starts_at, ends_at, capacity (seats), tracks and speakers. tracks and speakers are lists of names, such as ["Engineering"], that replace the session's current ones. Add speakers and tracks before the sessions that use them. Include id to update a specific session; sessions without an id are always added.
{"sessions": [{"title": "Opening Keynote", "starts_at": "2026-11-20T09:00", "ends_at": "2026-11-20T10:00", "room": "Main Hall", "speakers": ["Ana Ruiz"], "tracks": ["Engineering"]}]}Audiences
GET and POST /events/{id}/audiences. Fields: id, name, kind (rules or list), description (up to 300 characters), match (all or any) and rules. Without an id, an audience with the same name is updated and anything else is added. Each rule is {"field", "op", "value"}. Fields are job_title, company, email, table, question:<question label>, track, ticket_type, session, attended_session and checked_in. Ops are is, is_not, contains, is_empty and is_not_empty (track and ticket type take is, is_not, is_empty and is_not_empty; session, which means picked the session, and attended_session, which means checked in to it, take is or is_not; checked_in takes is with yes or no). Name tracks, ticket types and sessions by name or title.
{"audiences": [{"name": "Engineers", "match": "all", "rules": [{"field": "track", "op": "is", "value": "Engineering"}]}]}A list audience (kind list) is a hand-picked group with no rules. Create it, then put people on it with POST /events/{id}/audience-members. Each person must already be on the event's attendee list, and you name them by email (or attendee_id) and the audience by name (or audience_id). Up to 500 at a time, all or nothing. People who are already on it are counted in already. DELETE /events/{id}/audience-members with the same body takes people off (they stay registered). GET /events/{id}/audience-members lists who is on the list audiences, or add ?audience=<name or id> for one. Audience answers also show members (registered people in it) and listed (everyone on a list). The API never emails anyone.
{"audiences": [{"name": "VIP Guests", "kind": "list", "description": "Board and key customers"}]}{"members": [{"audience": "VIP Guests", "email": "pat@example.com"}]}Custom Pages
GET and POST /events/{id}/pages. Fields: id, title, body (the page text), published, position, icon, audience (an audience name, or null for everyone), show_from, show_until, own_tab, pin_home and on_event_page. image_path is read-only. Include id to update a specific page; without an id, a page with the same title is updated and anything else is added.
Emails and phone notifications
The API never sends email. When you're ready, send invitations and tickets from the People tab under Attendees. One exception: if you cancel or delete a registered person on a full event that has a waitlist, the next person moves up and gets their ticket by email, the same as when you do it by hand.
Notifications posted through the API appear in the attendee app and go to phones that turned notifications on, the same as ones posted on the Notifications screen.