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) | |
|---|---|---|
| What | JSON API you render into your own pages | Embeddable submission form |
| Direction | GoGood → your site | Your visitors → GoGood |
| Needs a key | Yes | No — the tenant is in the URL |
| Effort | You build the pages | Paste 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_…
| Param | Default | Max | Notes |
|---|---|---|---|
days | 90 | 365 | Forward horizon from now |
limit | 100 | 200 | Page size |
offset | 0 | — | Use 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']));
}
timezone — starts_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:
| Field | Reality |
|---|---|
certifications | Populated on 44 of 1,514 published assets. Design for empty. |
stewardship_score | Publishable on 19. Gated on the business opting in. |
ends_at | Often null — many sources give a start only |
event_url / ticket_url | Frequently 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
| Status | Meaning |
|---|---|
| 401 | Missing, unknown or revoked key. Deliberately not distinguished. |
| 404 | Event not found, or not in your key's destination |
| 500 | Our 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:
| Param | Effect |
|---|---|
?accent=1a7f5a | Sets the form's primary colour. Six-digit hex, # optional. |
?theme=dark | Dark surface, light text. |
Combine them: …/submit-event?theme=dark&accent=1a7f5a.
What happens to a submission
- Submitter fills the form (no account required) and confirms by email — double opt-in, so a typo'd address never creates a listing.
- It enters the DMO's review queue. Nothing auto-publishes.
- 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.