event67
Open menu

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/v1

There 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's meta.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')
done

A 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"
  }
}
  • fields is an object, not an array. Each key is the full dotted path of the value that failed, so a nested one readsfields.0.label and 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.
  • issues is the validator's own output, verbatim. Everything infields is 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-level message. If you are logging a failure rather than rendering it, log issues.
  • The top-level message is 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.
CodeStatusWhat it means
INVALID_INPUT400A 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_CURSOR400The 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.
UNAUTHORIZED401The key is missing, unknown, revoked or expired. Seeauthentication for the four reasons.
PAYMENT_REQUIRED402The organization is not entitled to integrations. Not retryable; a person has to act.
FORBIDDEN403The key lacks the required scope, or the organization is suspended.details.requiredScope names the scope in the first case.
NOT_FOUND404No 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.
CONFLICT409The 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_LIMITED429Too 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_DISABLED503Integrations 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 a 429 only, 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 owntimezone as 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 as requestId in 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, POST and PATCH are 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 pathScopeReturns
GET /meanyThe organization and the key behind this request.
GET /eventsevents:readEvents, newest first. Filter with status (draft,published or archived); paged.
GET /events/{eventId}events:readOne event.
GET /events/{eventId}/sessionsevents:readThe programme, in start-time order. Paged.
GET /events/{eventId}/tracksevents:readEvery track. Not paged.
GET /events/{eventId}/roomsevents:readEvery room. Not paged.
GET /events/{eventId}/speakersevents:readSpeakers, in name order. Paged.
GET /events/{eventId}/sponsorsevents:readSponsors, in the organiser's order. Not paged.
GET /events/{eventId}/info-sectionsevents:readPublished info sections only. Not paged.
GET /events/{eventId}/attendeesattendees:readAttendees, oldest first. Filter with status(invited, active or revoked); paged.
GET /events/{eventId}/attendees/{attendeeId}attendees:readOne attendee.
POST /events/{eventId}/attendeesattendees:writeCreates 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:writeUpdates displayName, company and jobTitle.null clears the last two. Email is immutable in v1 and is not accepted here.
GET /events/{eventId}/check-insengagement:readEvent check-ins. Paged.
GET /events/{eventId}/session-attendanceengagement:readSession scans. Filter with sessionId; paged.
GET /events/{eventId}/formsengagement:readForms and their field definitions. Not paged.
GET /events/{eventId}/forms/{formId}/submissionsengagement:readAnswers, per attendee. Paged.
GET /events/{eventId}/feedbackengagement:readSession ratings and comments. Paged.
GET /openapi.jsonnoneThe OpenAPI 3.1 document. Public.
GET /docsnoneThe 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"]
}
  • room is free text, not an id. A session records the room it was given by name, and /rooms is a list the organiser maintains separately. There is no key between the two, so do not try to join them.
  • type is 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 as other.
  • trackId, room and capacity are each nullable.speakerIds is 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 nofirstName or lastName. 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.
  • status is invited, active orrevoked: on the list, signed in and claimed, or removed by the organiser. There is no activated.
  • There is no joinedAt. Nothing records the moment somebody claimed an invite, only the status that results. invitedAt is when an invitation email was last sent, and is null if none ever was.
  • visibleInList is 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.
  • badgeCode is the short code printed on the badge. Six characters drawn from an alphabet that omits 0, 1, I,L and O, the last of them a check character. It is returned undashed, as A7K3QS, and shown to people as A7K-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 details is an array of{ label, value } rows, meant to render as a small table. The prose is in body, with summary as a one-line teaser andicon naming 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 photoUrl and photoPath, and usually only one of them. photoUrl is set when the organiser pasted a link. When the photograph was uploaded instead, that is null and photoPath names the stored object, which needs the event67 apps to resolve into an image.
  • tier on a sponsor is organiser-defined free text, not an enum. Sponsors, tracks and rooms each carry sortOrder, which is the order the organiser chose.
  • featuredOnCards marks 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 type decides 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's key, which is never renamed.
  • Feedback carries kind. rating is a bare score,review a score with a note, and feedback and suggestionare notes with no score at all, so rating is null for those. ItssessionId is null when the feedback is about the event as a whole, and itsattendeeId is 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.