oToK Developer Docs

Events

Events and their registrations. Create the event your form or system knows about, register people into it, and move them through the roster — every write here fires exactly what the same action fires inside oToK: the reminders and automations, the event.attendance.changed webhook, lead scoring, and the Zoom registrant push that produces an attendee's personal join link.

Plan feature required: these endpoints require the Events feature on the workspace's plan. Without it, calls return 403 with error_code: "FEATURE_NOT_INCLUDED_IN_PLAN" — see feature-gated resource groups.

The event object

Field Type Notes
id UUID
name string
external_id string or null Your own id for the event. Per-workspace unique, matched case-insensitively
external_provider string or null Read-only. Set by an integration ("zoom") when the event is linked to a provider meeting — never writable, because it is the key oToK pushes registrants against
start_at / end_at ISO 8601 or null
ended_at ISO 8601 or null Stamped when the event actually ended
status string draft, scheduled, canceled or completed
timezone / language string or null
category string or null Free-text label. A label used once is offered on every event after it
presenter string or null
link string or null Join link shared by every attendee
use_personal_links boolean Each attendee gets their own join link instead of the shared one
product_id / cycle_id UUID or null
event_type_id UUID or null The saved event this event was created from
event_type object or null { "id", "name" } of that saved event — null when the event was not created from one
suppress_event_automations boolean Stops every automation for this event, reminders included
collect_payment_on_registration boolean Priced-event opt-in — see Priced events. Default false
archived_at ISO 8601 or null Archived events are never written to
created_at ISO 8601

The registration object

Field Type Notes
id UUID The registration ("attendance") id
event_id / contact_id UUID
status string registered, attended, no_show, waitlist or unregistered
registered_at / attended_at / unregistered_at ISO 8601 or null Stamped as the row passes through each status
join_url string or null The attendee's personal join link, from Zoom or supplied by you
payment_sale_id UUID or null The sale this registration was charged through on a priced event, or null
payment object or null The registration's payment on a priced event, or null when it carries no sale — see below
created_at / updated_at ISO 8601

The payment block. Present on every registration read (the list, the POST and the PATCH answers):

Field Type Notes
sale_id UUID The sale recorded for this registration
sale_status string or null active, partially_cancelled or cancelled
settlement_status string or null untracked, unpaid, partially_paid, paid, partially_refunded or refunded
pay_url string or null The newest payment link that can still be paid, or null when none is live (nothing left to collect, or the link expired)

Status vocabulary. These five values are what oToK stores and what every surface returns — this API, the contact object's event_attendances[], and the event.attendance.changed webhook. On writes only, cancelled is accepted as an alias for unregistered; it is never returned, so a round-trip settles on the stored spelling.


GET /api/v1/events

Param Type Notes
q string (query) Substring match on the event name
external_id string (query) Exact, case-insensitive lookup by your own id
event_type_id UUID (query) Only events created from this saved event. A non-UUID value → 400
limit integer (query) Page size, default 50, max 500
offset integer (query) Rows to skip, default 0
curl "https://app.otok.io/api/v1/events?external_id=autumn-webinar-2026" \
  -H "Authorization: Bearer otok_live_abc123..."

Response 200: { "data": [ … ], "limit": 50, "offset": 0 }, newest start date first.

GET /api/v1/events/:id

Response 200: the event object. A non-UUID id → 400; an event in another workspace → 404 event_not_found.

POST /api/v1/events — upsert

Creates an event, or updates the one already carrying this external_id.

Field Type Notes
name string Required, ≤300 chars
external_id string ≤200 chars. Sending one that already exists updates that event and answers duplicate: true
start_at / end_at ISO 8601
status string draft, scheduled, canceled, completed. Defaults to scheduled — an event you create over the API is a real one, not an editor draft
timezone / language string
category / presenter / link_password string ≤200 chars each
link string ≤2000 chars
use_personal_links boolean
product_id / cycle_id UUID
event_type_id UUID The saved event to file this event under. See Saved events
event_type_name string ≤200 chars. The same saved event by its exact name (case-insensitive, whitespace trimmed) — for callers that don't hold the id
suppress_event_automations boolean
collect_payment_on_registration boolean Charge for registrations on this event — see Priced events

external_provider is not writable. It is how oToK knows a meeting belongs to a connected Zoom account, and a caller claiming it would make us push registrants at a meeting nobody owns.

curl -X POST "https://app.otok.io/api/v1/events" \
  -H "Authorization: Bearer otok_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Autumn webinar","external_id":"autumn-webinar-2026","start_at":"2026-10-06T14:00:00Z"}'

Response 201: the event object plus "duplicate": true | false.

Idempotency. Send the same external_id on every submission. The first call creates the event; the rest update it. There is no PATCH /v1/events/:id — the upsert is the update path.

Saved events

A saved event is a reusable event definition managed in the oToK app (Events → Saved events): a name plus a template of event-form defaults. Filing an event under one — by event_type_id, or by event_type_name when you only know the name — makes it show on that saved event's page, count in its statistics, and match automations scoped to it.

Status Code When
400 event_type_not_found No saved event in this workspace has that id / name
400 event_type_mismatch event_type_id and event_type_name name different saved events
curl -X POST "https://app.otok.io/api/v1/events" \
  -H "Authorization: Bearer otok_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Weekly yoga — October","external_id":"yoga-2026-10","event_type_name":"Weekly yoga","start_at":"2026-10-06T18:00:00Z"}'

Response 201: the event object with "event_type": { "id": "…", "name": "Weekly yoga" }.

Priced events

With collect_payment_on_registration: true, a new registration — made through POST /v1/events/:id/attendances, by an automation, or in the app's registration dialogs — records a sale of the event's product (at the cycle's price when the event has a cycle with one) and sends the contact a payment link. The registration's payment block then reports the sale and the live link.


POST /api/v1/events/:id/attendances — register

Registers a contact for the event, or sets their status directly.

Field Type Notes
contact_id UUID An existing contact. Mutually exclusive with contact
contact object { name, email, phone, national_id } (name ≤200, phone ≤40, national_id ≤20 chars; none may contain NUL characters) — upserted with the same identity resolution POST /v1/contacts uses (phone, then email, then national ID), so registering someone who already exists never creates a second copy of them. At least one identifier is required
status string Default registered. Accepts cancelled as an alias for unregistered
zoom_registration auto | skip Default auto. See below
join_url string The attendee's personal join link. Honoured with zoom_registration: "skip"
acquisition object How this registration was acquired — the same object POST /v1/contacts takes, minus attendance_id, which is stamped for you
curl -X POST "https://app.otok.io/api/v1/events/7f3c.../attendances" \
  -H "Authorization: Bearer otok_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
        "contact": { "name": "Jane Cohen", "email": "jane@example.com" },
        "acquisition": { "event_id": "webinar-signup-8891", "utm_source": "newsletter" }
      }'

Response 201: the registration object plus:

Field Notes
created true when this call created the registration, false when it moved an existing one
previous_status The status it moved from, or null when it was created
zoom { "status": …, "join_url": … } — what this call did with Zoom: registered (pushed, link returned), pending (Zoom was unreachable, a retry is scheduled and the "registered" automation waits for it), failed (Zoom refused permanently), skipped (you opted out) or not_applicable (the event isn't Zoom-linked, or nothing needed pushing)

Registering the same person twice does nothing twice. One registration per person per event: a retried form submission answers the same row with created: false, and fires no automation a second time.

Zoom. On a Zoom-linked event, auto registers the attendee with Zoom exactly as an in-app registration does, so their personal join link exists and the reminders that carry it work. Use skip only when you registered them with Zoom yourself, and pass the join_url you were given — a blank value never clears a link that is already there.

Refusals.

Status Code When
409 event_archived The event is archived — it takes no writes at all
409 event_canceled The event is canceled and the status is registered or waitlist. Marking attended / no_show still works: a roster is a record of what happened, not a plan
404 event_not_found / contact_not_found The event or contact is not in this workspace
400 contact_ambiguous / contact_required Both contact_id and contact, or a contact with no identifier
400 attendance_id_not_allowed acquisition.attendance_id was supplied — this call is the link, so it is stamped for you

An event that has merely ended still accepts registrations and roster marking.

GET /api/v1/events/:id/attendances

Param Type Notes
status string (query) One of the five stored statuses (or cancelled for unregistered). Any other value → 400 invalid_status
limit / offset integer (query) Page size default 50, max 500

Response 200: { "data": [ … ], "limit": 50, "offset": 0 }, newest first.

PATCH /api/v1/attendances/:id

Moves an existing registration.

curl -X PATCH "https://app.otok.io/api/v1/attendances/9a1e.../" \
  -H "Authorization: Bearer otok_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{"status":"attended"}'

Response 200: the registration object plus created: false and previous_status. On a priced event, moving someone back to registered charges like a new registration. The same refusals as the POST apply — an archived event takes no move, and a canceled one takes no move back to registered or waitlist.


Automations this fires

A registration made here is a registration like any other. It triggers the Event Activity automations (registered / unregistered / attended / waitlist), wakes any Wait-until-condition parked on the contact, records the lead-scoring signal, and emits event.attendance.changed — each exactly once per real status change, even if two of your servers call at the same moment.

Marking someone no_show records and scores like the rest, and wakes a waiting automation, but has no trigger of its own: automate no-shows with an Event Date/Time trigger targeting that status.

Related