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 /calendar for anything with dates

GET /calendar is the only endpoint that filters by date. GET /events has no date filter, and it quietly ignores start and end if 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

ScopeNeeded for
read:eventsGET /calendar and GET /events/{id}
read:campusesMapping 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 id plus an occurrence string, the date as YYYYMMDD (for example 20261018).

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 parameterDescription
start, endYYYY-MM-DD. Both are inclusive. The range can't be more than 45 days.
typePUBLIC (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_nameFilter by the name of the event's group. This matches part of the name, not the whole name.
include_shared_eventstrue 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, start and end. You don't need to work out the recurrence pattern yourself.
  • Take the date and time from the row, not from event.start and event.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).
  • images only appears when the event has a photo. Events without one have no images key at all, not even null. Plan for a placeholder image. When it's there, it has thumbnail (64px), medium (448px) and large (1024px) URLs.
  • address only appears when the event has a location, with name, street, city, state and zip. Any of these can be an empty string.
  • There's no all_day field on calendar rows. An all-day event starts at 00:00 and ends at 23:59, so check for that if you want to show it differently. The event detail (section 4) has all_day.
  • Rows also carry guest_counts, checkin, grouping, approval_status, is_draft, is_approved and actions. 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 sendRange you get
start and endExactly that range
start onlystart to 45 days after it
end only45 days before end to end
NeitherToday to 45 days from today
end before startAn 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:

Requeststartend
12026-11-012026-11-30
22026-12-012026-12-31
32027-01-012027-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 parameterDescription
occurrenceYYYYMMDD, 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_infotrue 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 occurrence isn't in YYYYMMDD form (for example 2026-11-01), the response is a 400.
  • An impossible date such as 20261299 is not rejected. It rolls over to a later date (here, March 9, 2027) and returns 200 with that date, so check the start in 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. If use_campus_address is true, show the campus's address instead (section 5).
  • all_day — true when the event runs from 00:00 to 23: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 to url when url is present and closed is false. When the event has no registration form, url is null and form_id is 0.
  • group — the full group profile, richer than the id and name on 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 the occurrence you asked for. In the example, the request was for November 1 but next_occurrence is October 15. Only recurring events have it.
  • creator, owner and modifier — 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:

FieldExampleOffset format
start / end (calendar and details)2026-11-01T09:00:00-0600No colon
next_occurrence.start / end2026-11-08T09:00:00-07:00Colon
date_created / date_modified2023-12-31 09:15:03No 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

SymptomLikely 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 effectThe 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 eventsend is before start, or type doesn't match how the events are set up in ChMS.
The same occurrence appears twiceTwo 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 dateThe 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 clickedoccurrence was an impossible date (such as 20261299), which rolls over to a later date instead of failing.
403 on /calendarThe token's user can't view that calendar type on one of the requested campuses. PUBLIC is always allowed.
No image on an eventThe event has no photo, so images is left out. Show a placeholder.
Times are an hour or more offThe offset was dropped, or every event was shown in one time zone. Each campus has its own offset.

API Reference

OperationMethodEndpoint
List occurrences in a rangeGET/calendar
Get an eventGET/events/{id}
Get one event's occurrencesGET/events/{id}/calendar (accepts start and end)
List campusesGET/campuses

Related Documentation


Did this page help you?