API conventions
What is true of every Partner API request, in one place: the base URL, the shape of a success and of a failure, how lists are paged, what the rate limiter does, and the full list of endpoints with the scope each one needs.
Base URL and versioning
https://api.event67.com/partner/v1There is one host and one version. Everything is JSON in and JSON out, over TLS, and there is no sandbox host: a draft event on your own organization is the safe place to experiment.
Additive changes do not bump the version. A new endpoint, a new optional query parameter, a new optional field on a request, or a new field on a response can appear under/partner/v1 at any time. Write clients that ignore fields they do not recognise; a parser that rejects unknown keys will break on a change that was safe for everybody else.
Breaking changes ship as a new version. Removing or renaming a field, making an optional field required, narrowing an accepted value set, or changing what a status code means would arrive as /partner/v2, announced ahead of time, with both versions running while you move. Every change of either kind is dated in thechangelog, and a spec change that does not add a changelog entry in the same commit fails our build.
The response envelope
Every successful response is an object with a data member. Single resources put the resource there; collections put an array there and add meta.
{
"data": { "id": "8d2f0a16-4e93-4b28-b0c7-1f5e9a3d6c40", "name": "Northwind Summit 2026" }
}{
"data": [ /* … */ ],
"meta": {
"nextCursor": "eyJhdCI6IjIwMjYtMTAtMjlUMTU6MDA6MDAuMDAwWiIsImlkIjoiOGQyZjBhMTYtNGM3Yi00ZjllLTlhMWQtM2I2ZTVjMmYwMDcxIn0"
}
}The envelope never disappears, not even for an empty list, so a client can readdata unconditionally. Errors replace data with error and never carry both.
Pagination
Collections are cursor paginated. Two query parameters, and a cursor you copy rather than construct.
limit— 1 to 200. Defaults to 50.cursor— the opaque string from the previous page'smeta.nextCursor. Omit it for the first page.
meta.nextCursor is null on the last page and that, not an emptydata array, is how you know to stop. Cursors are keyset based, so paging stays correct and fast while records are being created underneath you, and they are opaque: their contents will change without notice and decoding one is not a supported thing to do.
Copy the cursor; do not construct one. A value we did not issue is refused with its own code rather than the generic validation one, so you can tell "my paging loop is broken" apart from "my request body is wrong" without reading the message.
{
"error": {
"code": "INVALID_CURSOR",
"message": "That cursor is not one we issued.",
"details": { "reason": "invalid_cursor" },
"requestId": "c1a03f76-4e28-4b95-8d60-7ba2e9c4d013"
}
}Attendee lists page oldest first, so an incremental sync can store its last cursor and resume from it. Events page newest first, sessions in start-time order and speakers by name.
PAGE=$(curl -s "https://api.event67.com/partner/v1/events/$EVENT_ID/attendees?limit=200" \
-H "Authorization: Bearer $E67_KEY")
# Records: .data — process them, then read the cursor.
CURSOR=$(echo "$PAGE" | jq -r '.meta.nextCursor // empty')
while [ -n "$CURSOR" ]; do
PAGE=$(curl -s --get "https://api.event67.com/partner/v1/events/$EVENT_ID/attendees" \
--data-urlencode "limit=200" \
--data-urlencode "cursor=$CURSOR" \
-H "Authorization: Bearer $E67_KEY")
CURSOR=$(echo "$PAGE" | jq -r '.meta.nextCursor // empty')
doneA few small collections are not paginated because they cannot grow without bound: tracks, rooms, sponsors, published info sections, and the form list. They return the whole set indata with no meta.
Errors
Every failure is the same envelope. code is stable and safe to branch on;message is written for a human and will change. details is present when there is something specific to say, and requestId is the value to quote if you ask us about a particular call.
{
"error": {
"code": "NOT_FOUND",
"message": "No such event.",
"requestId": "9b0e4f21-8c76-4a3d-bb52-0e17c4d9a638"
}
}Validation failures
INVALID_INPUT is the one error that says more than a sentence, because it is the one you can act on field by field. Its details carries three members.
{
"error": {
"code": "INVALID_INPUT",
"message": "Validation failed",
"details": {
"reason": "invalid_body",
"fields": {
"email": { "code": "invalid_string", "message": "Invalid email" },
"firstName": { "code": "too_small", "message": "String must contain at least 1 character(s)" }
},
"issues": [
{ "code": "invalid_string", "validation": "email", "path": ["email"],
"message": "Invalid email" },
{ "code": "too_small", "type": "string", "minimum": 1, "inclusive": true,
"path": ["firstName"], "message": "String must contain at least 1 character(s)" }
]
},
"requestId": "9b0e4f21-8c76-4a3d-bb52-0e17c4d9a638"
}
}fieldsis an object, not an array. Each key is the full dotted path of the value that failed, so a nested one readsfields.0.labeland addresses exactly one input rather than collapsing onto the name of its container. Look a field up by path; do not iterate positionally.- One fault per path. If a value fails several ways at once, the first is kept. Three messages under one field read as three problems when there is one.
issuesis the validator's own output, verbatim. Everything infieldsis in here too, plus anything with no path at all: a complaint about the body as a whole, such as a missing object or a rule spanning two fields, has no field to attach to, so it appears only here and in the top-levelmessage. If you are logging a failure rather than rendering it, logissues.- The top-level
messageis fixed. It reads"Validation failed"and is not the field's message. Do not show it to a user on its own; it will tell them nothing.
| Code | Status | What it means |
|---|---|---|
| INVALID_INPUT | 400 | A parameter or body field failed validation, withdetails.reason: "invalid_body". details.fields is an object keyed by each failing field's dotted path, as above. Fix the request; retrying it unchanged will fail identically. |
| INVALID_CURSOR | 400 | The cursor query parameter is not one we issued.details.reason is "invalid_cursor". Start the collection again with no cursor, and copy meta.nextCursor verbatim from then on. |
| UNAUTHORIZED | 401 | The key is missing, unknown, revoked or expired. Seeauthentication for the four reasons. |
| PAYMENT_REQUIRED | 402 | The organization is not entitled to integrations. Not retryable; a person has to act. |
| FORBIDDEN | 403 | The key lacks the required scope, or the organization is suspended.details.requiredScope names the scope in the first case. |
| NOT_FOUND | 404 | No such record, or the record belongs to another organization. The two are deliberately indistinguishable, so an id from somebody else's event tells you nothing about whether it exists. |
| CONFLICT | 409 | The write would duplicate something. Creating an attendee whose email is already on the event returns details.reason: "attendee_exists" anddetails.attendeeId, so you can update instead of retrying. |
| RATE_LIMITED | 429 | Too many requests on this key, with details.reason: "rate_limited". Waitdetails.retryAfterSeconds, which is the Retry-After header rounded up to the next whole second, and retry. |
| INTEGRATIONS_DISABLED | 503 | Integrations are switched off platform-wide. Temporary, affects everybody, and the right response is to back off rather than to page somebody. |
Retry 429 and 503, and any 5xx, with exponential backoff. Do not retry 400, 401, 402, 403,404 or 409: nothing about repeating them changes the answer.
Rate limits
600 requests a minute, counted per API key rather than per organization or per IP address. A second key is not a way around it, but it does mean a busy nightly sync and a live badge printer on separate keys cannot starve each other.
HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 23
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. This API key is limited to 600 per minute.",
"details": { "reason": "rate_limited", "retryAfterSeconds": 23 },
"requestId": "4d7c2a90-1b6e-4f85-a03c-77e9b1d5f420"
}
}X-RateLimit-Limit— the ceiling for the window.X-RateLimit-Remaining— how many requests are left in it.X-RateLimit-Reset— seconds until the window rolls over.Retry-After— on a429only, the seconds to wait. Honour this rather than your own backoff schedule.
If you are bumping the limit, page with limit=200 rather than 50 before you ask for a raise: a full attendee list at 200 a page is four times fewer requests for the same data.
Timestamps, ids and caching
- Timestamps are ISO-8601 in UTC, always with a
Z, for example2026-10-29T15:00:00.000Z. An event also carries its owntimezoneas an IANA name, which is what you need to render a local start time. - Ids are UUIDs and are stable for the life of the record. Store them; do not match on names.
- Every response carries
x-request-id, and the same value appears asrequestIdin an error body. Log it. It is the fastest way for us to find one call among millions. - Responses are
Cache-Control: private, no-store. This is attendee data; nothing about it should sit in a shared cache. Cache in your own store if you need to, keyed on ids and refreshed by webhooks. - Only
GET,POSTandPATCHare used.There is no delete in the Partner API.
Every endpoint
Paths are relative to https://api.event67.com/partner/v1. Parameters and response schemas for each one are in the generated reference at https://api.event67.com/partner/v1/docs, with the machine readable document at https://api.event67.com/partner/v1/openapi.json.
| Method and path | Scope | Returns |
|---|---|---|
| GET /me | any | The organization and the key behind this request. |
| GET /events | events:read | Events, newest first. Filter with status (draft,published or archived); paged. |
| GET /events/{eventId} | events:read | One event. |
| GET /events/{eventId}/sessions | events:read | The programme, in start-time order. Paged. |
| GET /events/{eventId}/tracks | events:read | Every track. Not paged. |
| GET /events/{eventId}/rooms | events:read | Every room. Not paged. |
| GET /events/{eventId}/speakers | events:read | Speakers, in name order. Paged. |
| GET /events/{eventId}/sponsors | events:read | Sponsors, in the organiser's order. Not paged. |
| GET /events/{eventId}/info-sections | events:read | Published info sections only. Not paged. |
| GET /events/{eventId}/attendees | attendees:read | Attendees, oldest first. Filter with status(invited, active or revoked); paged. |
| GET /events/{eventId}/attendees/{attendeeId} | attendees:read | One attendee. |
| POST /events/{eventId}/attendees | attendees:write | Creates one, and returns 201. Only email is required. A duplicate email is a 409 carrying the existing attendee's id. No email is sent. |
| PATCH /events/{eventId}/attendees/{attendeeId} | attendees:write | Updates displayName, company and jobTitle.null clears the last two. Email is immutable in v1 and is not accepted here. |
| GET /events/{eventId}/check-ins | engagement:read | Event check-ins. Paged. |
| GET /events/{eventId}/session-attendance | engagement:read | Session scans. Filter with sessionId; paged. |
| GET /events/{eventId}/forms | engagement:read | Forms and their field definitions. Not paged. |
| GET /events/{eventId}/forms/{formId}/submissions | engagement:read | Answers, per attendee. Paged. |
| GET /events/{eventId}/feedback | engagement:read | Session ratings and comments. Paged. |
| GET /openapi.json | none | The OpenAPI 3.1 document. Public. |
| GET /docs | none | The browsable reference. Public. |
API keys, webhook endpoints and data sources are created and managed in the organizer console, not through the Partner API. Those routes authenticate as a signed-in org admin, which is the right boundary: a key that could mint another key would make revocation meaningless.
Resource shapes
These are the projections the API returns, copied from the examples in the spec itself. A webhook delivery carries the same shapes, minus a handful of fields the emit path cannot supply. The webhooks page lists exactly which.
Read this before you map anything. event67 does not store every field a conference API might, and these projections read the way the platform actually works rather than the way a generic one would. An attendee has one name, a session names its room as free text, and an info section's details is an array of rows. Each is called out below where it appears.
Event
{
"id": "8d2f0a16-4e93-4b28-b0c7-1f5e9a3d6c40",
"slug": "northwind-summit-2026",
"name": "Northwind Summit 2026",
"description": "Two days on supply chains, in New York.",
"status": "published",
"startAt": "2026-10-29T15:00:00.000Z",
"endAt": "2026-10-30T23:00:00.000Z",
"timezone": "America/New_York",
"location": "Javits Center, New York",
"createdAt": "2026-06-02T09:14:22.117Z",
"updatedAt": "2026-09-01T11:40:03.882Z"
}Draft events are visible to you, because a key is an organization credential. They are not visible to attendees. Every timestamp is UTC, so timezone, an IANA name, is what you need to render a local start time.
Session
{
"id": "c51b7e04-2d98-4a1f-8b3c-6e0a95d27f11",
"eventId": "8d2f0a16-4e93-4b28-b0c7-1f5e9a3d6c40",
"title": "Rebuilding a supplier network in ninety days",
"description": "A post-mortem.",
"type": "talk",
"startAt": "2026-10-29T16:30:00.000Z",
"endAt": "2026-10-29T17:15:00.000Z",
"trackId": "a2f61c8d-70b4-4e29-9d13-5c8e0b4a7d62",
"room": "Hall 2B",
"capacity": 180,
"speakerIds": ["d40a1f73-6c25-4b98-8e07-3a91d5c6b204"]
}roomis free text, not an id. A session records the room it was given by name, and/roomsis a list the organiser maintains separately. There is no key between the two, so do not try to join them.typeis a growing catalogue, not a closed set. Today it is one of keynote, talk, panel, fireside, lightning, workshop, masterclass, training, demo, roundtable, breakout, qa, ceremony, announcement, exhibition, networking, reception, party, meal, break and other. New kinds arrive without notice, so treat anything you do not recognise asother.trackId,roomandcapacityare each nullable.speakerIdsis in billing order.
Attendee
{
"id": "f13c6b28-9a05-4d71-b6e4-20c8f5a9d374",
"eventId": "8d2f0a16-4e93-4b28-b0c7-1f5e9a3d6c40",
"badgeCode": "A7K3QS",
"displayName": "Dana Okonkwo",
"email": "dana@example.com",
"company": "Meridian Logistics",
"jobTitle": "Head of Operations",
"status": "active",
"visibleInList": true,
"invitedAt": "2026-09-02T08:00:00.000Z",
"checkedInAt": "2026-10-29T14:52:31.006Z",
"createdAt": "2026-09-01T16:22:47.910Z",
"updatedAt": "2026-10-29T14:52:31.006Z"
}- One name field,
displayName. There is nofirstNameorlastName. If your system holds two, join them on the way in; splitting one on the way out would round-trip badly for every name that is not two words. statusisinvited,activeorrevoked: on the list, signed in and claimed, or removed by the organiser. There is noactivated.- There is no
joinedAt. Nothing records the moment somebody claimed an invite, only thestatusthat results.invitedAtis when an invitation email was last sent, and is null if none ever was. visibleInListis a consent flag. It says whether the attendee agreed to appear in the in-app directory. Respect it if you republish a guest list anywhere.badgeCodeis the short code printed on the badge. Six characters drawn from an alphabet that omits0,1,I,LandO, the last of them a check character. It is returned undashed, asA7K3QS, and shown to people asA7K-3QS. It also prints on the attendee's QR card, so someone can read it out when a scanner fails. It never changes once the record exists, and it is unique within an event, not across events. It is not the QR token behind the badge.
Writing one takes a smaller body than reading one returns.
POST /partner/v1/events/{eventId}/attendees
{
"email": "dana@example.com",
"displayName": "Dana Okonkwo",
"company": "Meridian Logistics",
"jobTitle": "Head of Operations"
}
// Only "email" is required. Omit displayName and it defaults to the local
// part of the address. PATCH takes displayName, company and jobTitle only;
// null clears company or jobTitle, and email cannot be changed at all.What an attendee never contains. The projection above is the whole of it. The QR token behind a badge, the invite code, the internal user id, the sign-in identity, and the per-message email engagement timestamps are not returned by any endpoint, at any scope, and a test in our build fails if one of them ever appears. If you need to identify an attendee to your own system, key on id or on email.
Speakers, sponsors, tracks, rooms and info sections
// Speaker
{ "id": "d40a1f73-…", "name": "Priya Raman", "title": "VP Supply Chain",
"company": "Meridian Logistics", "bio": "Twenty years in freight.",
"photoUrl": "https://example.com/priya.jpg", "photoPath": null }
// Sponsor
{ "id": "b93e0c17-…", "name": "Harbourline", "tier": "gold",
"websiteUrl": "https://example.com", "description": "Port logistics.",
"sortOrder": 0, "featuredOnCards": true }
// Track
{ "id": "a2f61c8d-…", "name": "Operations", "color": "#1E2A36", "sortOrder": 0 }
// Room
{ "id": "7e3d95a1-…", "name": "Hall 2B", "sortOrder": 0 }
// InfoSection (published only)
{
"id": "3c7f18b6-…", "title": "Getting there",
"summary": "Subway, taxi and parking.",
"body": "The 7 train stops at 34th St — Hudson Yards.",
"icon": "transport",
"details": [{ "label": "Nearest station", "value": "34th St — Hudson Yards" }],
"sortOrder": 2
}- An info section's
detailsis an array of{ label, value }rows, meant to render as a small table. The prose is inbody, withsummaryas a one-line teaser andiconnaming the glyph the apps draw. Only published sections are returned; drafts and sections an organiser has hidden are never in the list. - A speaker has both
photoUrlandphotoPath, and usually only one of them.photoUrlis set when the organiser pasted a link. When the photograph was uploaded instead, that is null andphotoPathnames the stored object, which needs the event67 apps to resolve into an image. tieron a sponsor is organiser-defined free text, not an enum. Sponsors, tracks and rooms each carrysortOrder, which is the order the organiser chose.featuredOnCardsmarks the sponsor printed on the badge. At most one sponsor of an event has it true, and it is the one whose logo prints on every attendee's QR card. An event may have none, so do not expect to find one.
Engagement
// GET /events/{eventId}/check-ins — the front desk
{ "attendeeId": "f13c6b28-…", "checkedInAt": "2026-10-29T14:52:31.006Z" }
// GET /events/{eventId}/session-attendance — a door scan
{ "id": "2a9d5f80-…", "sessionId": "c51b7e04-…", "attendeeId": "f13c6b28-…",
"scannedAt": "2026-10-29T16:33:12.480Z" }
// GET /events/{eventId}/forms
{
"id": "5b8c1d90-…", "eventId": "8d2f0a16-…", "name": "Dietary requirements",
"description": "", "status": "open",
"fields": [{ "key": "diet", "label": "Any dietary requirements?",
"type": "short_text", "required": false }]
}
// GET /events/{eventId}/forms/{formId}/submissions
{
"id": "e07a2c46-…", "formId": "5b8c1d90-…", "attendeeId": "f13c6b28-…",
"submittedAt": "2026-10-20T10:03:55.712Z",
"updatedAt": "2026-10-20T10:03:55.712Z",
"answers": { "diet": "No nuts" }
}
// GET /events/{eventId}/feedback
{
"id": "aa41f5d3-…", "attendeeId": "f13c6b28-…", "sessionId": "c51b7e04-…",
"kind": "review", "rating": 5, "comment": "Worth the flight.",
"createdAt": "2026-10-29T17:20:44.001Z"
}- Check-ins and session attendance are different things. A check-in is the front desk, once per event. Session attendance is a door scan, once per session.
- A form field's
typedecides the shape of its answer. Today it is one of short_text, long_text, email, phone, number, date, single_select, multi_select, checkbox and rating. A multi-select answer is an array, a checkbox a boolean. New types are added without notice. Answers are keyed by the field'skey, which is never renamed. - Feedback carries
kind.ratingis a bare score,reviewa score with a note, andfeedbackandsuggestionare notes with no score at all, soratingis null for those. ItssessionIdis null when the feedback is about the event as a whole, and itsattendeeIdis null when whoever left it is not on the event as an attendee, an organiser rating their own event for instance.
Once you are reading these reliably, stop polling for changes and letwebhooks tell you instead.