# Developer guide

This page is for whoever builds your church's own website or automations. HolyCRM offers
two ways to connect:

- **Public data feeds**: your website *reads* your events, small groups, service times
  and ministries from HolyCRM, always up to date.
- **Webhooks**: HolyCRM *sends* announcements and what happens in your church (new
  visitors, members, prayer requests, registrations, serving answers) to an address of
  yours.

Both are part of the Pro plan, and only an Administrator can turn them on, in
**Settings → [Integrations](#/integrations)**.

## Public data feeds

### Turning them on

In **Integrations → Public data feeds**, an Administrator ticks the feeds to share and
clicks **Save**. The address of each feed appears under it, with a **Copy** button. Untick
a feed and save to stop it at once.

Each feed carries **exactly what the church's HolyCRM website shows, never more**. Member
records, children, notes, addresses of homes and group hosts are never included.

### Addresses

Every church has its own addresses, built from its HolyCRM id (the screen shows them in
full):

| Address | What it returns |
|---|---|
| `https://pb.holycrm.app/backend/api/public/feeds/<id>` | The church's name and time zone, and the address of every feed that is on |
| `…/feeds/<id>/events` | Upcoming events |
| `…/feeds/<id>/groups` | Small groups marked "Show on the website" |
| `…/feeds/<id>/services` | Weekly service times marked "Show on the website" |
| `…/feeds/<id>/ministries` | Active ministries |

They need no key or login, and they can be called straight from a web page on any site
(cross-origin requests are allowed). Responses may be cached for 5 minutes, so a change in
HolyCRM shows up on your site within a few minutes.

### What each feed returns

Every feed returns JSON with the church and a list named after the feed:

```json
{
  "church": { "name": "Grace Community Church", "timezone": "America/Chicago" },
  "events": [ … ]
}
```

A field with nothing in it is `null`.

**Events** (`events`), soonest first:

| Field | |
|---|---|
| `id` | The event's id |
| `title` | Name of the event |
| `starts_at` | Start, in UTC: `"2026-11-01 15:00:00.000Z"` |
| `location_place` | Where it is, as typed in the event |
| `description` | The public description for the website |
| `photo_url` | Full address of the website photo |
| `registration` | `true` when visitors can sign up on the church's HolyCRM website |

It takes two optional settings: `?limit=` how many events (1 to 100, default 20) and
`?days=` how far ahead (1 to 365 days, default 90). For example
`…/events?limit=5&days=30`.

**Small groups** (`groups`), by name:

| Field | |
|---|---|
| `id` | The group's id |
| `name` | Name of the group |
| `description` | The public description for the website |
| `meeting_day` | `mon`, `tue`, `wed`, `thu`, `fri`, `sat` or `sun` |
| `meeting_time` | Time as typed, local to the church, e.g. `"19:30"` |

**Service times** (`services`), Sunday first:

| Field | |
|---|---|
| `title` | Name of the activity, e.g. "Sunday service" |
| `weekday` | `sun` … `sat` |
| `time` | Local time at the church, e.g. `"10:00"` |
| `duration_minutes` | Length, when set |
| `place` | Name of the location, when set |

**Ministries** (`ministries`), by name:

| Field | |
|---|---|
| `id` | The ministry's id |
| `name` | Name of the ministry |
| `min_age`, `max_age` | Age range, when set (for children's and youth ministries) |
| `description` | The public description for the website |
| `photo_url` | Full address of the website photo |

### Times and time zones

Event start times (`starts_at`) are in **UTC**. Service and group times are wall-clock
times **at the church**. Show both in the church's time zone, which comes in
`church.timezone` (for example `America/Argentina/Buenos_Aires`).

### When a feed answers "not found"

A feed answers `404 {"error": "not found"}` when it is turned off, when the address has a
wrong id, or when the church is no longer on the Pro plan. A church that comes back to Pro
gets its feeds back at the same addresses.

### Example

A list of the next five events on any web page:

```html
<ul id="events"></ul>
<script>
  const FEED = "https://pb.holycrm.app/backend/api/public/feeds/YOUR_CHURCH_ID/events?limit=5";
  fetch(FEED)
    .then((r) => r.json())
    .then(({ church, events }) => {
      const list = document.getElementById("events");
      for (const ev of events) {
        const when = new Date(ev.starts_at.replace(" ", "T")).toLocaleString(undefined, {
          timeZone: church.timezone || undefined,
          weekday: "long", day: "numeric", month: "long", hour: "2-digit", minute: "2-digit",
        });
        const li = document.createElement("li");
        li.textContent = `${ev.title} · ${when}${ev.location_place ? " · " + ev.location_place : ""}`;
        list.appendChild(li);
      }
    });
</script>
```

Use `textContent` (not `innerHTML`) for anything that comes from a feed, as above.

## Webhooks

Setting up a webhook, choosing what it receives and the ready-made formats for ntfy and
Telegram are explained in [Integrations](#/integrations). What matters to a developer:

- Each delivery is a `POST` with a JSON body (or your custom format). `X-HolyCRM-Event`
  says what happened, for example `visitor.created`.
- `X-HolyCRM-Delivery` stays the same when a delivery is retried, so you can ignore
  duplicates.
- **Every request is signed.** `X-HolyCRM-Signature` is `sha256=` followed by the
  HMAC-SHA256 of `<X-HolyCRM-Timestamp>.<raw body>`, keyed with your webhook's signing
  secret.
- Answer with any 2xx status. Timeouts (15 seconds), 408, 429, 5xx and network errors are
  tried again (after 30 seconds, 2, 8 and 32 minutes, then hourly, 6 attempts in all). Any
  other answer, including a redirect, fails at once: redirects are never followed.

With the default format the body looks like this (`data` depends on the event; `summary`
is a ready-made line in the church's language):

```json
{
  "event": "visitor.created",
  "version": 1,
  "id": "k3j2h1g0f9e8d7c",
  "created_at": "2026-10-08T14:00:00.000Z",
  "church": { "id": "abc123def456ghi", "name": "Grace Community Church" },
  "data": { "first_name": "John", "last_name": "Smith", "phone": "…", "summary": "New visitor: John Smith" }
}
```

Checking the signature in Node.js:

```js
const crypto = require("crypto");
function verify(rawBody, headers, secret) {
  const ts = headers["x-holycrm-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // older than 5 minutes
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex");
  const got = String(headers["x-holycrm-signature"] || "");
  return got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
```

Use the **raw** request body, before any JSON parsing.

## Not available yet

There is no API with keys for reading or changing member data. If your church needs one,
tell us what you'd use it for through the contact form on [holycrm.app](https://holycrm.app).
