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:
| Condition | Behavior |
|---|---|
| Event not found | Shows "Registration Unavailable" |
is_published = false | Shows "Registration Unavailable" |
| Event has ended | Shows "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:
- Event Header — Displays the
website_title(falls back toname), formatted date/time using the event's timezone, and location. - Description — Renders the
website_description(falls back todescription) as HTML with a collapsible "Read more" toggle for long content. - 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 successContact 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:
| Field | Value |
|---|---|
source | event |
is_subscribed | true |
opt_in_timestamp | Current timestamp |
opt_in_source | event_[eventId] |
opt_in_ip | Extracted 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 Fields | Purpose |
|---|---|
is_subscribed → true | Re-subscribes the contact |
opt_in_timestamp | Records fresh consent |
opt_in_source | event_[eventId] |
opt_in_ip | IP at time of registration |
unsubscribed_at → null | Clears 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:
- First registration — A new
event_registrationsrow is inserted withstatus = registered. - Already registered — Returns a 400 error: "You are already registered for this event."
- 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/eventsAuthentication
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
| Behavior | Detail |
|---|---|
| Filtering | Only events with is_published = true and a future end_time (or start_time if no end time) are returned |
| Sorting | Chronologically by start_time (ascending — nearest events first) |
| Timezone guardrail | The query uses a JavaScript UTC timestamp instead of Postgres now() to avoid server timezone mismatches |
| Registration URL | Manual events link to /rsvp/[id]; Eventbrite events link to the eventbrite_url |
| Title fallback | Returns website_title if set, otherwise falls back to name |
| Price | display_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). |
| CORS | Responds 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>