What the app is#
Events runs the whole lifecycle of an event inside HubSpot: an organiser creates the event as a CRM record, visitors register through an embed on the website, the app issues each attendee a QR ticket by email, and staff scan that ticket at the door. Capacity, waitlists and automatic promotion are handled for you; every registration is a CRM record, so reporting, workflows and lists work on it the way they work on anything else in the portal.
| Kind | Public HubSpot app (OAuth), installed per portal |
| Surfaces | Website embed (registration + ticket) · in-CRM organiser app page · QR scanner page for door staff · "Send Event Email" workflow action |
| Stores data in | Two custom objects on your own portal — events and event_registrations |
| Paid tickets | Optional, through the Commerce payment platform — see Integration seams |
| How to get it | Not self-service. Ask for an install: w@reus.ie |
Note
This app is scheduled for an overhaul. This page is its permanent entry point — the URL will not change — but expect the detail below to be rewritten rather than extended. Check the verified date before relying on a specific field name.
Overview#
The Events app stores event data in two HubSpot custom CRM objects: events and event_registrations. The worksby.design API provides endpoints for listing events, managing registrations, and checking attendees in. Events themselves are created and edited directly in HubSpot CRM (not via this API) — see Creating and managing events below.
Base URL: https://api.worksby.design/apps/events/
All responses are JSON. Successful responses include "success": true. Errors include "error": "<message>" and an appropriate HTTP status code.
Tip
For AI agents: the common read/registration flow is fetch-events → fetch-event-details → create-registration (each call requires portalId). For anything belonging to a specific member — their ticket, their registrations, check-in — mint a session with scan-session first and read Contact sessions; passing a contactId in the body does not identify a caller. To seed events and sample registrations on a portal, write the events and event_registrations custom objects directly via the HubSpot CRM API — see Creating and managing events.
Authentication#
There are five auth policies, not one. Which applies to a given endpoint is listed in Endpoint index below, and that table is generated from the same manifest the service's own route-coverage test enforces — so it cannot drift from the running code.
| Policy | What the caller must present | Used for |
|---|---|---|
| None | portalId only | license-status, callable before install |
| Portal + licence | portalId, and the portal must have completed OAuth install with an active licence | Public reads and registration |
| Portal + contact session | The above plus a session token proving a specific contactId | Anything returning or mutating a specific member's data |
| Portal + shared secret | The above plus a configured secret | Sibling-app configuration and payment callbacks |
| HubSpot signature | A valid HubSpot request signature (v3 preferred, v2 accepted) | The "Send Event Email" workflow action |
Every request carries portalId — the numeric HubSpot portal ID. For GET pass it as a
query parameter; for POST include it in the JSON body. No API key is ever passed by the
caller.
The server validates that the portal has completed the OAuth install flow and holds a stored
access token. If not, the endpoint returns 403 with
{ "error": "Portal <id> not authorised for events" }. If the licence has expired it returns
403 with { "code": "LICENSE_INACTIVE" }.
Contact sessions — read this before integrating#
Five endpoints require a proven contact and will not accept a contactId in the
body as proof of identity: check-in-by-qr, door-events, my-event-registration,
fetch-user-registrations and update-registration. For the member-scoped three, the session
token's contact overrides any id you submit, so a caller can only ever read or change their
own registrations; the two scanner endpoints use the session to prove staff, not to scope data.
Mint the token with scan-session. It is an HMAC token valid
for one hour.
Important
Enforcement is unconditional on every portal, and has been since 2026-08-12. There is no
per-portal staging, no securityVersion branch and no environment flag: a call to any of these
five endpoints without a valid token is a 401. Earlier versions of this page described a
per-portal fall-open — that is gone, and integrations written against it will break rather than
degrade.
Endpoint index#
30 endpoints — 9 public, 5 member, 13 admin, 3 server-to-server.
| Endpoint | Surface | Auth required |
|---|---|---|
GET /apps/events/license-status | Public | None |
POST /apps/events/create-registration | Public | Portal + licence |
POST /apps/events/event-stats | Public | Portal + licence |
POST /apps/events/fetch-event-details | Public | Portal + licence |
POST /apps/events/fetch-events | Public | Portal + licence |
POST /apps/events/payment-status | Public | Portal + licence |
POST /apps/events/resume-payment | Public | Portal + licence |
POST /apps/events/scan-session | Public | Portal + licence |
POST /apps/events/subscription-status | Public | Portal + licence |
POST /apps/events/check-in-by-qr | Member | Portal + contact session |
POST /apps/events/door-events | Member | Portal + contact session |
POST /apps/events/fetch-user-registrations | Member | Portal + contact session |
POST /apps/events/my-event-registration | Member | Portal + contact session |
POST /apps/events/update-registration | Member | Portal + contact session |
GET /apps/events/page/dashboard | Admin | Authenticated portal user, from a CRM card |
GET /apps/events/page/event-detail | Admin | Authenticated portal user, from a CRM card |
GET /apps/events/page/events-list | Admin | Authenticated portal user, from a CRM card |
GET /apps/events/page/payments | Admin | Authenticated portal user, from a CRM card |
POST /apps/events/admin/migrate-schema | Admin | Portal + shared secret |
POST /apps/events/admin/reconcile | Admin | Portal + shared secret |
POST /apps/events/admin/set-interop-config | Admin | Portal + shared secret |
POST /apps/events/admin/set-marketing-email | Admin | Portal + shared secret |
POST /apps/events/admin/set-payments-config | Admin | Portal + shared secret |
POST /apps/events/admin/set-subscription | Admin | Portal + shared secret |
POST /apps/events/admin/set-transactional-email | Admin | Portal + shared secret |
POST /apps/events/page/create-event | Admin | Authenticated portal user, from a CRM card |
POST /apps/events/page/mark-complimentary | Admin | Authenticated portal user, from a CRM card |
POST /apps/events/payment-callback | Server-to-server | Portal + shared secret |
POST /apps/events/promote-waitlist | Server-to-server | Portal + shared secret |
POST /apps/events/workflow/send-event-email | Server-to-server | HubSpot signature |
Endpoints marked Admin or Server-to-server are part of the app's operation, not its integration contract: they are listed for completeness and are not documented in detail below.
Custom object — events#
Created automatically on first install. The name property is unique and acts as the primary identifier across all API calls and navigation links. Object type name: events. The numeric objectTypeId is portal-specific and resolved by the backend at runtime — callers never need it.
| Property | Type | Description |
|---|---|---|
name | string · unique | Event name. Must be unique per portal. Used as the primary event identifier for URL routing and in fetch-event-details name lookups. Cannot be duplicated. |
url_slug | string | Legacy URL identifier. Also accepted by fetch-event-details as a lookup key (tried before name). By convention equals a slugified version of name. |
description | string · textarea | Full event description. Shown on the event detail page. |
meta_description | string | SEO meta description. Keep under 160 characters. Maps to the HubSpot dynamic page meta description field. |
highlights | string · textarea | Short bullet-style summary of event highlights. Shown as a callout on event detail pages. |
start_datetime | datetime | Event start date and time. ISO 8601 format (2026-06-15T09:00:00.000Z). Used as the sort key in fetch-events and the upcoming-only filter. |
end_datetime | datetime | Event end date and time. ISO 8601 format. |
event_type | string | Free-text event category. Common values: conference, webinar, workshop, meetup. |
event_status | enumeration | Controls visibility. Only published events are returned by fetch-events. Values: draft · published · cancelled |
event_tags | string | Free-text tags for filtering. Semicolon-separated by convention (e.g. training;certification;online). |
image_featured | string · URL | Full URL to the hero/featured image. Also maps to the HubSpot dynamic page featured image field. |
image_thumbnail | string · URL | Full URL to the event card thumbnail image. |
video_featured | string · URL | URL to a featured video (embed URL or direct link). Optional. |
is_online | boolean | Whether the event is online-only. Values: true · false |
location_name | string | Venue name or platform name for online events (e.g. Convention Center Amsterdam, Online via Zoom). |
street_address | string | Street address of the venue. Leave blank for online events. |
city | string | City. Leave blank for online events. |
postal_code | string | Postal/ZIP code. |
country | string | Country name in full (e.g. Netherlands, Belgium). |
location_map_type | string | Map provider hint (e.g. google). Optional. |
location_map_url | string · URL | Embed URL for an interactive map. |
location_image_map | string · URL | URL to a static map image. |
price_amount | number | Ticket price. Use 0 for free events. |
price_currency | string | ISO 4217 currency code (e.g. EUR, USD). |
payment_link | string · URL | External payment or booking URL (e.g. Stripe, Eventbrite). Optional. |
ticket_types | string · textarea | Available ticket type names, one per line or semicolon-separated (e.g. standard;vip;student). Used to populate the ticket type selector on the registration form. The ticket_type field on a registration should match one of these values. |
capacity_total | number | Maximum number of attendees. Set to 0 for unlimited. create-registration blocks new registrations when capacity_current >= capacity_total (and capacity_total > 0). |
capacity_current | number | Current registration count. Incremented automatically by create-registration. Starts at 0. |
features | string | Semicolon-separated list of feature/amenity codes shown as icons on event pages. Common values: wifi, parking, food_drinks, networking, certificate, recording, accessible. |
contact_email | string | Organiser contact email address. Shown on the event detail page. |
contact_phone | string | Organiser contact phone number. |
external_url | string · URL | Link to an external event page or additional information. Optional. |
registration_mode | string | How registrations are collected. Common value: form (inline registration form). Optional — used by the front-end to decide which registration UI to show. |
requires_membership | boolean | Whether the event is restricted to members only. Values: true · false |
meeting_platform | string | Online meeting platform name (e.g. Zoom, Microsoft Teams). Shown when is_online is true. |
online_meeting_url | string · URL | Direct join URL for the online meeting. Typically shared only after registration. |
associated_projects | string | Internal field — links the event to a HubSpot Projects record by numeric ID. Events with a value here are hidden when hideProjectEvents: true is passed to fetch-events. |
training_type | string | Optional classification for training events (e.g. certification, induction). Used by portal-specific filtering logic. |
Custom object — event_registrations#
One record per attendee per event. Created by create-registration. Each registration is associated to its event and (optionally) to a HubSpot contact.
| Property | Type | Description |
|---|---|---|
attendee_name | string | Full name (firstName + ' ' + lastName). Set automatically by create-registration. Can be edited via update-registration. |
first_name | string | Given name. |
last_name | string | Family name. |
email | string | Email address. Used to match or create a HubSpot contact during registration. Editable via update-registration. |
phone | string | Phone number. Editable via update-registration. |
company | string | Company name. Editable via update-registration. |
job_title | string | Job title. Editable via update-registration. |
registration_date | datetime | ISO 8601 timestamp of when the registration was created. Set automatically. |
registration_status | enumeration | Registration lifecycle status. Physical attendance is tracked separately in attendance_status — a checked-in attendee stays confirmed. Values: pending · confirmed · waitlisted · cancelled |
attendance_status | enumeration | Physical attendance, tracked separately from the registration lifecycle. Written by check-in-by-qr (sets checked_in). New registrations default to not_arrived. Values: not_arrived · checked_in · no_show |
ticket_type | string | Ticket type chosen at registration. Defaults to standard if not specified. Should match a value from the event's ticket_types property. |
qr_code | string | Unique QR check-in code generated at registration time. Format: EVT-XXXXXXXXXXXX (16 chars, uppercase alphanumeric). Used by check-in-by-qr. |
check_in_datetime | datetime | ISO 8601 timestamp of when the attendee was checked in. Set by check-in-by-qr when action: 'checkin' (alongside attendance_status: checked_in). |
payment_status | enumeration | Payment state. check-in-by-qr grants access when status is paid, free, or complimentary. All five are declared schema options. Values: pending · paid · free · complimentary · refunded |
amount_paid | number | Money that actually moved for this ticket, in the event's currency — not the ticket's list price. On a voucher-discounted order it is the ticket's share of what was charged, and on one a voucher covered in full it is 0. (Before 2026-08-10 it was written as the list price regardless, which over-reported revenue.) |
voucher_discount_amount | number | What a voucher covered for this ticket, reported beside amount_paid rather than folded into it: price_amount = amount_paid + voucher_discount_amount. Written as an explicit 0 when nothing was discounted, so "nothing was discounted" and "nobody has looked" stay distinguishable. |
special_requirements | string · textarea | Dietary, accessibility, or other special requirements. Shown on the event attendee list. |
purchaser_email | string | Email of the person who registered/paid. Equals the attendee's email for self-registration; differs for group registrations (one purchaser, several attendees). Mirrors the Purchaser association so emails can route to the buyer without resolving associations. |
purchaser_name | string | Name of the purchaser. See purchaser_email. |
Associations#
Five association labels are created automatically on install. Label names are stable contracts — the backend resolves portal-specific numeric type IDs at runtime by matching on the label name. Do not rename these labels in HubSpot.
| From | To | Label | Inverse label |
|---|---|---|---|
events | event_registrations | Event Registrations | Event |
contacts | event_registrations | Registered Attendee | Event Registration |
contacts | event_registrations | Purchaser | Purchased Registration |
contacts | events | Registered | Registered Contact |
projects | events | Project Event | Project |
Note
create-registration creates the event→registration, Registered Attendee, Purchaser, and contact→event links automatically. The attendee and purchaser links point to the same contact for self-registration, or to different contacts for group registrations. (Project Event links events to HubSpot Projects and is managed separately.)
Endpoints#
GET /apps/events/license-status#
Check whether a portal has an active Events license. Does not require OAuth install — safe to call before the install flow.
Query parameters
| Parameter | Type | Description |
|---|---|---|
portalId (required) | string | HubSpot portal ID. |
Response
{ "licensed": true }
POST /apps/events/fetch-events#
Return a list of published events, sorted by start_datetime ascending. By default returns only upcoming events (start date in the future).
Request body
| Field | Type | Description |
|---|---|---|
portalId (required) | string | HubSpot portal ID. |
timeframe | string | upcoming (default) · past · all. past also flips the sort to most-recent-first. Prefer this over upcomingOnly. |
upcomingOnly | boolean | Legacy form of timeframe, kept for older callers: true (default) → upcoming, false → all. Ignored when timeframe is present. |
hideProjectEvents | boolean | If true, exclude events that have a value in associated_projects (used to hide internal project-linked events from public listings). Default: false. |
The endpoint pages through HubSpot results for you (100 per page, up to 10 pages) and returns the full set in one response — there is no cursor to follow.
Response
{
"success": true,
"total": 3,
"events": [
{
"id": "12345678",
"name": "Monthly Member Webinar",
"url_slug": "monthly-member-webinar",
"start_datetime": "2026-06-15T14:00:00.000Z",
"end_datetime": "2026-06-15T16:00:00.000Z",
"event_type": "webinar",
"event_status": "published",
"event_tags": "online;members",
"is_online": "true",
"location_name": "Online via Microsoft Teams",
"city": null,
"country": null,
"price_amount": "0",
"price_currency": "EUR",
"capacity_total": "500",
"capacity_current": "12",
"image_featured": "https://images.unsplash.com/...",
"image_thumbnail": "https://images.unsplash.com/..."
}
]
}
Note
Each event in the array contains all events object properties. Use id when you need to reference this event in other HubSpot CRM API calls; use name when calling fetch-event-details.
POST /apps/events/fetch-event-details#
Fetch full details for a single event. Looks up the event by url_slug, then name (exact match), then name (partial match) — the first match wins.
This is the public event page read, so the event body is visible to anonymous callers. The registrations array is not: it is scoped to the caller's own proven session contact.
Warning
registrations does not return the attendee roster. It contains only the registrations belonging to the contact proven by the session token, and is [] for an anonymous caller. Building an attendee list from this endpoint will silently return nothing. For a member's own ticket use my-event-registration; for organiser figures use event-stats, which returns counts and no PII.
Request body
| Field | Type | Description |
|---|---|---|
portalId (required) | string | HubSpot portal ID. |
eventId (required) | string | Event identifier — can be the url_slug, the exact name, or a partial name. The backend tries each lookup in order until a match is found. |
isNameLookup | boolean | Pass true when eventId is a decoded event name from a URL path segment. This skips the url_slug lookup and saves one HubSpot API call. Default: false. |
contactId | string | Accepted but not used for scoping. Which registrations come back is decided by the session token, never by this field. |
Response
{
"success": true,
"event": {
"id": "12345678",
"name": "Monthly Member Webinar",
"description": "Our regular online gathering...",
"start_datetime": "2026-06-15T14:00:00.000Z",
"capacity_total": "500",
"capacity_current": "12",
"...": "...all event properties..."
},
"registrations": [
{
"id": "98765432",
"attendee_name": "Sarah Johnson",
"email": "sarah.johnson@example.com",
"registration_status": "confirmed",
"payment_status": "paid",
"qr_code": "EVT-ABC123DEF456",
"check_in_datetime": null,
"...": "...all registration properties..."
}
]
}
Above, registrations holds the session contact's own registration. An anonymous caller gets the same event object and "registrations": [].
POST /apps/events/scan-session#
Mint the contact-session token the member-scoped endpoints require. The caller is already an authenticated CMS member; this verifies their email against the CRM contact before issuing.
Request body
| Field | Type | Description |
|---|---|---|
portalId (required) | string | HubSpot portal ID. |
contactId (required) | string | HubSpot contact ID of the signed-in member. |
contactEmail (required) | string | Their email. It must match the contact's CRM email, or the call returns 403 Identity verification failed. Omitting it is also a 403 — naming a contact is not proof of being one. |
Every refusal returns the same 403 Identity verification failed, whether the email was absent,
wrong, or the contact id does not exist. That is deliberate: a distinguishable response would let a
caller discover which contact ids are real.
Important
What a successful mint proves is that the caller knows the contact's e-mail address — it is
shared knowledge, not a session. HubSpot attaches no signed identity to a CMS-page request,
so this is the strongest check available for the surfaces that call this route. (App-page
hubspot.fetch() requests ARE signed — see backend/app-security-model.md § "REVERSED
2026-08-12" — but this mint serves the CMS scanner/embed, which that mechanism does not cover.)
Scope what you put behind the token accordingly: one contact's own records, not a roster.
Response
{
"success": true,
"sessionToken": "eyJwb3J0YWxJZCI6...",
"sessionExpiry": 1786351200000,
"keyringScannerUrl": null
}
The token is valid for one hour; sessionExpiry is epoch milliseconds. keyringScannerUrl is the configured hand-off target for the staff scanner, or null when no sibling app is configured.
Note
A portal with no contact secret provisioned returns "sessionToken": null and "success": true. Every install provisions one, so this should not occur — and there is no longer a permissive path behind it. The member-scoped endpoints always require a valid token, so a caller without one gets 401 rather than legacy behaviour. Treat a null token as "this member cannot be identified" and fall back to the anonymous experience.
POST /apps/events/my-event-registration#
The signed-in member's own ticket(s) for one event, plus the online join link. This is the endpoint to use for "my ticket" surfaces — never fetch-event-details.
Request body
| Field | Type | Description |
|---|---|---|
portalId (required) | string | HubSpot portal ID. |
eventId (required) | string | Numeric event ID, url_slug, or exact name. |
Response
{
"success": true,
"registrations": [
{
"id": "98765432",
"attendee_name": "Sarah Johnson",
"email": "sarah.johnson@example.com",
"registration_status": "confirmed",
"attendance_status": "not_arrived",
"ticket_type": "standard",
"qr_code": "EVT-ABC123DEF456",
"payment_status": "paid"
}
],
"onlineMeetingUrl": "https://teams.microsoft.com/l/meetup-join/..."
}
onlineMeetingUrl is returned only when the member holds a non-cancelled ticket; otherwise null. Registrations are matched by the proven contact's email, so a member with no ticket gets [] rather than an error.
POST /apps/events/event-stats#
Aggregate figures for one event. Counts only — no attendee PII, which is why it is safe for a public capacity bar as well as the organiser dashboard.
Request body
| Field | Type | Description |
|---|---|---|
portalId (required) | string | HubSpot portal ID. |
eventId (required) | string | Numeric event ID, url_slug, or exact name. |
Response
{
"success": true,
"stats": {
"eventName": "Annual Member Conference",
"capacityTotal": 500,
"confirmed": 312,
"waitlisted": 18,
"pending": 4,
"cancelled": 9,
"total": 343,
"active": 334,
"checkedIn": 287,
"noShow": 2,
"notArrived": 25,
"spotsRemaining": 188,
"checkInRate": 92,
"byEventTicketType": { "standard": 280, "vip": 54 },
"byPaymentStatus": { "paid": 300, "free": 34 },
"revenue": { "amount": 18750.00, "currency": "EUR" },
"registrationsByDay": [{ "date": "2026-06-01", "count": 12 }],
"arrivalsByHour": [{ "time": "2026-06-15T09:00", "count": 63 }]
}
}
spotsRemaining is null for an uncapped event (capacity_total of 0). checkInRate is a whole-number percentage of confirmed. notArrived is confirmed - checkedIn, floored at zero.
POST /apps/events/fetch-user-registrations#
Find all event IDs that a specific contact is registered for. Matches by contact email address via the event_registrations object.
Request body
| Field | Type | Description |
|---|---|---|
portalId (required) | string | HubSpot portal ID. |
contactId (required) | string | HubSpot contact ID. The backend fetches the contact's email and uses it to search registrations. |
Response
{
"success": true,
"eventIds": ["12345678", "87654321"]
}
Note
Returns an array of event IDs (not names or slugs). Cross-reference these with the id field returned by fetch-events to determine which events a contact has already registered for.
POST /apps/events/create-registration#
Register an attendee for an event. Creates the event_registrations record, creates or updates the HubSpot contact, and creates all three association links (event → registration, contact → registration, contact → event). Increments capacity_current on the event.
Request body
| Field | Type | Description |
|---|---|---|
portalId (required) | string | HubSpot portal ID. |
eventId (required) | string | Event identifier — numeric ID from fetch-events, or a url_slug string. Non-numeric values trigger a slug lookup first. |
formData (required) | object | Attendee details. See sub-fields below. |
contactId | string | Optional HubSpot contact ID. If provided, the contact is updated with the form data rather than searched by email. If omitted, the backend searches by email and creates the contact if not found. |
formData fields
| Field | Type | Description |
|---|---|---|
firstName (required) | string | Attendee first name. |
lastName (required) | string | Attendee last name. |
email (required) | string | Attendee email address. Used to find or create the HubSpot contact. |
phone | string | Phone number. |
company | string | Company name. |
jobTitle | string | Job title. |
ticketType | string | Ticket type. Defaults to standard if not provided. Should match a value from the event's ticket_types field. |
specialRequirements | string | Dietary, accessibility, or other special requirements. |
Response
{
"success": true,
"registration": {
"id": "98765432",
"qr_code": "EVT-ABC123DEF456",
"email": "sarah.johnson@example.com"
}
}
Important
Capacity check: if capacity_total > 0 and capacity_current >= capacity_total, the registration is rejected with 400 { "error": "Event is at full capacity" }. Check capacity before attempting to register.
Note
Group registration: include an optional attendees array (each item carries the same fields as formData) to register several people under one purchaser in a single call. Each registration stores purchaser_email/purchaser_name and gets both a Registered Attendee link and (for the purchaser contact) a Purchaser link. Omit attendees for ordinary self-registration — the single formData attendee is also the purchaser.
POST /apps/events/update-registration#
Update a single editable property on an existing registration. Used for inline attendee detail corrections.
Request body
| Field | Type | Description |
|---|---|---|
portalId (required) | string | HubSpot portal ID. |
registrationId (required) | string | Numeric ID of the event_registrations record. |
propertyName (required) | string | Property to update. Only these five values are accepted: attendee_name · job_title · company · email · phone |
propertyValue | string | New value. Pass an empty string to clear the field. |
Response
{ "success": true }
POST /apps/events/check-in-by-qr#
Look up a registration by QR code and optionally check the attendee in. Returns an access decision that drives the check-in UI: grant entry, deny, flag as already checked in, or report an error.
Requires a contact session — mint one with scan-session. Without it the endpoint would hand attendee PII to anyone holding a portal ID and a QR string.
Request body
| Field | Type | Description |
|---|---|---|
portalId (required) | string | HubSpot portal ID. |
qrCode (required) | string | QR code value from the registration. Format: EVT-XXXXXXXXXXXX. |
action | string | Pass "checkin" to set attendance_status: checked_in and record check_in_datetime (only on a GRANT). Omit (or any other value) for a read-only verification that doesn't mutate the record. |
eventId | string | The event this door is staffing. When present, a ticket belonging to any other event returns WRONG_EVENT and is never checked in. Omit to accept a ticket for any event on the portal. |
Access decisions
| Decision | Meaning | When |
|---|---|---|
WRONG_EVENT | Right ticket, wrong door | eventId was supplied and the ticket belongs to a different event — evaluated first, ahead of ALREADY_IN, so a ticket already scanned in elsewhere can't render as an amber "already checked in" |
ALREADY_IN | Already checked in | attendance_status is checked_in (or check_in_datetime is set) |
GRANT | Entry permitted | Not yet checked in, registration_status is confirmed, AND payment_status is paid, free, or complimentary |
DENY | Entry refused | Not yet checked in, but registration isn't confirmed (e.g. pending/waitlisted/cancelled) or payment isn't settled |
ERROR | QR code not found | No registration matches the provided QR code (404 response) |
A registration that carries no event association is never refused on scope grounds — that's a data defect on the portal, not a reason to turn an attendee away. The decision proceeds as if unscoped and an EVENT_UNKNOWN warning is returned instead.
Warnings
warnings is advisory and never changes the decision. Each entry carries a code and a server-authored message; render the message verbatim for any code you don't recognise, since new codes can appear before a scanner UI is updated.
| Code | Meaning |
|---|---|
EARLY | Scanned before the event's doors-open time (start minus the early grace) |
LATE | Scanned after the event's end plus the late grace. When the event has no end_datetime, an assumed duration is used and the message says so |
EVENT_UNKNOWN | The door was scoped but the ticket's event couldn't be resolved, so the scope check did not happen |
PAYMENT_ATTENTION | The decision is ALREADY_IN but the ticket isn't settled — e.g. a chargeback suspended it after it first scanned in. The decision stays ALREADY_IN on purpose: a legitimately checked-in attendee must not become a DENY on re-scan |
On a DENY, denyReason carries { code, message } — REGISTRATION_CANCELLED, ON_WAITLIST, REGISTRATION_PENDING, PAYMENT_PENDING, PAYMENT_REFUNDED, PAYMENT_SUSPENDED, or NOT_GRANTED. It is null for every other decision. Same rule as warnings: render your own copy for codes you know, the server's message for ones you don't.
Time is only ever a warning — entry is never refused on the clock alone. The grace either side is operator-tunable per deployment.
Response
{
"success": true,
"accessDecision": "GRANT",
"checkedIn": true,
"warnings": [
{ "code": "LATE", "minutes": 300, "message": "This event ended 5 h ago." }
],
"doorEventId": "551",
"registration": {
"id": "98765432",
"attendeeName": "Sarah Johnson",
"firstName": "Sarah",
"lastName": "Johnson",
"email": "sarah.johnson@example.com",
"company": "Acme Corp",
"jobTitle": "Product Manager",
"eventTicketType": "standard",
"registrationStatus": "confirmed",
"paymentStatus": "paid",
"attendanceStatus": "checked_in",
"checkInDatetime": "2026-06-15T09:32:00.000Z",
"specialRequirements": null
},
"event": {
"id": "551",
"name": "Annual Member Conference",
"startDatetime": "2026-06-15T09:00:00.000Z",
"endDatetime": "2026-06-15T17:00:00.000Z"
}
}
Note
checkedIn is true only when action: "checkin" was passed and the decision was GRANT. On DENY, ALREADY_IN or WRONG_EVENT, the record is not modified regardless of the action.
Tip
On a WRONG_EVENT, event describes the event the ticket belongs to, while doorEventId echoes the event the door is staffing — enough to tell someone "this is for Saturday" without a second lookup.
POST /apps/events/door-events#
The short list of events a door might be staffing: published events starting within the last 48 hours or the next 30 days, oldest first. Feeds the scanner's "which event are you staffing?" picker, whose selection becomes eventId on check-in-by-qr.
Requires the same contact session as check-in-by-qr. The payload is published event names and times — the same data fetch-events serves publicly — and carries no attendee information.
Request body
| Field | Type | Description |
|---|---|---|
portalId (required) | string | HubSpot portal ID. |
Response
{
"success": true,
"events": [
{
"id": "551",
"name": "Annual Member Conference",
"startDatetime": "2026-06-15T09:00:00.000Z",
"endDatetime": "2026-06-15T17:00:00.000Z",
"locationName": "Main Hall",
"live": true
}
]
}
live is true when now falls inside the door window (start minus the early grace, to end plus the late grace) — the same window that produces the EARLY/LATE warnings, so a scanner's auto-selection and the warning it later shows can never disagree about what "now" means. An event with no start_datetime is never live.
Creating and managing events#
Events are created and edited directly via the HubSpot CRM API — this worksby.design API does not expose a create/update event endpoint. Use the standard HubSpot /crm/v3/objects/{objectTypeId} endpoints with the events custom object.
Resolving the objectTypeId#
HubSpot assigns a different numeric objectTypeId to the events custom object on each portal. To resolve it, fetch all schemas and find the one whose name field equals "events":
GET https://api.hubapi.com/crm/v3/schemas
Authorization: Bearer {access_token}
// In the response, find:
results.find(s => s.name === 'events').objectTypeId
// → e.g. "2-12345678"
Creating an event#
Once you have the objectTypeId, create an event with a POST to /crm/v3/objects/{objectTypeId}:
POST https://api.hubapi.com/crm/v3/objects/{objectTypeId}
Authorization: Bearer {access_token}
Content-Type: application/json
{
"properties": {
"name": "Monthly Member Webinar",
"url_slug": "monthly-member-webinar",
"description": "Our regular online gathering for members.",
"meta_description": "Free monthly online webinar — live panel and open Q&A.",
"highlights": "Live panel, member updates, exclusive content.",
"event_type": "webinar",
"event_status": "published",
"start_datetime": "2026-07-15T14:00:00.000Z",
"end_datetime": "2026-07-15T16:00:00.000Z",
"is_online": "true",
"location_name": "Online via Microsoft Teams",
"price_amount": "0",
"price_currency": "EUR",
"capacity_total": "500",
"capacity_current": "0",
"features": "recording",
"registration_mode": "form",
"image_featured": "https://images.unsplash.com/photo-...",
"image_thumbnail": "https://images.unsplash.com/photo-..."
}
}
Required properties for a visible event#
At minimum, set these properties for an event to appear in fetch-events results:
name— must be unique; this is the event identifierevent_status: "published"— draft and cancelled events are excludedstart_datetime— must be in the future (unlessupcomingOnly: false)
Updating an event#
PATCH https://api.hubapi.com/crm/v3/objects/{objectTypeId}/{eventId}
Authorization: Bearer {access_token}
Content-Type: application/json
{
"properties": {
"event_status": "cancelled"
}
}
Warning
Name uniqueness: the name property has a unique constraint. Attempting to create two events with the same name on the same portal will fail with a HubSpot 409 conflict error. If you need test events, use distinct names.
Writing registrations directly (sample data)#
To seed demo attendees, create event_registrations records directly via the CRM API rather than calling create-registration (which also matches contacts, enforces capacity, and may send email). Resolve the event_registrations objectTypeId the same way as for events.
POST https://api.hubapi.com/crm/v3/objects/{registrationsObjectTypeId}
Authorization: Bearer {access_token}
Content-Type: application/json
{
"properties": {
"attendee_name": "Sarah Johnson",
"first_name": "Sarah",
"last_name": "Johnson",
"email": "sarah.johnson@example.com",
"registration_date": "2026-06-01T10:00:00.000Z",
"registration_status": "confirmed",
"attendance_status": "not_arrived",
"payment_status": "free",
"ticket_type": "standard",
"qr_code": "EVT-ABC123DEF456",
"purchaser_email": "sarah.johnson@example.com",
"purchaser_name": "Sarah Johnson"
}
}
Then link the record. Resolve each numeric association type ID by label via GET /crm/v4/associations/{from}/{to}/labels, then create with batch/create. Use the field name from (not _from) or the link is silently dropped:
events → event_registrations· labelEvent Registrationscontacts → event_registrations· labelRegistered Attendee(the attendee)contacts → event_registrations· labelPurchaser(the buyer — same contact as the attendee for self-registration)contacts → events· labelRegistered
Tip
QR codes: use a unique value per registration shaped as EVT- + 12 uppercase alphanumerics. Capacity: the app derives the live attendee count from non-cancelled registrations, so you needn't keep capacity_current exact — but set capacity_total on the event for display and the full-capacity check.
Integration seams#
Events is one of three of our apps that share a portal, and each seam is deliberately thin — a documented contract, never shared code.
| With | What crosses | Where it is documented |
|---|---|---|
| Commerce | Paid tickets. Events creates an Order (externalSource: "events", externalRef = the registration ids), hands the buyer to Commerce's checkout, and issues tickets only when the signed completion callback says the money moved. The payment-callback, payment-status and admin/set-payments-config routes in the index above are this seam. | commerce-reference.md § The payment contract |
| Keyring | Door scanning. A venue running Keyring's staff scanner can hand a scanned EVT-… ticket code straight to the Events scanner instead of failing it, so one device handles both loyalty cards and event tickets. One-directional and off by default. | keyring-reference.md § Integration seams |
Neither seam is required. Events runs perfectly well as the only one of the three on a portal; free events need no Commerce, and door staff can use the Events scanner directly.