Events
Registration & Public Pages

Registration & Public Pages

Gordon CRM provides two public-facing surfaces for events: a hosted registration page for manual events and a headless JSON API for powering event listings on external websites.

Public Registration Page

Every manual event has a shareable registration page at:

https://your-domain.com/rsvp/[eventId]

Page Rendering

The registration page is a server-rendered Next.js page that queries the event by UUID. The page decides what to render based on four conditions:

ConditionBehavior
Event not foundShows "Registration Unavailable"
is_published = falseShows "Registration Unavailable"
Event has endedShows "Registration Unavailable"
external_event_id is set (Eventbrite)Shows "Registration Unavailable" — Eventbrite events use their own registration flow

If all checks pass, the page renders:

  1. Event Header — Displays the website_title (falls back to name), formatted date/time using the event's timezone, and location.
  2. Description — Renders the website_description (falls back to description) as HTML with a collapsible "Read more" toggle for long content.
  3. Registration Form — First name, last name, and email with honeypot spam protection.

Registration Form

The form collects three fields — first name, last name, and email — and submits to the RSVP API endpoint.

Honeypot Spam Protection

The registration form uses the same honeypot pattern as headless forms: a hidden website field that is invisible to real users but gets filled by bots. If the field contains any value, the API silently returns a 200 response without processing the submission.

Submission Flow

User submits form → POST /api/rsvp/[eventId]
  → Honeypot check
  → Validate required fields (email, first name, last name)
  → Validate event (published, not past, not Eventbrite)
  → Upsert contact
  → Create or reactivate registration
  → Fire automation triggers
  → Return success

Contact Upsert Logic

The RSVP endpoint uses a non-destructive upsert strategy that differs based on whether the contact already exists:

New Contact

A new contact record is inserted with:

FieldValue
sourceevent
is_subscribedtrue
opt_in_timestampCurrent timestamp
opt_in_sourceevent_[eventId]
opt_in_ipExtracted from x-forwarded-for header

Existing Contact

For existing contacts, only consent fields are updated — the endpoint never overwrites name fields to prevent data loss from casual re-registrations:

Updated FieldsPurpose
is_subscribed → trueRe-subscribes the contact
opt_in_timestampRecords fresh consent
opt_in_sourceevent_[eventId]
opt_in_ipIP at time of registration
unsubscribed_at → nullClears previous unsubscribe

Additionally, any non-complaint contact suppressions (bounce, unsubscribe, manual) are cleared, since the contact is actively re-engaging.

Registration Deduplication

When a contact submits the registration form:

  1. First registration — A new event_registrations row is inserted with status = registered.
  2. Already registered — Returns a 400 error: "You are already registered for this event."
  3. Previously cancelled — The existing row's status is flipped back to registered (re-registration).

Automation Trigger

After a successful registration (or re-registration), the endpoint fires the event_registration automation trigger. See Events Overview → Automation Integration for the full parameter specification.

Note: The automation fires in a try/catch block — if the automation engine throws, the registration itself is already saved. This ensures registrations are never lost due to automation failures.


Headless Events API

Gordon CRM exposes a public JSON API that returns all published, upcoming events for a workspace. External websites can use this feed to display event listings, calendars, or registration CTAs without building a custom CMS.

Endpoint

GET /api/v1/public/events

Authentication

The API requires a Public API Key, provided via either:

  • Authorization: Bearer <key> header (recommended)
  • ?key=<key> query parameter (convenience for static sites)

Response Schema

{
  "status": "success",
  "data": [
    {
      "id": "uuid",
      "type": "manual" | "eventbrite",
      "title": "Spring Gala 2026",
      "description": "Join us for an evening of...",
      "start_date": "2026-05-01T23:00:00.000Z",
      "end_date": "2026-05-02T03:00:00.000Z",
      "timezone": "America/Chicago",
      "display_date": "May 1, 2026, 6:00 PM – 10:00 PM CDT",
      "location": "Grand Ballroom, Hotel Example",
      "is_free": false,
      "ticket_price": "75.00",
      "ticket_class_count": 2,
      "display_price": "From $75.00",
      "registration_url": "https://your-domain.com/rsvp/[id]"
    }
  ]
}

Key Behaviors

BehaviorDetail
FilteringOnly events with is_published = true and a future end_time (or start_time if no end time) are returned
SortingChronologically by start_time (ascending — nearest events first)
Timezone guardrailThe query uses a JavaScript UTC timestamp instead of Postgres now() to avoid server timezone mismatches
Registration URLManual events link to /rsvp/[id]; Eventbrite events link to the eventbrite_url
Title fallbackReturns website_title if set, otherwise falls back to name
Pricedisplay_price is computed from ticket_price and ticket_class_count: "Free" if the event is free, "From $X" if multiple paid ticket types exist, or "$X" if there is a single ticket type. ticket_price is always the lowest buyer-facing price (tax-inclusive).
CORSResponds with Access-Control-Allow-Origin: * for cross-origin requests

Integration Example

<div id="events-list"></div>
 
<script>
  fetch('https://your-crm-domain.com/api/v1/public/events', {
    headers: { 'Authorization': 'Bearer YOUR_PUBLIC_API_KEY' }
  })
    .then(res => res.json())
    .then(({ data }) => {
      const container = document.getElementById('events-list');
      data.forEach(event => {
        const card = document.createElement('div');
        card.innerHTML = `
          <h3>${event.title}</h3>
          <p>${event.display_date}</p>
          <a href="${event.registration_url}">Register</a>
        `;
        container.appendChild(card);
      });
    });
</script>