
# Events app — reference

> Load this file when: integrating with, seeding, evaluating or troubleshooting the **worksby.design Events app** — what it does, its `events` / `event_registrations` custom objects, the label-resolved associations, and the `api.worksby.design/apps/events/` endpoints for listing events, registering attendees, and QR check-in.

> 🔒 **Works by Design system — not a HubSpot platform feature.** The endpoints here live on `api.worksby.design` and work only for portals with the Events app installed. Portable regardless: the custom-object schema shape, resolving association type IDs by label, and the QR check-in decision model. What we share vs. what we can only demo: [projects-catalog.md](projects-catalog.md)

## 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](#integration-seams) |
| **How to get it** | Not self-service. Ask for an install: [w@reus.ie](mailto: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](#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](#contact-sessions--read-this-before-integrating); 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](#creating-and-managing-events).

## Authentication

There are **five** auth policies, not one. Which applies to a given endpoint is listed in
[Endpoint index](#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`](#post-appseventsscan-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

<!-- routes:events — GENERATED from routes/apps/events/policy-manifest.js by sync-routes.mjs. Do not edit by hand. -->

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 |

<!-- /routes:events -->

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**

```json
{ "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**

```json
{
  "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`](#post-appseventsmy-event-registration); for organiser figures use [`event-stats`](#post-appseventsevent-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**

```json
{
  "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**

```json
{
  "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**

```json
{
  "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**

```json
{
  "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**

```json
{
  "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**

```json
{
  "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**

```json
{ "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`](#post-appseventsscan-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**

```json
{
  "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`](#post-appseventscheck-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`](#post-appseventsfetch-events) serves publicly — and carries no attendee information.

**Request body**

| Field | Type | Description |
|---|---|---|
| `portalId` **(required)** | string | HubSpot portal ID. |

**Response**

```json
{
  "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"`:

```text
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}`:

```text
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

```text
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.

```text
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.

| 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](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](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.
