Events API integration

Render GoGood events as native pages on your own site, and collect submissions from it — read API, the embeddable submission form, fields, caching, and a WordPress starter template.

For partner developers (a DMO's web agency) rendering GoGood events as native pages on their own site.

Base URL: https://gogoodtravel.com Auth: Authorization: Bearer <key> (or ?api_key= where a header is awkward)

Your key is issued by GoGood, scoped to one destination. It can only ever read that destination's events — there is no tenant parameter, so nothing you put in the URL can widen the scope.

Two ways to integrate

There are two independent pieces here. Most partners want both, and they need different things from you:

Read events (§1–5)Collect events (§6)
WhatJSON API you render into your own pagesEmbeddable submission form
DirectionGoGood → your siteYour visitors → GoGood
Needs a keyYesNo — the tenant is in the URL
EffortYou build the pagesPaste an iframe + a short script

If you only do one, do the read API — that's the SEO win, since the event pages live on your domain. Add the submission embed when you want your visitors and local organisers to feed the calendar without leaving your site. Everything submitted lands in the DMO's review queue; nothing auto-publishes.


1. Endpoints

List events

GET /api/v1/events?days=90&limit=100&offset=0
Authorization: Bearer gg_live_…
ParamDefaultMaxNotes
days90365Forward horizon from now
limit100200Page size
offset0Use next_offset from the previous response
{
  "events": [ /* … */ ],
  "next_offset": 100,        // null when there are no more pages
  "query": { "tenant": "…", "from": "…", "to": "…", "limit": 100, "offset": 0 }
}

Single event

GET /api/v1/events/<id>

<id> is the id from the list response — a stable occurrence id. Use it to build permalinks: store the id alongside your slug and fetch this on render.

Returns { "event": { … } }, or 404 if the event isn't in your key's destination (we deliberately don't distinguish "wrong tenant" from "doesn't exist").

Caching

Responses carry Cache-Control: public, s-maxage=300, stale-while-revalidate=3600. A calendar doesn't need to be real-time. Please cache on your side too — a 5-minute WordPress transient is plenty and keeps a busy page from re-fetching per visitor.

CORS

By default keys are server-to-server only and no Access-Control-Allow-Origin is sent. That's what a normal PHP integration needs. If you must call from browser JavaScript, ask us to add your origin to the key's allowlist.


2. The event object

{
  "id": "9c735409-…",                    // stable; use for permalinks
  "title": "Taco Town",
  "description": "A vibrant hub for professional theatre…",
  "starts_at": "2026-08-07T19:30:00-06:00",
  "ends_at": null,
  "timezone": "America/Denver",
  "all_day": false,
  "time_unknown": false,
  "status": "scheduled",
  "event_url": null,                     // the show's own page
  "ticket_url": null,
  "image_url": "https://…",
  "categories": ["theatre_performing_arts", "venues"],
  "venue": {
    "id": "beb74533-…",
    "name": "Miners Alley Performing Arts Center",
    "slug": "miners-alley-performing-arts-center",
    "city": "Golden",
    "state": "CO",
    "latitude": 39.7554,
    "longitude": -105.2211,
    "website": "https://…",
    "image_url": "https://…",
    "gogood_url": "https://gogoodtravel.com/listing/miners-alley-…",
    "certifications": [],
    "stewardship_score": null
  }
}

Three fields that will bite you if you ignore them

all_day — render the date only. No time.

time_unknown — we know the date but not the showtime. Render the date only. Do not print starts_at's time, which will be midnight and wrong. Publishing "12:00 AM" for an event whose time we don't know is worse than printing nothing.

if ($e['all_day'] || $e['time_unknown']) {
    echo date('l, F j', strtotime($e['starts_at']));      // Friday, August 7
} else {
    echo date('l, F j · g:i a', strtotime($e['starts_at']));
}

timezonestarts_at is ISO 8601 with an offset, already in the venue's zone. Format it as-is. If you convert to your server's timezone you'll shift evening events onto the wrong day.

Fields that are usually empty

Be honest with yourself about these when designing:

FieldReality
certificationsPopulated on 44 of 1,514 published assets. Design for empty.
stewardship_scorePublishable on 19. Gated on the business opting in.
ends_atOften null — many sources give a start only
event_url / ticket_urlFrequently null

Render these conditionally. A "Certifications:" heading above an empty list is worse than omitting the section.


3. Content strategy — please read this one

Since we don't set a cross-domain canonical in either direction, your pages and ours both stand on their own in search. That's deliberate: you should own your event traffic. But it means we should avoid publishing the same paragraph on two domains.

Treat fields as FACTS or PROSE

Facts — render as-is, no duplication risk. Nobody owns "starts at 7:30pm":

title · starts_at · ends_at · timezone · all_day · time_unknown · categories · venue.name · venue.city · venue.state · venue.latitude/longitude · venue.website · ticket_url · event_url

Prose — use as a starting point, not verbatim:

description — this is our editorial copy, and it also appears on our own page for that venue. Copying it word-for-word puts identical text on two domains.

Template your own sentence

Compose from the facts instead of reprinting our paragraph:

// Your prose, our facts. This sentence exists nowhere else on the web.
printf(
  '%s runs %s at %s in %s.%s',
  esc_html($e['title']),
  esc_html($when),                       // formatted per the rules above
  esc_html($e['venue']['name']),
  esc_html($e['venue']['city']),
  $e['ticket_url'] ? ' Tickets available online.' : ''
);

Rendered: "Taco Town runs Friday, August 7 at 7:30pm at Miners Alley Performing Arts Center in Golden. Tickets available online."

Same facts, your wording. This is better for you (original copy ranks) and better for us (our editorial stays unique to our pages).

If you do want to show our description, truncate it as a teaser and link to venue.gogood_url for the full text — that reads as a citation rather than a copy.

Honest scope note. Google's duplicate handling is more forgiving than this is often made to sound: near-identical paragraphs across two domains usually mean one is filtered from results, not that either is penalised. So treat this as good practice, not an emergency. The upside — original copy on your pages — is the real reason to do it.

Structured data

Emit Event JSON-LD on your detail pages. You have everything needed:

{
  "@context": "https://schema.org",
  "@type": "Event",
  "name": "Taco Town",
  "startDate": "2026-08-07T19:30:00-06:00",
  "eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
  "eventStatus": "https://schema.org/EventScheduled",
  "location": {
    "@type": "Place",
    "name": "Miners Alley Performing Arts Center",
    "address": { "@type": "PostalAddress", "addressLocality": "Golden", "addressRegion": "CO" },
    "geo": { "@type": "GeoCoordinates", "latitude": 39.7554, "longitude": -105.2211 }
  }
}

Google requires location for Event rich results — that's why we always send venue name and coordinates.


4. Errors

StatusMeaning
401Missing, unknown or revoked key. Deliberately not distinguished.
404Event not found, or not in your key's destination
500Our side. Serve your cached copy.

Fail soft. If we're unreachable, render your last good response rather than an error — an events page showing yesterday's cache beats a 500.


5. Where events come from

Worth knowing, because it affects how much you trust each field:

  • Owner/admin entered — highest confidence
  • Public submissions — moderated by DMO staff before publishing. You can feed this stream directly from your own site with the submission embed (§6) — no API key required.
  • AI research — found on venue websites, verified against a citation

Everything is reviewed by a human before it reaches this API. Nothing auto-publishes.


6. Collecting events from your own site (no key needed)

The API above is read-only. To let people submit events to the calendar from your site, embed the submission form. It needs no API key — the tenant is in the URL, and everything submitted lands in the moderation queue.

<!-- GoGood Travel — submit an event -->
<iframe
  id="gogood-submit-event"
  src="https://gogoodtravel.com/embed/<tenant>/submit-event"
  width="100%"
  height="900"
  frameborder="0"
  style="border: 0; width: 100%;"
  title="Submit an event"
></iframe>
<script>
  // The form's height changes as the user fills it in; resize to match so
  // there's no scrollbar inside the frame.
  window.addEventListener('message', function (e) {
    var d = e.data
    if (!d || d.type !== 'gogood:embed:height') return
    if (typeof d.height !== 'number' || !isFinite(d.height)) return
    var f = document.getElementById('gogood-submit-event')
    if (f) f.style.height = d.height + 'px'
  })
</script>

Replace <tenant> with your tenant slug (golden, boulder, …). Your GoGood contact can generate the exact snippet from the admin embed page, and can also show you a live preview of how it will render on your site — including what it looks like if the resize script is missing.

The height handshake is not optional

An iframe has a fixed height, but the form's height changes as fieldsets toggle (all-day hides the time inputs), as the venue typeahead opens, and when the success screen replaces the form. Without the <script> above your visitors get a scrollbar inside a box. The embed posts { type: 'gogood:embed:height', height: <number> } to the parent window on every size change; the listener resizes the iframe to match.

The listener checks the message type and that the height is a finite number, because your page can receive postMessage from any frame it hosts. Keep both checks if you adapt the snippet.

Matching your palette

Two query params, so you don't need per-partner CSS from us:

ParamEffect
?accent=1a7f5aSets the form's primary colour. Six-digit hex, # optional.
?theme=darkDark surface, light text.

Combine them: …/submit-event?theme=dark&accent=1a7f5a.

What happens to a submission

  1. Submitter fills the form (no account required) and confirms by email — double opt-in, so a typo'd address never creates a listing.
  2. It enters the DMO's review queue. Nothing auto-publishes.
  3. Once approved, it appears in the guide calendar and in this read API.

The form carries a honeypot plus per-email and per-IP rate limiting.

The venue typeahead searches published listings in your tenant, and a chosen venue is re-validated server-side — the submitted asset id is re-checked against the same constraints (published, not deleted, inside this destination tree) and the binding is dropped if it doesn't hold, so a crafted request can't attach an event to a listing the submitter never picked. A submission whose venue doesn't match a listing still goes through; it simply arrives unbound for the reviewer to resolve.

Why an iframe rather than a script widget

The iframe keeps the form's protections — honeypot, rate limiting, double opt-in, and a server action that runs with elevated privileges — entirely on our side of the origin boundary. A script that injected into your DOM would mean cross-origin POSTs, CORS, CSRF handling without a same-site cookie, and our styling fighting your stylesheet. Same submission path, none of that.

The embed is noindex by design: the canonical form lives in the guide, and two indexable copies of the same form would compete in search.


7. Getting a key

Ask your GoGood contact. You'll get it once — we store only a hash and cannot recover it. Keep it server-side; it is not safe in browser JavaScript unless we've explicitly allowlisted your origin for CORS.

To rotate or revoke, contact us. Revocation takes effect on the next request.