Public Events Calendar
This guide walks through pulling your church's events from the ChMS API v2 and rendering them as a custom calendar or events list on your own website.
Public Events Calendar
This guide covers how to pull a church's events from Pushpay ChMS through the API and show them as a calendar or events list on your own website: reading a date range, showing an event's details, and handling time zones, recurring events and images.
Use/calendarfor anything with dates
GET /calendaris the only endpoint that filters by date.GET /eventshas no date filter, and it quietly ignoresstartandendif you send them, so a calendar built on it shows the wrong events without any error.
Overview
With the calendar and event endpoints you can:
- List every event occurrence in a date range, with recurring events already expanded
- Filter by campus, group type, department, group name, room or resource
- Show an event's full description, location, image and registration link
- Map campus IDs to campus names for display
Authentication
ChMS API v2 uses OAuth 2.0. See the Getting Started guide to request credentials and the OAuth Authentication page for the token flow.
Every request needs these headers:
Authorization: Bearer YOUR_ACCESS_TOKEN
Accept: application/vnd.ccbchurch.v2+json
Required Scopes
| Scope | Needed for |
|---|---|
read:events | GET /calendar and GET /events/{id} |
read:campuses | Mapping campus_id to a campus name with GET /campuses (optional) |
No write: scope is needed for a read-only calendar. Request these when you register your OAuth application.
1. Understanding Events and Occurrences
Event → Occurrences
- Event — the event as it's set up in ChMS, such as "8 AM Prayer". It has one
id, and if it repeats, a recurrence pattern. - Occurrence — one date the event happens on. A weekly event has one occurrence per week. Each occurrence is identified by the event's
idplus anoccurrencestring, the date asYYYYMMDD(for example20261018).
A calendar shows occurrences, not events. GET /calendar returns occurrences, and GET /events/{id} describes the event, optionally for one occurrence.
2. Pulling the Calendar for a Date Range
GET /calendar returns every occurrence in a date range.
| Query parameter | Description |
|---|---|
start, end | YYYY-MM-DD. Both are inclusive. The range can't be more than 45 days. |
type | PUBLIC (default), CAMPUS_WIDE, FACILITIES, ALL_EVENTS_ADMIN or DRAFT. Use PUBLIC for a website. |
campus_ids[] | Filter by campus. Repeat it for several campuses. Leave it out to get every campus. |
group_type_ids[] | Filter by group type |
department_ids[] | Filter by department |
attendance_grouping_ids[] | Filter by attendance grouping |
room_ids[], resource_ids[] | Filter to events using specific rooms or resources |
group_name | Filter by the name of the event's group. This matches part of the name, not the whole name. |
include_shared_events | true to include events shared from other campuses |
Array parameters must use [] and be repeated (campus_ids[]=1&campus_ids[]=8). The brackets can be sent as-is or URL-encoded (campus_ids%5B%5D=1). A repeated key without brackets (campus_ids=1&campus_ids=8) is rejected with a 412: campus_ids value '8' does not match type `list<int>`. A single campus can also be sent as campus_id=1. Every ID must exist, or the request fails with 412 Campus not found for 2.
curl --request GET \
--url 'https://api.ccbchurch.com/calendar?start=2026-11-01&end=2026-11-30&type=PUBLIC&campus_ids[]=1&campus_ids[]=8' \
--header 'Accept: application/vnd.ccbchurch.v2+json' \
--header 'Authorization: Bearer YOUR_ACCESS_TOKEN'The response is an array with one row per occurrence, sorted by start time (across time zones):
[
{
"event_id": 4445,
"occurrence": "20261101",
"start": "2026-11-01T08:00:00-0600",
"end": "2026-11-01T09:00:00-0600",
"event": {
"id": 4445,
"campus_id": 1,
"name": "8 AM Prayer",
"description": "",
"start": "2021-06-01T08:00:00-0500",
"end": "2021-06-01T09:00:00-0500",
"owner_id": 1,
"setup": "2021-06-01T08:00:00-0500",
"cleanup": "2021-06-01T09:00:00-0500",
"group": { "id": "686", "name": "Prayer Team" },
"is_shared_and_accepted": false,
"guest_counts": { "going": 1, "maybe": 0, "not_going": 0, "not_responded": 0, "requesting": 0 },
"approval_status": "APPROVED",
"approval_status_id": 1,
"is_draft": false,
"is_approved": true,
"recurs": true,
"recur_end_date": null,
"recurrence": { "frequency": "D", "interval": "1", "frequency_modifier": "", "end": null },
"grouping": { "id": 157, "name": "Prayer Grouping" },
"checkin": { "grouping_id": 157, "checkin_type": "PRECHECK", "allowed": true, "...": "..." },
"actions": { "...": "..." }
},
"group_id": "686",
"checkin_label_type": "",
"checkin_label_quantity": 1
}
]- Recurring events are already expanded. A weekly event appears once per week in the range, each row with its own
occurrence,startandend. You don't need to work out the recurrence pattern yourself. - Take the date and time from the row, not from
event.startandevent.end. Those describe the event as it was first set up, so for a recurring event they show its first date (in the example, June 2021). imagesonly appears when the event has a photo. Events without one have noimageskey at all, not evennull. Plan for a placeholder image. When it's there, it hasthumbnail(64px),medium(448px) andlarge(1024px) URLs.addressonly appears when the event has a location, withname,street,city,stateandzip. Any of these can be an empty string.- There's no
all_dayfield on calendar rows. An all-day event starts at00:00and ends at23:59, so check for that if you want to show it differently. The event detail (section 4) hasall_day. - Rows also carry
guest_counts,checkin,grouping,approval_status,is_draft,is_approvedandactions. A public calendar can usually ignore them.
The 45-day limit
end can be at most 45 days after start. A wider range returns:
[{ "errors": ["Max range of 45 days exceeded"], "status_type": null }]with a 412. A calendar month always fits. To show more than 45 days (for example, the next three months), make one request per month and combine the results.
If you leave out a bound, the API fills it in for you rather than returning an error:
| You send | Range you get |
|---|---|
start and end | Exactly that range |
start only | start to 45 days after it |
end only | 45 days before end to end |
| Neither | Today to 45 days from today |
end before start | An empty array, with a 200 |
Always send both bounds, so a date bug in your code doesn't quietly show the wrong range.
No paging
/calendar returns the whole range in one array. There are no paging headers, and page and per_page are ignored. The type you choose has a large effect on size: on one multi-site church, 30 days returned about 120 occurrences as PUBLIC and about 4,300 as ALL_EVENTS_ADMIN. Use PUBLIC for a website.
3. Requesting Back-to-Back Ranges
start and end are both inclusive. If one request ends on November 30 and the next starts on November 30, every occurrence on the 30th comes back twice. Start each range on the day after the previous one ended:
| Request | start | end |
|---|---|---|
| 1 | 2026-11-01 | 2026-11-30 |
| 2 | 2026-12-01 | 2026-12-31 |
| 3 | 2027-01-01 | 2027-01-31 |
As a safety net, remove duplicates on event_id plus occurrence when you combine the results. Together they identify one occurrence.
4. Showing an Event's Details
GET /events/{id} returns the full event: description, location, image, registration and recurrence.
| Query parameter | Description |
|---|---|
occurrence | YYYYMMDD, from the calendar row. The response's start and end are then set to that date. Without it, they show the event's first date. |
attendance_info | true to add whether attendance has been taken. It only works together with occurrence, and isn't needed for a public calendar. |
Always pass the occurrence from the calendar row the visitor clicked.
- If the event doesn't happen on that date, the response is a
404. - If
occurrenceisn't inYYYYMMDDform (for example2026-11-01), the response is a400. - An impossible date such as
20261299is not rejected. It rolls over to a later date (here, March 9, 2027) and returns200with that date, so check thestartin the response if the date came from user input.
curl --request GET \
--url 'https://api.ccbchurch.com/events/4445?occurrence=20261101' \
--header 'Accept: application/vnd.ccbchurch.v2+json' \
--header 'Authorization: Bearer YOUR_ACCESS_TOKEN'{
"id": 4445,
"campus_id": 1,
"name": "8 AM Prayer",
"description": "",
"start": "2026-11-01T08:00:00-0600",
"end": "2026-11-01T09:00:00-0600",
"setup": "2026-11-01T08:00:00-0600",
"cleanup": "2026-11-01T09:00:00-0600",
"all_day": false,
"use_campus_address": true,
"group": {
"id": 686,
"campus_id": 1,
"name": "Prayer Team",
"interaction_type": "ANNOUNCE_ONLY",
"public_signup_form": null
},
"recurs": true,
"recur_end_date": null,
"recurrence": { "frequency": "D", "interval": "1", "frequency_modifier": "", "end": null },
"next_occurrence": { "start": "2026-10-15T08:00:00-05:00", "end": "2026-10-15T09:00:00-05:00", "occurrence": "20261015" },
"registration": { "closed": false, "form_id": 0, "url": null },
"invitation_settings": { "show_guest_list": true, "allow_additional_guests": true, "notify_owner_of_status_changes": false, "attend_max_quantity": 0 },
"creator": { "id": 1, "first_name": "First", "last_name": "Last", "name": "First Last", "phone": {}, "urls": { "...": "..." } },
"owner": { "id": 1, "first_name": "First", "last_name": "Last", "name": "First Last", "phone": {}, "urls": { "...": "..." } },
"modifier": { "id": 1, "first_name": "First", "last_name": "Last", "name": "First Last", "phone": {}, "urls": { "...": "..." } },
"date_created": "2021-06-21 15:43:00",
"date_modified": "2024-08-15 12:07:09"
}The response is trimmed. It also carries everything a calendar row's event has (guest_counts, checkin, approval_status and so on), plus uses_resources, setup_notes, estimated_attendance and type_id. For an event with a registration form, registration looks like this:
{ "closed": false, "form_id": 935, "url": "https://yourchurch.ccbchurch.com/goto/forms/935/responses/new" }What to use on an event page:
description— can contain HTML, and line breaks are turned into<br/>. Render it as rich text, and sanitize it before putting it on your page.address— only present when the event has a location. Ifuse_campus_addressistrue, show the campus's address instead (section 5).all_day—truewhen the event runs from00:00to23:59. Show it as an all-day event rather than a time slot.images— only present when the event has a photo, the same as on the calendar.registration— show a Register button that links tourlwhenurlis present andclosedisfalse. When the event has no registration form,urlisnullandform_idis0.group— the full group profile, richer than theidandnameon a calendar row.recurrence— for text such as "Repeats daily". Only recurring events have it.next_occurrence— the next occurrence from today, not the one after theoccurrenceyou asked for. In the example, the request was for November 1 butnext_occurrenceis October 15. Only recurring events have it.creator,ownerandmodifier— staff names and profile details. Don't show them on a public page.
5. Resolving Campus Names (Optional)
Calendar rows and event details carry a campus_id, not a name. GET /campuses (scope read:campuses) lists the church's campuses. Call it once, build an ID-to-name lookup, and cache it. Campuses rarely change, so refreshing once a day is plenty.
curl --request GET \
--url https://api.ccbchurch.com/campuses \
--header 'Accept: application/vnd.ccbchurch.v2+json' \
--header 'Authorization: Bearer YOUR_ACCESS_TOKEN'[
{
"id": 1,
"name": "Downtown",
"country": "US",
"is_favorite": false,
"timezone": "America/Denver",
"address": { "street": "123 Main St", "city": "Springfield", "state": "CO", "zip": "80000", "longitude": "-104.816256", "latitude": "38.851635", "country_iso": "US" }
},
{ "id": 8, "name": "Northside", "country": "US", "is_favorite": false, "timezone": "America/Chicago" }
]Each campus also carries its timezone, and its address when one is set. Use that address for events where use_campus_address is true.
6. Handling Time Zones
Every start and end includes a UTC offset, and the offset depends on the event's campus. A multi-site church can return several offsets in one response (for example -0500, -0600 and +1000). Show each time in its campus's zone, so an event set for 9:00 AM shows as 9:00 AM.
The offset isn't written the same way everywhere:
| Field | Example | Offset format |
|---|---|---|
start / end (calendar and details) | 2026-11-01T09:00:00-0600 | No colon |
next_occurrence.start / end | 2026-11-08T09:00:00-07:00 | Colon |
date_created / date_modified | 2023-12-31 09:15:03 | No offset |
Use your language's date parser (new Date() in JavaScript, DateTime in PHP), which reads both offset formats, rather than matching one format with your own pattern. Don't show date_created or date_modified to visitors, because they have no time zone.
Complete Example
A website that shows November and December 2026 for two campuses, with a details page for each event.
# 1. Cache campus names (once a day)
curl 'https://api.ccbchurch.com/campuses' \
-H 'Accept: application/vnd.ccbchurch.v2+json' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
# 2. Pull each month separately: each range is 45 days or less, and they don't overlap
curl 'https://api.ccbchurch.com/calendar?start=2026-11-01&end=2026-11-30&type=PUBLIC&campus_ids[]=1&campus_ids[]=8' \
-H 'Accept: application/vnd.ccbchurch.v2+json' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
curl 'https://api.ccbchurch.com/calendar?start=2026-12-01&end=2026-12-31&type=PUBLIC&campus_ids[]=1&campus_ids[]=8' \
-H 'Accept: application/vnd.ccbchurch.v2+json' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
# -> combine, remove duplicates on event_id + occurrence, cache for a few minutes
# 3. A visitor clicks "8 AM Prayer" on November 1
curl 'https://api.ccbchurch.com/events/4445?occurrence=20261101' \
-H 'Accept: application/vnd.ccbchurch.v2+json' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN'The same flow in JavaScript, splitting any range into requests of at most 45 days:
const BASE = "https://api.ccbchurch.com";
const HEADERS = {
Accept: "application/vnd.ccbchurch.v2+json",
Authorization: `Bearer ${token}`,
};
const DAY = 24 * 60 * 60 * 1000;
const toDate = (d) => d.toISOString().slice(0, 10);
async function getCalendar(start, end, campusIds = []) {
const occurrences = [];
// Ranges are inclusive, so each request starts the day after the previous one ended
for (let from = new Date(start); from <= new Date(end); ) {
const to = new Date(Math.min(from.getTime() + 44 * DAY, new Date(end).getTime()));
const params = new URLSearchParams({ start: toDate(from), end: toDate(to), type: "PUBLIC" });
campusIds.forEach((id) => params.append("campus_ids[]", id));
const res = await fetch(`${BASE}/calendar?${params}`, { headers: HEADERS });
if (!res.ok) throw new Error(`Calendar request failed: ${res.status}`);
occurrences.push(...(await res.json()));
from = new Date(to.getTime() + DAY);
}
// Remove duplicates on event_id + occurrence
const seen = new Set();
return occurrences.filter((o) => {
const key = `${o.event_id}|${o.occurrence}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
const occurrences = await getCalendar("2026-11-01", "2026-12-31", [1, 8]);Cache the calendar for a few minutes rather than calling the API on every page view, and only call GET /events/{id} when a visitor opens an event.
Troubleshooting
| Symptom | Likely cause |
|---|---|
412 "Max range of 45 days exceeded" | end is more than 45 days after start. Split the range into smaller requests. |
412 "campus_ids value '8' does not match type list<int>." | The array was sent without []. Use campus_ids[]=1&campus_ids[]=8. |
412 "Campus not found for 2" | That campus ID doesn't exist. Get valid IDs from GET /campuses. |
412 "type value 'X' does not match any of …" | type must be PUBLIC, CAMPUS_WIDE, FACILITIES, ALL_EVENTS_ADMIN or DRAFT. |
| A filter has no effect | The parameter name is misspelled. /calendar ignores parameters it doesn't recognize and returns everything, with a 200. Check that the filter actually reduced the results. |
| Empty array, but the church has events | end is before start, or type doesn't match how the events are set up in ChMS. |
| The same occurrence appears twice | Two requests share a boundary date. Start each range the day after the previous end, and remove duplicates on event_id + occurrence. |
| Events show the wrong date | The date came from event.start instead of the row's start, or GET /events/{id} was called without occurrence. |
GET /events?start=...&end=... returns the wrong events | /events has no date filter and ignores those parameters. Use /calendar. |
404 on GET /events/{id}?occurrence=... | The event doesn't happen on that date. Use the occurrence from the calendar row. |
400 on GET /events/{id}?occurrence=... | occurrence isn't in YYYYMMDD form. |
| The details page shows a different date than the one clicked | occurrence was an impossible date (such as 20261299), which rolls over to a later date instead of failing. |
403 on /calendar | The token's user can't view that calendar type on one of the requested campuses. PUBLIC is always allowed. |
| No image on an event | The event has no photo, so images is left out. Show a placeholder. |
| Times are an hour or more off | The offset was dropped, or every event was shown in one time zone. Each campus has its own offset. |
API Reference
| Operation | Method | Endpoint |
|---|---|---|
| List occurrences in a range | GET | /calendar |
| Get an event | GET | /events/{id} |
| Get one event's occurrences | GET | /events/{id}/calendar (accepts start and end) |
| List campuses | GET | /campuses |
Related Documentation
- Getting Started guide
- OAuth Authentication
- Docs MCP server — lets your AI coding tools answer questions against these docs
Updated 8 days ago
