Changelog
What changed in the Partner API, when, and whether it can break you. This page is rendered from the same file the build checks, so a spec change that shipped without a line here would have failed before it reached you.
How to read this
Entries are newest first and dated, because the API's version is a date. The version the running server reports is in info.version athttps://api.event67.com/partner/v1/openapi.json, and the corresponding entry below tells you what moved. The browsable reference is at https://api.event67.com/partner/v1/docs.
Two habits make additive changes free for you: ignore response fields you do not recognise, and ignore webhook event types you did not ask about. Both will appear, and neither is announced as a breaking change because neither breaks a client that does those two things.
The public record of what changed in the event67 Partner API, written for the people who build against it. It is rendered verbatim at https://event67.com/developers/changelog/ at build time, so this file is the only copy and the page cannot drift from it.
The rule this file exists to enforce. A change to the Partner API spec bumps info.version and adds a ## <version> entry here, in the same commit. Entries say what changed, whether it is additive or breaking, and what an integrator must do. The snapshot test regenerates the spec, compares it with the committed backend/openapi/partner-v1.json, and asserts that a heading for the spec's own version exists in this file, so a spec change without a changelog line cannot land.
What counts as additive. A new endpoint, a new optional request field, a new optional query parameter, or a new field on a response body. These ship under /partner/v1 and every call you already make keeps working, so parse responses leniently and ignore fields you do not recognise.
What counts as breaking. Removing or renaming a field, making an optional request field required, narrowing an accepted value set, or changing the meaning of a status code. A breaking change ships as /partner/v2 with notice, and both versions run side by side while you move.
2026-09-10
Additive. Attendees now carry a short badge code, and sponsors carry a flag saying which of them prints on every attendee's badge card. Every response and webhook body that describes an attendee carries the code, and every Sponsor object carries the flag. Nothing you already call changes shape beyond the new fields, so parse leniently and you need do nothing.
badgeCode on the Attendee resource
badgeCode is a required string on every Attendee object: in GET /events/{eventId}/attendees, GET /events/{eventId}/attendees/{attendeeId}, and in the bodies of POST /events/{eventId}/attendees and PATCH /events/{eventId}/attendees/{attendeeId}. It is also on the attendee.* webhook bodies — attendee.created, attendee.updated, attendee.checked_in and attendee.deleted — so a partner syncing over webhooks and a partner polling the API see the same value.
What it is. The short code printed on the attendee's badge card, so staff can type it at a door when a QR scanner will not read. It is not a credential: it is printed on a lanyard in front of everyone at the event, and holding one admits nobody. Every event67 endpoint that accepts a badge code already requires an event moderator or an organization admin.
The format. Six characters from the 31-character alphabet 23456789ABCDEFGHJKMNPQRSTUVWXYZ, which omits every pair a person confuses on a printed card (0/O, 1/I/L). The first five characters are random; the sixth is a check character:
check = alphabet[(Σ (i + 1) × value_i) mod 31]
over the five data characters, with i zero-based (weights 1, 2, 3, 4, 5) and value_i the index of that character in the alphabet. Thirty-one is prime and every weight is below it, so the check character catches every single mistyped character and every transposition of two adjacent characters. You can validate a code with the same arithmetic before you send it anywhere.
Worked example: A7K3Q has values 8, 5, 17, 1, 21. The weighted sum is 1×8 + 2×5 + 3×17 + 4×1 + 5×21 = 178, and 178 mod 31 = 23, which is S. The code is therefore A7K3QS.
Stored vs displayed. The API always returns and accepts the STORED form: uppercase, six characters, no separator (A7K3QS). event67 shows it to people grouped as A7K-3QS. If you display a code, group it the same way; if you take one as input, strip spaces and dashes and upper-case it before comparing.
Scope of uniqueness. A badge code is unique within an EVENT, not across the platform. Two attendees at two different events can hold the same code, so always key on (eventId, badgeCode).
Backfill. Every attendee that existed before this release was given a code in the same migration, so there is no window in which the field is absent or empty.
featuredOnCards on the Sponsor resource
Additive. featuredOnCards is a required boolean on every Sponsor object in GET /events/{eventId}/sponsors. Nothing you already call changes shape beyond the new field.
What it is. An organiser can nominate one sponsor per event as the title sponsor, whose logo then prints on every attendee's badge card. featuredOnCards is true for that sponsor and false for the rest.
At most one, and possibly none. The database holds the rule directly, so a response can never carry two featured sponsors for one event, and an event whose organiser has not nominated anybody carries none. If you surface this, treat it as an optional single value rather than as a list.
It can move. Nominating a second sponsor clears the first in the same transaction, so a sponsor that was featured on your last sync may not be on your next one. Read it from the sponsor list rather than caching it against a sponsor id.
2026-09-09
Initial release of the Partner API v1, outbound webhooks and data sources. Additive by definition: there is nothing before it to break.
Partner API
Base URL https://api.event67.com/partner/v1, authenticated with an org-scoped API key sent as Authorization: Bearer e67_live_… (X-API-Key is accepted as a secondary for tooling that cannot set an Authorization header). Responses are wrapped in { data }, cursor-paged list responses add { meta: { nextCursor } }, and errors are { error: { code, message, details?, requestId } }.
GET /me— the org and the key behind the request. Any scope, including none.GET /events,GET /events/{eventId}— scopeevents:read.GET /eventsfilters onstatusand is paged, newest first.GET /events/{eventId}/sessions— scopeevents:read. Paged, in start-time order.GET /events/{eventId}/tracks,GET /events/{eventId}/rooms— scopeevents:read.GET /events/{eventId}/speakers— scopeevents:read. Paged, in name order.GET /events/{eventId}/sponsors— scopeevents:read.GET /events/{eventId}/info-sections— published sections only, scopeevents:read.GET /events/{eventId}/attendees,GET /events/{eventId}/attendees/{attendeeId}— scopeattendees:read. The list filters onstatusand is paged oldest first, so an incremental sync can resume from its last cursor.POST /events/{eventId}/attendees,PATCH /events/{eventId}/attendees/{attendeeId}— scopeattendees:write. No email is ever sent. A duplicate email answers409withdetails.reason: "attendee_exists"anddetails.attendeeId.GET /events/{eventId}/check-ins— scopeengagement:read. Venue arrivals.GET /events/{eventId}/session-attendance— scopeengagement:read. Door scans, filtered withsessionId.GET /events/{eventId}/forms,GET /events/{eventId}/forms/{formId}/submissions— scopeengagement:read.GET /events/{eventId}/feedback— scopeengagement:read.GET /openapi.json,GET /docs— the machine-readable spec and its reference UI. Both are unauthenticated.
Paged reads take limit (1 to 200, default 50) and an opaque cursor. Rate limit is 600 requests a minute per key. Timestamps are ISO-8601 in UTC. Every response carries Cache-Control: private, no-store and an x-request-id header.
Not paginated, because they cannot grow without bound: tracks, rooms, sponsors, published info sections, and the form list. They return { data } with no meta, and the absence of meta is how you tell the two kinds apart.
Where a resource differs from what you might expect
event67 does not store every field a conference API might. Rather than invent one, these read the way the platform actually works.
- **An attendee has one name field,
displayName, notfirstNameandlastName.** If your system holds two, join them on the way in. Splitting one stored name would round-trip badly for every name that is not two words, and aPATCHof one half would have to re-derive the other from a guess. - An attendee's
statusisinvited,activeorrevoked— on the list, signed in, or removed by the organiser. There is noactivated. The ten-state funnel the organiser console shows is computed from per-message email engagement, and publishing it would hand you the same facts those timestamps carry, one derivation removed. - An attendee has no
joinedAt. No column records when somebody claimed an invite, only thestatusthat results from it.invitedAtis when an invitation email was last sent, and is null if none ever was. - An attendee's job title is
jobTitle, andvisibleInListsays whether they agreed to appear in the in-app attendee directory. Respect it if you republish a guest list anywhere. - A session carries
roomas free text, not aroomId. Sessions record the room they were given by name;/roomsis a list the organiser maintains separately, and there is no key between them. A session also carriestype, which is a growing catalogue rather than a closed set. - An info section's
detailsis an array of{ label, value }rows, not a string. It also carriessummary,bodyandicon. - A speaker's
photoUrlis set only when the organiser pasted a link. When the photograph was uploaded instead,photoUrlis null andphotoPathnames the stored object, which needs the event67 apps to render. - A form field's
typeis a string, currently one ofshort_text,long_text,email,phone,number,date,single_select,multi_select,checkbox,rating. New types are added without notice. - Feedback carries
kind(feedback,suggestion,rating,review), and itsattendeeIdis null when the person who left it is not on the event as an attendee — an organiser rating their own event, for instance.sessionIdis null when the feedback is about the event as a whole. - An invalid cursor answers
400with codeINVALID_CURSOR, notINVALID_INPUT. Copymeta.nextCursor; do not construct one.
What an attendee never contains: the QR token behind a badge, the invite code, the internal user id, the sign-in identity, and every per-message email engagement timestamp. Not at any scope, on any endpoint. A test in our build fails if one of them ever appears.
Webhooks
Register an HTTPS endpoint in the console and event67 signs and POSTs domain events to it. The catalogue in this release is ping, event.updated, event.published, attendee.created, attendee.updated, attendee.deleted, attendee.checked_in, session.created, session.updated, session.deleted, session_attendance.created, form.submission.created and feedback.created.
The data member of a delivery is the Partner API's projection of that resource, parsed through the same schema, so a field that appears in both means the same thing in both. It is not a promise that both carry the same fields: a webhook body leaves several out, and "Fields a webhook body omits" below lists every one of them and why. Signatures are X-Event67-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256> over "<t>.<raw body>". Failed deliveries retry six times over 24 hours and an endpoint auto-disables after 50 consecutive failures.
Fields a webhook body omits
A webhook is emitted from a workflow holding a domain record; the API is served from a repository holding a database row, and the two do not carry the same columns. Where the emitting side cannot supply a field it is left out rather than guessed — a createdAt inferred from another timestamp is worse than an absent one, because you cannot tell it is wrong. Read the field as "not sent here", never as "null" or "changed". Fetch the resource from the API when you need it.
| Event | Omitted from data | Why |
|---|---|---|
attendee.* | createdAt, updatedAt | The attendee record the workflow holds does not carry them. |
event.* | createdAt, updatedAt | Same. |
session.* | speakerIds | Resolving them is a second query on a path that must never slow a request down or fail one. |
feedback.created | attendeeId | Feedback records the person as a user, and mapping that to an attendee means reaching into another module from the emit path. The body carries the feedback id; re-read /events/{eventId}/feedback for the person. |
check_in and session_attendance.created bodies are complete: they carry every field their API projection does.
form.submission.created has a shape of its own, and is the one delivery whose data is not an API projection:
{ "formId": "5b8c1d90-…", "eventId": "8d2f0a16-…", "attendeeId": "f13c6b28-…",
"revision": 1, "answers": { "diet": "No nuts" },
"submittedAt": "2026-10-20T10:03:55.712Z" }
The public submit path upserts and answers with a revision rather than a stored row, so there is no id and no updatedAt to send, and adding a read to an unauthenticated request purely to decorate a webhook is not a trade worth making. A correction arrives as this same event with a higher revision, which is why there is no form.submission.updated. GET /events/{eventId}/forms/{formId}/submissions returns the stored row, with id and updatedAt.
Data sources
Point event67 at a partner's JSON API, preview the payload, map fields with no code, and pull into attendees, sessions or a raw record store, on demand or on an hourly or daily schedule. Pulls create and update. They never delete.
Known limits in v1
- Writes are limited to attendees. Sessions, speakers, sponsors and info sections are read-only.
- There is no OAuth 2 and no per-user delegated access. Keys are org credentials.
- There are no test-mode keys. The
e67_test_prefix is reserved and onlye67_live_keys are minted. - An attendee's email address is immutable through
PATCH.