@acms-subir/client
Dependency-free ESM TypeScript client for ACMS public API v1. Supports Expo/React
Native, browsers and Node 18+ using global fetch, URL, Headers and
AbortController. Supply fetch when your environment needs an adapter.
npm install @acms-subir/client
import { createClient, isAcmsError } from '@acms-subir/client';
const client = createClient({ baseUrl: 'https://example.com', locale: 'es' });
const site = await client.site();
const home = await client.home();
const page = await client.page('about/team');
for await (const post of client.paginate.posts({ collection: 'blog', limit: 50 })) {
console.log(post.title);
}
const english = client.withLocale('en');
baseUrl accepts a site origin or a full /api/v1 URL, with an optional
hosting prefix. All methods return unwrapped data; openapi() returns the
raw document. Every method accepts locale and signal in its options object.
Locale is sent only for routes that support translations. withLocale shares
the cache and transport configuration with the original client. site() and
pushTopics() take a language since core 0.3.32; an older site rejects it, so
the client asks once more without it and gets the main language. book()
sends the client's locale in its body, so the booking's emails (and the links
in them) speak the app's language; a site before core 0.3.38 refuses that field,
so the client books once more without it, with the same key, in the site's main
language. Update the site before shipping an app build with a newer client.
| Method | Options / behavior |
|---|---|
site(), theme(), home() | Identity (including legal.privacy / legal.terms URLs to link in the app), tokens, resolved homepage |
page(slug, opts) | Resolved page including sections; nested slugs supported |
content(opts) | kind, collection, q, page, limit, cursor |
contentById(id, opts) | Optional kind to disambiguate imported IDs |
posts(opts) | Content filtered to posts; collection, tag, q, pagination |
post(slug, opts) | Scans post summaries, then loads detail by ID; pass collection for ambiguous slugs |
pages(opts) | Page summaries, with pagination; use page() for sections |
search(text, opts) | Top matches in posts, pages and events, plus more per group; types, collection, limit (1–20, default 5). Blank text resolves empty without a request |
events(opts) | Occurrences with from, to, calendar, tag, excludeTag, room, q, pagination. The filters take one value or an array (any of them matches) |
event(slug, opts) | One event by slug, whatever its date (GET /events/{slug}; on sites older than core 0.3.40, the first match in the requested window) |
eventDetail(slug, { on? }) | The event and its next occurrence, or the one on on (YYYY-MM-DD) |
calendars() | Public calendars |
resources(opts), resource(slug, opts) | Public resources; slug lookup scans list pages |
availability(opts) | Required resource, link; optional from, days, minutes |
roomFree({ room, start, end }) | Free or busy for a room the site lets people check, with the busy times and no names; never cached (core 0.3.45+) |
rsvpStatus(slug, { start }) | One event date's RSVP status: open or reason (disabled, ended, closed past the event's deadline, cancelled, full), closesAt (when RSVPs to the date close, or null: open until it ends), live going/left (held waitlist spots count as taken), maxSeats for one RSVP right now, autoConfirm, waitlist (enabled: a full date takes places in line; waiting people for seats seats; maxSeats for one place in line), and form (questions and version, or null). start is the occurrence's start exactly as events() returns it (a UTC ISO string such as 2026-10-05T14:00:00.000Z). Never cached |
book(opts) | Resource booking or RSVP input (RsvpInput for an RSVP; waitlist: true joins a full date's waitlist, status waitlisted), optional idempotencyKey; sends the client's locale (or opts.locale) for its emails |
openapi() | Raw OpenAPI document |
registerDevice({ token, platform, locale?, topics?, channels? }) | Register or refresh an Expo push token (call on every app start); { created, device }. channels: Android channels the app created (e.g. ['urgent']) |
updateDevice(token, { topics?, locale?, channels? }) | Change a device's topics, language or channels |
unregisterDevice(token) | Opt out (idempotent); also unlinks a staff test phone |
pushTopics() | Topics to offer as notification toggles, named in the client's language (built-in and the site's own, kind: 'site') |
bibles(), biblePassage(ref, { bible }), bibleChapter(book, chapter, { bible }) | Bible verses for the Bible links in posts (Settings → Bible, core 0.3.84+): the site's Bibles, a passage (a link's ?ref=), a chapter with its headings and neighbours; 404 while off |
linkStaffPhone(token, code) | Staff only: link this registered device with the 6-digit code from the admin's Test phones, so it gets that person's test notifications; { linked, staff: { name } }. A wrong, used or expired code is a 400, too many tries a 429; never retried |
unlinkStaffPhone(token) | Unlink it (idempotent); { linked: false, staff: null } |
links() | The links page: profile, socials and visible blocks (link, email, phone, contact, heading, text, spacer, live, embed) |
live() | Live video: the current broadcast (snapshot or null), serverNow and the owner's display choices. Never cached; 404 while site.features.live is false |
announcements({ kind?, pinned?, following?, scope?, page?, limit? }), announcement(id), paginate.announcements() | Announcements up right now in the client's language, newest first (pinned: true: the banners, urgent first); 404 while site.features.announcements is false |
form(id), submitForm(id, { answers, version?, idempotencyKey? }) | A published form's questions; send answers (checkbox groups as arrays). Each call makes one key; retries reuse it |
requestOptions(), submitRequest(input, { idempotencyKey? }) | Event requests without an account: what the form offers (open, types, rooms, replyTime, notice, phone, timezone; never cached), and asking for a new event or a change ({ id }). 404 while site.features.requests is false. See Event requests below |
manageBooking(token), cancelBooking(token) | The booker's own booking from its manageToken, and cancelling it (or leaving a waitlist, or releasing a held spot); can says what is allowed. A waitlisted RSVP has booking.position (its place in line), an offered one booking.holdUntil |
confirmBooking(token) | Take a spot the waitlist offered (can.confirm): confirmed, or pending when RSVPs need approval. 410 once holdUntil passed. Core 0.3.40 or later |
rescheduleAvailability(token, { from?, days?, minutes? }), rescheduleBooking(token, { start, minutes? }) | Open times for moving it (its own time counts as free), then the move |
bookingIcs(token) | The booking as an iCalendar file (string) |
requestGuestCode(email), guestSignIn({ email, code, deviceName? }), guestBookings(guestToken, { when? }), guestSignOut(guestToken) | Guest sign-in for "My bookings" |
The server has no post-slug, event-detail or resource-detail endpoint. Those
convenience methods may make multiple requests. event() only finds events
with occurrences in its window (default: next 30 UTC days); an unlisted
calendar requires calendar. It does not fetch arbitrary historical events.
If post slugs collide and no collection is supplied, the first listed match wins.
Search needs site.features.search (on unless the site sets
acms({ search: { enabled: false } })). It uses the website's search index and
ranking: titles count most, then excerpts, tags and body text. Every word
must match, and the last one matches as a prefix (birth finds "birthday"). q on content()/posts() returns
ranked summaries, posts before pages, and pages through at most 200 matches per
kind. search() is for a combined results screen: each hit carries a plain-text
snippet with highlights ({ start, end } ranges) to bold, and events add
their next occurrence. Use more.posts to offer "See all", then
posts({ q, collection }). Matching uses the main-language text, so a
translated title is shown but not searched.
Pagination uses page/nextPage, not opaque cursors. cursor is a numeric
alias for page; an explicit page wins. tag filters each fetched page
locally, so a page can be empty with a non-null nextPage. Iterators continue
through these pages. paginate.content/posts/pages/events/resources yield
individual items (events yields occurrences). They do not deduplicate changing
server results or provide snapshot consistency; deduplicate IDs in your app.
Bookings, errors and cancellation
import type { RsvpInput } from '@acms-subir/client';
// Persist a UUID v4 with this booking intent before submitting; reuse it until
// the outcome is known. Use your platform's secure UUID/storage facilities.
const idempotencyKey = crypto.randomUUID();
try {
const result = await client.book({
resource: 'hall', link: 'meeting', start: '2026-10-05T14:00:00Z', minutes: 60,
name: 'Example Guest', email: '[email protected]', idempotencyKey,
});
// Store result.manageToken securely; it grants booking-management access.
} catch (error) {
if (isAcmsError(error)) {
console.log(error.status, error.code, error.message, error.retryAfter);
// error.idempotencyKey preserves even an automatically generated key.
}
}
// RSVP to one date of an event (needs only the calendar: site.features.rsvp)
const { occurrences } = await client.events({ q: 'supper' });
const date = occurrences[0];
const status = await client.rsvpStatus(date.event.slug, { start: date.start });
if (status.open) {
const rsvp: RsvpInput = {
event: date.event.slug, start: date.start, seats: 2, name: 'Example Guest', email: '[email protected]',
// field id → answer; checkbox groups send a list
answers: { meal: 'Vegetarian', help: ['Setup', 'Music'] },
// required when the event asks questions: a changed form answers 409, so read the status again
...(status.form ? { formVersion: status.form.version } : {}),
};
// an email that already has an RSVP for this date: AcmsError 409 with code 'duplicate' (its link is emailed again)
const result = await client.book({ ...rsvp, idempotencyKey: crypto.randomUUID() });
// pending when the organiser approves RSVPs; status.form?.confirmation is the thank-you text.
// result.manageToken changes or cancels it later (manageBooking, cancelBooking), like the email's link:
// keep it in secure storage only.
console.log(result.booking.status);
} else if (status.reason === 'full' && status.waitlist.enabled) {
// a place in line (status 'waitlisted', no seats); when a spot opens the site emails them and holds it
// for 24 hours: then manageBooking shows status 'offered' and holdUntil, and confirmBooking(token) takes it
const result = await client.book({
event: date.event.slug, start: date.start, seats: 2, name: 'Example Guest', email: '[email protected]', waitlist: true,
...(status.form ? { formVersion: status.form.version } : {}), idempotencyKey: crypto.randomUUID(),
});
console.log(result.booking.status);
}
const controller = new AbortController();
const pending = client.home({ signal: controller.signal });
controller.abort();
await pending.catch(() => {});
Requests that are safe to repeat (GET, PUT, DELETE, device registration, and
POSTs with an Idempotency-Key: bookings, form responses and event requests) retry network
failures, 5xx and 429 responses at most twice (three attempts total), with
250/500ms backoff and at least the server's Retry-After delay (seconds or HTTP
date). A wait that would outlast timeoutMs throws the 429 straight away, with
retryAfter. One-shot writes (guest code requests and sign-in, cancelling and
moving a booking, confirming a held spot, guest sign-out) are never repeated automatically: on a
network error, check the state before trying again. Booking retries always
reuse the same serialized body and UUID. By default each book() call creates
a key using crypto.randomUUID, falling back to a Math.random UUID v4 where
unavailable. For recovery across calls/app restarts, supply and persist your own
secure UUID. Never create a new intent merely because a response timed out.
timeoutMs defaults to 30,000 and covers the entire request, including retries,
cache access and body reading. A longer rate-limit delay may exhaust that
budget. Explicit aborts and timeouts are not automatically retried; an uncertain
booking must be retried with its existing key. AcmsError exposes status,
code, message, retryAfter (seconds), optional validation issues and
idempotencyKey. Local failures use status 0 and network_error, timeout,
aborted, or invalid_response; non-JSON HTTP errors use http_error.
Convenience lookups apply the timeout separately to each underlying request.
Event requests
A site can take event requests from people
without an account (acms({ requests: true }), so site.features.requests
is true; staff answer them in Calendar → Requests). Someone asks for
something new, or for a change to an event, and gives a name, an email and
optionally a phone. Nothing goes on the calendar until staff approve it.
import type { RequestInput } from '@acms-subir/client';
const options = await client.requestOptions();
// { open, types: [{ id, label }], rooms: [{ slug, name, color }], replyTime: '3 days', notice, phone, timezone }
if (!options.open) {
// say "We aren't taking requests here right now." and show options.phone; keep the screen itself reachable
}
const ask: RequestInput = {
kind: 'new', type: options.types[0]?.id, title: 'Guitar workshop',
// the site's wall clock (options.timezone), in the future; the end may be just HH:mm on the same day
start: '2026-11-07T18:00', end: '20:00',
repeat: 'weekly', // 'once' (default), 'weekly', or 'other' with repeatText
rooms: ['fellowship-hall'], // slugs from options.rooms, or roomsUnsure: true
people: 12, message: 'We bring our own chairs.',
name: 'Example Guest', email: '[email protected]', phone: '555 0100',
};
const { id } = await client.submitRequest(ask, { idempotencyKey: crypto.randomUUID() });
// a change to a public event: one date of it (YYYY-MM-DD, the site's day), or leave date out for every date
await client.submitRequest({ kind: 'change', event: 'fall-fair', date: '2026-11-08', message: 'Could it start at 10?', name: 'Example Guest', email: '[email protected]' });
- Check whether a room looks free first with
roomFree({ room, start, end })(needs bookings on the site; the times there take a zone, so convert the wall-clock times withoptions.timezone). - Keep one Idempotency-Key per request and reuse it for retries: a retry
answers the same
{ id }, and the same key with other details is a 409changed. Each call withoutidempotencyKeymakes a new one. - The client's
locale(orinput.locale) goes in the body, so the emails about the request speak the app's language. - A refusal is an
AcmsErrorwhosecodesays why:closed(409, requests closed meanwhile), a field (400:title,type,start,end,past,far,repeat,room,people,event,date,what(the change's message),name,email,phone),changed(409), orbusy/full(429, withretryAfter). 10 requests a minute per address, plus the site's daily cap.
Push notifications
Available when the site enables push (acms({ mobile: { push: true } }));
otherwise these return a 404 AcmsError. Tokens come from
expo-notifications (getExpoPushTokenAsync). The site stores the token,
platform, language and topics only.
const { topics } = await client.pushTopics(); // [{ id: 'news', label: 'News', kind: 'general' }, …]
await client.registerDevice({ token, platform: 'ios', locale: 'es', topics: ['news', 'events'] });
await client.updateDevice(token, { topics: ['events'] });
await client.unregisterDevice(token);
Following one event: event.follow.topic (event:<id>, null when it can't be
followed now) goes in the device's topics like any other; the site drops it
once the event has no dates left. The announcements list takes the events a
phone follows:
const topics = [...device.topics, event.follow.topic!];
await client.updateDevice(token, { topics });
const following = followedEvents(topics); // event ids
await client.announcements({ following }); // + their messages and notices (kind 'message')
await client.announcements({ following, scope: 'following' }); // only those ("My classes")
A follower can also pick a reminder before each date, among
event.follow.reminders ([{ id: '1h' | 'evening', topic, at }], at the
evening's time, HH:mm site time; [] when the calendar offers none): its
topic (event:<id>:remind:1h) goes in the topics beside the event's, one
at a time; event.follow.reminderStart is what a new follower starts on
('none' = no reminder until they pick). The site keeps a reminder only beside
its event's topic, and drops it when the calendar stops offering it.
import { reminderOf, withReminder } from '@acms-subir/client';
reminderOf(device.topics, event.id); // '1h' | 'evening' | null
await client.updateDevice(token, { topics: withReminder(device.topics, event.id, 'evening') }); // "The evening before, at 7:00 PM"
await client.updateDevice(token, { topics: withReminder(device.topics, event.id, null) }); // no reminder
event.hosts names its hosts when its calendar shows them; event.hostsLabel
is what the calendar calls them in the request's language ("Maestro/a"), or
'' when it sets nothing (use your own words, like "Led by").
Staff can link their own phone for test notifications ("Send test to my
phone" in the admin): the admin's Test phones panel shows a one-time 6-digit
code (10 minutes, single use) that the app sends with this phone's token.
device.staff (from registerDevice and updateDevice) and the link
response name who it's linked to (their name only), or null. Test pushes
carry data.test: true.
const { staff } = await client.linkStaffPhone(token, '123456'); // { name: 'Ana López' }; 400 for a wrong, used or expired code
await client.unlinkStaffPhone(token); // turning notifications off unlinks it too
Live video
site.features.live is true while the site's live video (Integrations → Live
video) isn't off. live() returns the same snapshot the website shows:
status (live, upcoming or offline for the "Watch our channel" fallback),
title, watchUrl, an absolute thumbUrl (empty when there's none),
startedAt, expiresAt and optional channel and viewers. Times are Unix
milliseconds on the server's clock: keep serverNow - Date.now() as an
offset, and stop treating a snapshot as current once expiresAt (when nonzero)
passes on that clock, even if later requests fail. display carries the
owner's choices (style: poster, split or row; bar, header, dock)
so an app can follow the website. Poll about every 30 seconds while the app is
in the foreground; @acms-subir/native's useLive() does all of this. The
"we're live" notification is the push topic live.
const { snapshot, serverNow } = await client.live();
const offset = serverNow - Date.now();
const current = snapshot && (!snapshot.expiresAt || snapshot.expiresAt > Date.now() + offset) ? snapshot : null;
if (current?.status === 'live') console.log(`Live: ${current.title} → ${current.watchUrl}`);
Cache and headers
ETag revalidation defaults to an in-memory URL-keyed cache. Each read contacts
the server with If-None-Match; a 304 reuses the saved data. This is not an
offline fallback. Treat returned cached objects as immutable. Use cache: false
to disable it. Availability, live status, RSVP status, booking results and errors are never cached.
Responses without an ETag or with no-store invalidate an older entry.
import type { ClientCache } from '@acms-subir/client';
const cache: ClientCache = {
async get(url) { /* retrieve { etag, body } from app storage */ return null; },
async set(url, entry) { /* persist entry; null means delete */ },
};
const persistent = createClient({ baseUrl: 'https://example.com/api/v1', cache });
Adapters may be synchronous or asynchronous. Keep persistent caches scoped to
one site's public API and account for storage limits. Cache adapter failures
propagate to the caller. Custom headers are supported; do not embed privileged
credentials. Requests omit browser credentials. Conditional headers are managed
by the client; a per-call booking key overrides a configured header key.
Development
npm run build -w packages/client
npm run test -w packages/client
npm run check:types -w packages/client
npm run generate:types -w packages/client # after reviewing core schema changes
The contract test imports the actual core OpenAPI generator using core's virtual module test stubs, compares all GET/POST paths to the client method registry, and checks generated request/response types byte-for-byte. HEAD and OPTIONS are transport operations and have no separate data-returning methods. No core code, Zod, or Node built-ins ship in this package's runtime.
Managing bookings and "My bookings"
// a booking the app made (result.manageToken from book()), or one from guestBookings()
const view = await client.manageBooking(manageToken);
if (view.can.reschedule) {
const open = await client.rescheduleAvailability(manageToken, { days: 7 });
await client.rescheduleBooking(manageToken, { start: open.days[0].slots[0].start });
}
if (view.can.cancel) await client.cancelBooking(manageToken);
// guest sign-in (check site.features.guestSignIn first)
await client.requestGuestCode('[email protected]');
const { accessToken } = await client.guestSignIn({ email: '[email protected]', code: '481203' });
const mine = await client.guestBookings(accessToken, { when: 'upcoming' });
Booking and guest tokens travel in headers (Booking-Token,
Authorization: Bearer), never in URLs, and their responses are never cached.
Keep both in secure storage (expo-secure-store). can.closed means the
booking type's change cutoff has passed: show "Contact us" instead of the
buttons. A guest token ends after 90 days unused; 401 means sign in again.
Editor apps (staff sign-in)
For sites with acms({ mobile: { editor: { redirectUris } } }). Staff sign in
in the system browser (OAuth 2.0 + PKCE); the app never sees a password.
import { createEditorAuth, createEditorSession, createEditorClient } from '@acms-subir/client';
const auth = createEditorAuth({ baseUrl: 'https://example.com', clientId: 'org.example.staff', redirectUri: 'org.example.staff://auth' });
const session = createEditorSession({ auth, storage /* get/set backed by expo-secure-store */, onSignedOut });
const api = createEditorClient({ baseUrl: 'https://example.com', session });
const { url, verifier, state } = await auth.start({ deviceName: 'Ana’s iPhone' });
// open `url` with expo-web-browser openAuthSessionAsync, then:
const { code } = auth.parseRedirect(returnedUrl, state);
await session.signIn(await auth.exchangeCode({ code, codeVerifier: verifier }));
const doc = await api.getDocument('post', id);
await api.saveDocument('post', id, { revision: doc.revision, fields: { title: 'New title' } });
In React Native pass crypto: { randomBytes, sha256 } from expo-crypto to
createEditorAuth (Web Crypto is the default). Refreshes are single-flight;
a 401 is retried once after a refresh, then onAuthError runs. Writes carry
the revision you loaded (409 when someone saved first).
Schedule and setup: schedule({ from, to, resource? }) (up to 63 days of
bookings, blocked time, events and feeds), createBooking() (on someone's
behalf), rescheduleBooking(id, …), listResources() / getResource() /
createResource() / updateResource() / deleteResource(),
createBookingLink() / updateBookingLink() / deleteBookingLink(),
blockTime() / unblockTime(), listCalendars() / createCalendar() /
updateCalendar() / deleteCalendar(), listForms() /
listFormResponses(), getLinksPage() / saveLinksPage().
updateBookingLink(resourceId, linkId, { order: 0 }) puts a booking type first
in its room, the one its booking page and apps lead with. Setup writes need
me().permissions.schedule.manage; form responses and HTML link blocks need
an administrator or owner (403 otherwise).