# Mass-times API

> A worldwide directory of churches and the times of their Masses, confessions and adoration. Reads are open to anyone; a correction is a suggestion that a person reviews.

Web page: https://ember.dpgu.me/developers/api/ · Em português: https://ember.dpgu.me/pt/desenvolvedores/api.md

## Basics

- **Base URL:** `https://ember-mass-times.dpgu.workers.dev`
- **OpenAPI:** [openapi.json](https://ember.dpgu.me/developers/openapi.json), for Postman, Bruno or a client generator
- **Auth:** None. Every route allows any origin, so a web page can call it directly.
- **Cache:** Reads are `public, max-age=300`; map tiles an hour, then a day stale while revalidating.
- **Errors:** A missing church is `404 { "error": "not_found" }`. A bad parameter is `400` with the [validation issues](https://ember.dpgu.me/developers/api/#schema-ValidationError). Too many writes is `429 { "error": "rate_limited" }`.

## Reading a schedule

A church’s `services` are its recurring times, one pattern at one time each: “Sundays at 9:00” is one service and “Sundays at 11:30” another. `rrule` is an iCalendar rule that picks the days, and `startTime` gives the wall-clock time in the church’s own `timezone`. `exdate` lists cancelled days and `rdate` added ones. The API sends the rules unexpanded, so you compute the next Mass on your side.

[expand.ts](https://github.com/gustavo-depaula/ember/blob/main/packages/api/src/expand.ts) does this in sixty lines on the [rrule](https://github.com/jkbrzt/rrule) package, and like the rest of Ember’s code it is in the public domain to copy:

```js
import { expandService } from './expand' // packages/api/src/expand.ts

const church = await (await fetch('https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp')).json()
for (const service of church.services.filter((s) => s.kind === 'mass')) {
  for (const { date, startTime } of expandService(service, { count: 3 })) {
    console.log(date.toISOString().slice(0, 10), startTime, service.language)
  }
}
```

A bundler takes it as it is. Plain Node finds no named exports in rrule: write `import rrule from 'rrule'` and `const { RRule, RRuleSet } = rrule`.

Where no structured schedule is on record (`hasStructuredSchedule` is false), the parish’s own wording is in `texts`, as it was published.

## Churches

Public reads

### GET /churches/near

**Churches near a point.** Churches within `radiusKm` of a point, nearest first. A `kind` or `rite` filter trims each church’s services and drops churches left with none.

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `lat` | query | number | yes | -90–90 |
| `lng` | query | number | yes | -180–180 |
| `radiusKm` | query | number |  | > 0; ≤ 50; default 10 |
| `kind` | query | string |  | Keep only services of this kind: mass, confession, adoration |
| `rite` | query | string |  | Keep only services of this rite, e.g. latin_tridentine |
| `institute` | query | string |  | Only churches served by this institute, e.g. FSSP |
| `status` | query | string |  | active \| temporarily_closed \| closed |
| `limit` | query | integer |  | 1–100; default 25 |

Responses:

- `200` `{ churches: (Church & { distanceKm: number })[] }`: OK
- `400` `ValidationError`: A parameter or the body failed validation

Example:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches/near?lat=-23.5505&lng=-46.6333&radiusKm=2&limit=5'
```

### GET /churches

**Search by name, or read a map viewport.** Give `q` to search by name, or `bbox` for the churches in a map viewport. A viewport too wide to list answers with `clusters`, counted by geohash cell.

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `q` | query | string |  | Name search |
| `near` | query | string |  | With q: "lat,lng" to rank matches nearest it first |
| `bbox` | query | string |  | A map viewport, "minLng,minLat,maxLng,maxLat": churches, or clusters when wide |
| `kind` | query | string |  |  |
| `rite` | query | string |  |  |
| `institute` | query | string |  |  |
| `status` | query | string |  |  |
| `limit` | query | integer |  | 1–100; default 25 |
| `offset` | query | integer |  | ≥ 0; default 0 |

Responses:

- `200` `{ churches: Church[], clusters?: Cluster[] }`: `{ churches }` for a search, `{ churches, clusters }` for a viewport
- `400` `ValidationError`: A parameter or the body failed validation

Example:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches?q=Carmo&near=-23.5505%2C-46.6333&limit=5'
```

### GET /churches/index

**The whole directory, a page at a time.** Every church in id order, with the few fields a sitemap needs, or with `full=1` each church whole, as `/churches/{id}` gives it. Pass the `next` of one page as the `after` of the next; a page without `next` is the last.

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `after` | query | string |  | The last id of the previous page; at most 256 characters |
| `scheduled` | query | "1" |  | Only churches with service times on record |
| `country` | query | string |  | Only churches in these countries: ISO codes, comma-separated (BR,PT); matches ^[A-Za-z]{2}(,[A-Za-z]{2})*$ |
| `limit` | query | integer |  | 1–1000; default 500 |
| `full` | query | "1" |  | Each church whole, as /churches/{id} gives it: schedule, texts, links, parish and chapels |

Responses:

- `200` `{ churches: { id: string, name: string, longName: string | null, city: string | null, region: string | null, countryCode: string | null, hasStructuredSchedule: boolean | null, updatedAt: string | null }[], next?: string }`: OK
- `400` `ValidationError`: A parameter or the body failed validation

Example:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches/index?limit=5'
```

### GET /churches/tiles/{cell}

**A map tile.** A five-character cell answers with its churches (without texts and links); a shorter one, or `root`, with the counts of the cells inside it. Cached for an hour.

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `cell` | path | string | yes | A geohash of 1–5 characters, or root; matches ^(root\|[0-9b-hjkmnp-z]{1,5})$ |
| `kind` | query | string |  | Count only churches with services of this kind |

Responses:

- `200` `{ churches: Church[] } | { cells: Cluster[] }`: `{ churches }` for a full-length cell, `{ cells }` above it
- `400` `ValidationError`: A parameter or the body failed validation

Example:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches/tiles/6gyf'
```

### GET /churches/{id}

**One church.** A church with its schedule, texts and links, the parish it belongs to and the chapels it serves.

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string | yes | The church id, a slug of its name and city |

Responses:

- `200` `Church & { parish?: ChurchKin, chapels: ChurchKin[] }`: OK
- `404` `Error`: No church has this id: `not_found`

Example:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp'
```

### GET /churches/{id}/verifications

**Confirmations of a church’s times.** When people confirmed the times on record, newest first.

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string | yes | The church id, a slug of its name and city |
| `limit` | query | integer |  | 1–100; default 25 |
| `offset` | query | integer |  | ≥ 0; default 0 |

Responses:

- `200` `{ verifications: Verification[] }`: OK
- `400` `ValidationError`: A parameter or the body failed validation

Example:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp/verifications?limit=5'
```

## Changes

What the moderators changed, for directories that keep in step

### GET /changes

**The change log.** Every change a moderator applied to the directory and that still stands, newest first. Each church it touched comes with what changed and its schedule as it now stands, and `message` says the same as plain text, ready to pass on. Pass the `next` of one page as the `before` of the next; to keep in step, ask with `since` set to the newest `appliedAt` you hold.

[Recent changes](https://ember.dpgu.me/mass-times/changes/) shows the same log as a page.

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `since` | query | string |  | Only changes applied after this instant (ISO 8601); matches ^(?:(?:\d\d[2468][048]\|\d\d[13579][26]\|\d\d0[48]\|[02468][048]00\|[13579][26]00)-02-29\|\d{4}-(?:(?:0[13578]\|1[02])-(?:0[1-9]\|[12]\d\|3[01])\|(?:0[469]\|11)-(?:0[1-9]\|[12]\d\|30)\|(?:02)-(?:0[1-9]\|1\d\|2[0-8])))T(?:(?:[01]\d\|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z\|([+-](?:[01]\d\|2[0-3]):[0-5]\d)))$ |
| `before` | query | string |  | The last id of the previous page; at most 256 characters |
| `church` | query | string |  | Only changes that touched this church; at most 256 characters |
| `lang` | query | "en" \| "pt" |  | The language of every message; by default each church’s own |
| `limit` | query | integer |  | 1–100; default 50 |

Responses:

- `200` `{ changes: { id: string, appliedAt: string, churches: ChangedChurch[], message: string }[], next?: string }`: OK
- `400` `ValidationError`: A parameter or the body failed validation

Example:

```sh
curl 'https://ember-mass-times.dpgu.workers.dev/changes?limit=5'
```

## Suggestions

Rate-limited writes, reviewed before they apply

### POST /churches/{id}/verify

**Confirm the times on record.** Counts once per client and church in 30 days; a repeat answers `deduped: true` and records nothing.

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string | yes | The church id, a slug of its name and city |
| `X-Client-Id` | header | string |  | A stable id per device or person; with the IP, it identifies the client for the rate limit and for confirmations |

Request body:

| Field | Type | Required | Notes |
|---|---|---|---|
| `serviceId` | string |  | The service confirmed; none confirms the whole church |

Responses:

- `200` `{ deduped: boolean }`: OK
- `400` `ValidationError`: A parameter or the body failed validation
- `429` `Error`: Too many writes from this client: `rate_limited`

Example:

```sh
curl -X POST 'https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp/verify' \
  -H 'Content-Type: application/json' -H 'X-Client-Id: device-7f3a2c' \
  -d '{}'
```

### POST /churches/{id}/corrections

**Suggest a correction.** Queues a change to a service or to the church, a new service, a closure, or a note, for a moderator. `kind` decides the shape of the rest.

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string | yes | The church id, a slug of its name and city |
| `X-Client-Id` | header | string |  | A stable id per device or person; with the IP, it identifies the client for the rate limit and for confirmations |

Request body:

`kind: "edit_service"`

| Field | Type | Required | Notes |
|---|---|---|---|
| `kind` | "edit_service" | yes |  |
| `serviceId` | string | yes |  |
| `changes` | object | yes |  |
| `↳ kind` | string |  |  |
| `↳ rite` | string |  |  |
| `↳ language` | string |  |  |
| `↳ rrule` | string |  |  |
| `↳ startTime` | string |  |  |
| `↳ endTime` | string |  |  |
| `↳ exdate` | string |  |  |
| `↳ rdate` | string |  |  |
| `↳ locationNote` | string |  |  |
| `↳ note` | string |  |  |
| `comment` | string |  | at least 1 characters; at most 2000 characters |
| `attachmentKeys` | string[] |  | at most 10 items |

`kind: "edit_church"`

| Field | Type | Required | Notes |
|---|---|---|---|
| `kind` | "edit_church" | yes |  |
| `changes` | object | yes |  |
| `↳ name` | string |  |  |
| `↳ longName` | string |  |  |
| `↳ address` | string |  |  |
| `↳ city` | string |  |  |
| `↳ region` | string |  |  |
| `↳ postalCode` | string |  |  |
| `↳ country` | string |  |  |
| `↳ phoneE164` | string |  |  |
| `↳ email` | string |  |  |
| `↳ status` | string |  |  |
| `↳ administration` | string |  |  |
| `↳ institute` | string |  |  |
| `↳ canonicalStatus` | string |  |  |
| `↳ note` | string |  |  |
| `comment` | string |  | at least 1 characters; at most 2000 characters |
| `attachmentKeys` | string[] |  | at most 10 items |

`kind: "add_service"`

| Field | Type | Required | Notes |
|---|---|---|---|
| `kind` | "add_service" | yes |  |
| `changes` | object | yes |  |
| `↳ kind` | string | yes |  |
| `↳ rite` | string |  |  |
| `↳ language` | string |  |  |
| `↳ rrule` | string | yes |  |
| `↳ startTime` | string | yes |  |
| `↳ endTime` | string |  |  |
| `↳ exdate` | string |  |  |
| `↳ rdate` | string |  |  |
| `↳ locationNote` | string |  |  |
| `↳ note` | string |  |  |
| `comment` | string |  | at least 1 characters; at most 2000 characters |
| `attachmentKeys` | string[] |  | at most 10 items |

`kind: "flag_closed"`

| Field | Type | Required | Notes |
|---|---|---|---|
| `kind` | "flag_closed" | yes |  |
| `serviceId` | string |  |  |
| `comment` | string |  | at least 1 characters; at most 2000 characters |
| `attachmentKeys` | string[] |  | at most 10 items |

`kind: "note"`

| Field | Type | Required | Notes |
|---|---|---|---|
| `kind` | "note" | yes |  |
| `serviceId` | string |  |  |
| `comment` | string | yes | at least 1 characters; at most 2000 characters |
| `attachmentKeys` | string[] |  | at most 10 items |

Responses:

- `201` `{ id: string }`: Queued
- `400` `ValidationError`: A parameter or the body failed validation
- `429` `Error`: Too many writes from this client: `rate_limited`

Example:

```sh
curl -X POST 'https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp/corrections' \
  -H 'Content-Type: application/json' -H 'X-Client-Id: device-7f3a2c' \
  -d '{"kind": "note", "comment": "From October the Sunday Mass is at 9:30."}'
```

### POST /churches/{id}/corrections/attachments

**Attach a photo to a correction.** The raw bytes of one file of at most 1 MB, such as a photo of the bulletin. Send the returned `key` in the correction’s `attachmentKeys`.

| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `id` | path | string | yes | The church id, a slug of its name and city |
| `X-Client-Id` | header | string |  | A stable id per device or person; with the IP, it identifies the client for the rate limit and for confirmations |

Request body:

| Field | Type | Required | Notes |
|---|---|---|---|

Responses:

- `201` `{ key: string }`: Stored
- `400` `Error`: An empty body: `empty_body`
- `413` `Error`: Larger than 1 MB: `too_large`
- `429` `Error`: Too many writes from this client: `rate_limited`

Example:

```sh
curl -X POST 'https://ember-mass-times.dpgu.workers.dev/churches/ns-do-carmo-se-sao-paulo-sp/corrections/attachments' \
  -H 'Content-Type: image/jpeg' -H 'X-Client-Id: device-7f3a2c' \
  --data-binary @bulletin.jpg
```

## Other

### GET /health

**Liveness.**

Responses:

- `200` `{ ok: boolean }`: OK

## Schemas

### Church

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | string | yes |  |
| `name` | string | yes |  |
| `longName` | string \| null | yes |  |
| `address` | string \| null | yes |  |
| `city` | string \| null | yes |  |
| `region` | string \| null | yes |  |
| `postalCode` | string \| null | yes |  |
| `country` | string \| null | yes |  |
| `countryCode` | string \| null | yes |  |
| `lat` | number | yes |  |
| `lng` | number | yes |  |
| `geohash` | string | yes |  |
| `timezone` | string | yes |  |
| `phoneE164` | string \| null | yes |  |
| `email` | string \| null | yes |  |
| `status` | string \| null | yes |  |
| `featured` | boolean \| null | yes |  |
| `administration` | string \| null | yes |  |
| `institute` | string \| null | yes |  |
| `canonicalStatus` | string \| null | yes |  |
| `parishId` | string \| null | yes |  |
| `note` | string \| null | yes |  |
| `hasStructuredSchedule` | boolean \| null | yes |  |
| `lastVerifiedAt` | string \| null | yes |  |
| `verifiedSource` | string \| null | yes |  |
| `updatedAt` | string \| null | yes |  |
| `services` | Service[] \| null | yes |  |
| `texts` | ChurchText[] \| null | yes |  |
| `links` | ChurchLink[] \| null | yes |  |

### Service

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | string | yes |  |
| `kind` | string | yes | mass \| confession \| adoration |
| `rite` | string |  | Mass only: latin_novus_ordo \| latin_tridentine \| byzantine \| … |
| `language` | string |  | ISO 639 |
| `rrule` | string | yes | iCal RRULE: one pattern at one time |
| `startTime` | string | yes | 'HH:MM' wall-clock in the church's timezone |
| `endTime` | string |  |  |
| `exdate` | string |  | RRuleSet EXDATE: cancelled dates |
| `rdate` | string |  | RRuleSet RDATE: one-off added dates |
| `locationNote` | string |  |  |
| `note` | string |  |  |
| `source` | string |  | import \| manual \| user |
| `confidence` | number |  |  |

### ChurchText

| Field | Type | Required | Notes |
|---|---|---|---|
| `kind` | string | yes | mass_times \| seasonal_mass_times \| confession \| adoration \| info |
| `rawText` | string | yes |  |
| `sourceUpdatedAt` | string |  |  |

### ChurchLink

| Field | Type | Required | Notes |
|---|---|---|---|
| `kind` | string | yes | website \| instagram \| facebook \| whatsapp \| youtube \| livestream \| donation |
| `url` | string | yes |  |

### ChurchKin

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | string | yes |  |
| `name` | string | yes |  |

### Cluster

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | string | yes | The geohash cell |
| `lat` | number | yes |  |
| `lng` | number | yes |  |
| `count` | integer | yes |  |
| `church` | ChurchKin |  | Named when the cell holds one church |

### Verification

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | string | yes |  |
| `churchId` | string | yes |  |
| `serviceId` | string \| null | yes |  |
| `fingerprint` | string | yes |  |
| `createdAt` | string | yes |  |

### ChangeLine

| Field | Type | Required | Notes |
|---|---|---|---|
| `label` | string | yes |  |
| `before` | string |  |  |
| `after` | string |  |  |

### ChangedChurch

| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | string | yes |  |
| `name` | string | yes |  |
| `isNew` | boolean | yes | Created by this change |
| `parish` | ChurchKin |  | The parish this church is a chapel of |
| `place` | string | yes |  |
| `lat` | number | yes |  |
| `lng` | number | yes |  |
| `changes` | ChangeLine[] | yes |  |
| `schedule` | { kind: string, label: string, times: string[] }[] | yes |  |

### Error

| Field | Type | Required | Notes |
|---|---|---|---|
| `error` | string | yes |  |

### ValidationError

`success: false`

| Field | Type | Required | Notes |
|---|---|---|---|
| `success` | false | yes |  |
| `error` | object | yes |  |
| `↳ name` | "ZodError" | yes |  |
| `↳ message` | string | yes |  |
