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.

KindPublic HubSpot app (OAuth), installed per portal
SurfacesWebsite embed (registration + ticket) · in-CRM organiser app page · QR scanner page for door staff · "Send Event Email" workflow action
Stores data inTwo custom objects on your own portal — events and event_registrations
Paid ticketsOptional, through the Commerce payment platform — see Integration seams
How to get itNot 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-eventsfetch-event-detailscreate-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.

PolicyWhat the caller must presentUsed for
NoneportalId onlylicense-status, callable before install
Portal + licenceportalId, and the portal must have completed OAuth install with an active licencePublic reads and registration
Portal + contact sessionThe above plus a session token proving a specific contactIdAnything returning or mutating a specific member's data
Portal + shared secretThe above plus a configured secretSibling-app configuration and payment callbacks
HubSpot signatureA 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.

EndpointSurfaceAuth required
GET /apps/events/license-statusPublicNone
POST /apps/events/create-registrationPublicPortal + licence
POST /apps/events/event-statsPublicPortal + licence
POST /apps/events/fetch-event-detailsPublicPortal + licence
POST /apps/events/fetch-eventsPublicPortal + licence
POST /apps/events/payment-statusPublicPortal + licence
POST /apps/events/resume-paymentPublicPortal + licence
POST /apps/events/scan-sessionPublicPortal + licence
POST /apps/events/subscription-statusPublicPortal + licence
POST /apps/events/check-in-by-qrMemberPortal + contact session
POST /apps/events/door-eventsMemberPortal + contact session
POST /apps/events/fetch-user-registrationsMemberPortal + contact session
POST /apps/events/my-event-registrationMemberPortal + contact session
POST /apps/events/update-registrationMemberPortal + contact session
GET /apps/events/page/dashboardAdminAuthenticated portal user, from a CRM card
GET /apps/events/page/event-detailAdminAuthenticated portal user, from a CRM card
GET /apps/events/page/events-listAdminAuthenticated portal user, from a CRM card
GET /apps/events/page/paymentsAdminAuthenticated portal user, from a CRM card
POST /apps/events/admin/migrate-schemaAdminPortal + shared secret
POST /apps/events/admin/reconcileAdminPortal + shared secret
POST /apps/events/admin/set-interop-configAdminPortal + shared secret
POST /apps/events/admin/set-marketing-emailAdminPortal + shared secret
POST /apps/events/admin/set-payments-configAdminPortal + shared secret
POST /apps/events/admin/set-subscriptionAdminPortal + shared secret
POST /apps/events/admin/set-transactional-emailAdminPortal + shared secret
POST /apps/events/page/create-eventAdminAuthenticated portal user, from a CRM card
POST /apps/events/page/mark-complimentaryAdminAuthenticated portal user, from a CRM card
POST /apps/events/payment-callbackServer-to-serverPortal + shared secret
POST /apps/events/promote-waitlistServer-to-serverPortal + shared secret
POST /apps/events/workflow/send-event-emailServer-to-serverHubSpot 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.

PropertyTypeDescription
namestring · uniqueEvent 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_slugstringLegacy URL identifier. Also accepted by fetch-event-details as a lookup key (tried before name). By convention equals a slugified version of name.
descriptionstring · textareaFull event description. Shown on the event detail page.
meta_descriptionstringSEO meta description. Keep under 160 characters. Maps to the HubSpot dynamic page meta description field.
highlightsstring · textareaShort bullet-style summary of event highlights. Shown as a callout on event detail pages.
start_datetimedatetimeEvent 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_datetimedatetimeEvent end date and time. ISO 8601 format.
event_typestringFree-text event category. Common values: conference, webinar, workshop, meetup.
event_statusenumerationControls visibility. Only published events are returned by fetch-events. Values: draft · published · cancelled
event_tagsstringFree-text tags for filtering. Semicolon-separated by convention (e.g. training;certification;online).
image_featuredstring · URLFull URL to the hero/featured image. Also maps to the HubSpot dynamic page featured image field.
image_thumbnailstring · URLFull URL to the event card thumbnail image.
video_featuredstring · URLURL to a featured video (embed URL or direct link). Optional.
is_onlinebooleanWhether the event is online-only. Values: true · false
location_namestringVenue name or platform name for online events (e.g. Convention Center Amsterdam, Online via Zoom).
street_addressstringStreet address of the venue. Leave blank for online events.
citystringCity. Leave blank for online events.
postal_codestringPostal/ZIP code.
countrystringCountry name in full (e.g. Netherlands, Belgium).
location_map_typestringMap provider hint (e.g. google). Optional.
location_map_urlstring · URLEmbed URL for an interactive map.
location_image_mapstring · URLURL to a static map image.
price_amountnumberTicket price. Use 0 for free events.
price_currencystringISO 4217 currency code (e.g. EUR, USD).
payment_linkstring · URLExternal payment or booking URL (e.g. Stripe, Eventbrite). Optional.
ticket_typesstring · textareaAvailable 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_totalnumberMaximum number of attendees. Set to 0 for unlimited. create-registration blocks new registrations when capacity_current >= capacity_total (and capacity_total > 0).
capacity_currentnumberCurrent registration count. Incremented automatically by create-registration. Starts at 0.
featuresstringSemicolon-separated list of feature/amenity codes shown as icons on event pages. Common values: wifi, parking, food_drinks, networking, certificate, recording, accessible.
contact_emailstringOrganiser contact email address. Shown on the event detail page.
contact_phonestringOrganiser contact phone number.
external_urlstring · URLLink to an external event page or additional information. Optional.
registration_modestringHow registrations are collected. Common value: form (inline registration form). Optional — used by the front-end to decide which registration UI to show.
requires_membershipbooleanWhether the event is restricted to members only. Values: true · false
meeting_platformstringOnline meeting platform name (e.g. Zoom, Microsoft Teams). Shown when is_online is true.
online_meeting_urlstring · URLDirect join URL for the online meeting. Typically shared only after registration.
associated_projectsstringInternal 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_typestringOptional 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.

PropertyTypeDescription
attendee_namestringFull name (firstName + ' ' + lastName). Set automatically by create-registration. Can be edited via update-registration.
first_namestringGiven name.
last_namestringFamily name.
emailstringEmail address. Used to match or create a HubSpot contact during registration. Editable via update-registration.
phonestringPhone number. Editable via update-registration.
companystringCompany name. Editable via update-registration.
job_titlestringJob title. Editable via update-registration.
registration_datedatetimeISO 8601 timestamp of when the registration was created. Set automatically.
registration_statusenumerationRegistration lifecycle status. Physical attendance is tracked separately in attendance_status — a checked-in attendee stays confirmed. Values: pending · confirmed · waitlisted · cancelled
attendance_statusenumerationPhysical 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_typestringTicket type chosen at registration. Defaults to standard if not specified. Should match a value from the event's ticket_types property.
qr_codestringUnique QR check-in code generated at registration time. Format: EVT-XXXXXXXXXXXX (16 chars, uppercase alphanumeric). Used by check-in-by-qr.
check_in_datetimedatetimeISO 8601 timestamp of when the attendee was checked in. Set by check-in-by-qr when action: 'checkin' (alongside attendance_status: checked_in).
payment_statusenumerationPayment 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_paidnumberMoney 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_amountnumberWhat 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_requirementsstring · textareaDietary, accessibility, or other special requirements. Shown on the event attendee list.
purchaser_emailstringEmail 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_namestringName 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.

FromToLabelInverse label
eventsevent_registrationsEvent RegistrationsEvent
contactsevent_registrationsRegistered AttendeeEvent Registration
contactsevent_registrationsPurchaserPurchased Registration
contactseventsRegisteredRegistered Contact
projectseventsProject EventProject

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

ParameterTypeDescription
portalId (required)stringHubSpot 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

FieldTypeDescription
portalId (required)stringHubSpot portal ID.
timeframestringupcoming (default) · past · all. past also flips the sort to most-recent-first. Prefer this over upcomingOnly.
upcomingOnlybooleanLegacy form of timeframe, kept for older callers: true (default) → upcoming, falseall. Ignored when timeframe is present.
hideProjectEventsbooleanIf 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

FieldTypeDescription
portalId (required)stringHubSpot portal ID.
eventId (required)stringEvent 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.
isNameLookupbooleanPass 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.
contactIdstringAccepted 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

FieldTypeDescription
portalId (required)stringHubSpot portal ID.
contactId (required)stringHubSpot contact ID of the signed-in member.
contactEmail (required)stringTheir 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

FieldTypeDescription
portalId (required)stringHubSpot portal ID.
eventId (required)stringNumeric 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

FieldTypeDescription
portalId (required)stringHubSpot portal ID.
eventId (required)stringNumeric 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

FieldTypeDescription
portalId (required)stringHubSpot portal ID.
contactId (required)stringHubSpot 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

FieldTypeDescription
portalId (required)stringHubSpot portal ID.
eventId (required)stringEvent identifier — numeric ID from fetch-events, or a url_slug string. Non-numeric values trigger a slug lookup first.
formData (required)objectAttendee details. See sub-fields below.
contactIdstringOptional 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

FieldTypeDescription
firstName (required)stringAttendee first name.
lastName (required)stringAttendee last name.
email (required)stringAttendee email address. Used to find or create the HubSpot contact.
phonestringPhone number.
companystringCompany name.
jobTitlestringJob title.
ticketTypestringTicket type. Defaults to standard if not provided. Should match a value from the event's ticket_types field.
specialRequirementsstringDietary, 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

FieldTypeDescription
portalId (required)stringHubSpot portal ID.
registrationId (required)stringNumeric ID of the event_registrations record.
propertyName (required)stringProperty to update. Only these five values are accepted: attendee_name · job_title · company · email · phone
propertyValuestringNew 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

FieldTypeDescription
portalId (required)stringHubSpot portal ID.
qrCode (required)stringQR code value from the registration. Format: EVT-XXXXXXXXXXXX.
actionstringPass "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.
eventIdstringThe 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

DecisionMeaningWhen
WRONG_EVENTRight ticket, wrong dooreventId 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_INAlready checked inattendance_status is checked_in (or check_in_datetime is set)
GRANTEntry permittedNot yet checked in, registration_status is confirmed, AND payment_status is paid, free, or complimentary
DENYEntry refusedNot yet checked in, but registration isn't confirmed (e.g. pending/waitlisted/cancelled) or payment isn't settled
ERRORQR code not foundNo 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.

CodeMeaning
EARLYScanned before the event's doors-open time (start minus the early grace)
LATEScanned 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_UNKNOWNThe door was scoped but the ticket's event couldn't be resolved, so the scope check did not happen
PAYMENT_ATTENTIONThe 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

FieldTypeDescription
portalId (required)stringHubSpot 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 identifier
  • event_status: "published" — draft and cancelled events are excluded
  • start_datetime — must be in the future (unless upcomingOnly: 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 · label Event Registrations
  • contacts → event_registrations · label Registered Attendee (the attendee)
  • contacts → event_registrations · label Purchaser (the buyer — same contact as the attendee for self-registration)
  • contacts → events · label Registered

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.

WithWhat crossesWhere it is documented
CommercePaid 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
KeyringDoor 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.