Event wizard: one date or multiple sessions with a repeat-pattern builder, and a new Attendance step (late joining and pricing, check-in window, session reminders, certificates with preview). Public pages show the schedule and late prices; the scanner picks today's session; new attendance register page, My Certificates, and a public verify page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
44 KiB
Hope Events — API Documentation
Full interactive docs (with request/response examples) are available at GET /docs?token=<admin-jwt> on the running backend.
All authenticated requests require:
Authorization: Bearer <jwt>
Tokens are issued by POST /api/users/login.
Table of Contents
- Users
- Events
- Event Options & Variants
- Event Attachments
- Registrations
- Coupons
- Parental consent
- Waitlist
- Multi-day events & attendance
- Offline scanning
- Ticket transfer
- Rooms & groups
- Sponsor a spot
- Post-event feedback
- Registration funnel
- Impersonation
- Payments
- Tickets
- Broadcasts (Email)
- WhatsApp Broadcasts
- Scheduled Messages
- Automations
- Reports
- Uploads
- Sections
- Banner
- Forms
- Yoco Transactions
- WhatsApp Admin
- Webhooks
- Settings
- Setup
- Static Files
Users
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/users |
public | Register new account |
| POST | /api/users/login |
public | Login, returns JWT — or a two-factor challenge when the account has 2FA on |
| POST | /api/users/login/2fa |
public | Exchange a two-factor code for the JWT |
| POST | /api/users/login/2fa/resend |
public | Send a new code for a pending login challenge (60s cooldown) |
| POST | /api/users/forgot |
public | Request password reset email |
| POST | /api/users/reset |
public | Reset password with token |
| POST | /api/users/activate |
public | Activate account from email link |
| GET | /api/users/profile |
user+ | Own profile |
| PUT | /api/users/profile |
user+ | Update own profile |
| POST | /api/users/revoke-sessions |
user+ | Revoke own sessions |
| POST | /api/users/close-account |
user+ | Deactivate own account |
| POST | /api/users/2fa/enable |
user+ | Start turning on 2FA — sends a code to the chosen channel |
| POST | /api/users/2fa/confirm |
user+ | Finish turning on 2FA with that code |
| POST | /api/users/2fa/disable |
user+ | Turn off 2FA (requires password) |
| POST | /api/users/verify-password |
user+ | Re-confirm own password without starting a new session |
| GET | /api/users |
supervisor+ | List users — supports ?search=, ?role=, ?isActive=true|false, ?page=, ?limit= |
| GET | /api/users/:id |
admin | Get user by ID |
| PUT | /api/users/:id |
admin | Update user (name, email, role, isActive, phoneNumber). twoFactorEnabled: false resets a user's 2FA; it cannot be switched on this way |
| DELETE | /api/users/:id |
admin | Deactivate user (isActive: false) |
| POST | /api/users/:id/revoke-sessions |
admin | Revoke all sessions for a user |
| POST | /api/users/:id/anonymize |
admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account |
POST /api/users
{
"name": "Jane Smith",
"email": "jane@example.com",
"password": "securepass123",
"phoneNumber": "+27821234567"
}
Returns: { _id, name, email, role, token }
POST /api/users/login
{ "email": "jane@example.com", "password": "securepass123", "deviceToken": "optional-trusted-device-token" }
Returns: { _id, name, email, role, token }
If the account has two-factor authentication on and deviceToken is missing, expired or not recognised, no token is issued. A 6-digit code is sent by email or WhatsApp and the response is instead:
{ "twoFactorRequired": true, "challengeId": "uuid", "method": "email", "destination": "j•••@example.com" }
Required for admins and supervisors. An admin or supervisor without 2FA gets the same challenge with "enrolling": true (trusted devices don't skip it), sent to their email when SMTP is configured, otherwise WhatsApp. Verifying it turns 2FA on with that channel. If neither channel can deliver a code, the requirement is skipped so the account isn't locked out. TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off.
POST /api/users/login/2fa
{ "challengeId": "uuid", "code": "123456", "rememberDevice": true }
Returns: { _id, name, email, role, token }, plus deviceToken when rememberDevice is true. Send that deviceToken with later logins to skip the code for 30 days. Codes expire after 10 minutes and allow 5 attempts. Trusted devices are cleared when the password changes or sessions are revoked.
Two-factor setup (/api/users/2fa/*)
POST /api/users/2fa/enable—{ "method": "email" | "whatsapp", "password": "..." }sends a code to that channel and returns{ challengeId, method, destination }. WhatsApp needs a valid phone number on the account and a connected WhatsApp session.POST /api/users/2fa/confirm—{ "challengeId": "uuid", "code": "123456" }turns 2FA on.POST /api/users/2fa/disable—{ "password": "..." }turns it off. Returns 403 for admins and supervisors, who must keep it on (they can switch channel withenable/confirm).
An admin or supervisor session without 2FA (from before the requirement, or after a promotion or 2FA reset) is rejected by every protected route with 401 { "code": "TWO_FACTOR_REQUIRED" }; logging in again enrols them. Promoting a user to supervisor/admin ends their sessions when they don't have 2FA yet.
At login, if a WhatsApp code can't be sent, the code falls back to the account's email address.
PUT /api/users/profile
{ "name": "Jane Doe", "phoneNumber": "+27829999999", "notificationPreference": "email" }
notificationPreference: "email" | "whatsapp" | "both"
Events
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/events |
public | Active, public, upcoming events. Includes isSoldOut: boolean per event. |
| GET | /api/events/all |
staff+ | All events including hidden/past (?includePast=true, ?includeInactive=true) |
| GET | /api/events/admin/all |
admin | Admin view of all events |
| GET | /api/events/:id |
public | Full event detail — options, variants, earlyBirdTiers, attachments, form, availableCount per option/variant |
| GET | /api/events/by-alias/:redirectUrl |
public | Get event by URL alias |
| POST | /api/events |
supervisor+ | Create event |
| PUT | /api/events/:id |
supervisor+ | Update event |
| DELETE | /api/events/:id |
admin | Delete event |
| POST | /api/events/:id/email-attendees |
supervisor+ | Bulk email attendees (dryRun: true to preview) |
| POST | /api/events/:id/email-attendees/schedule |
supervisor+ | Schedule bulk email |
| POST | /api/events/:id/whatsapp-attendees |
supervisor+ | Bulk WhatsApp to attendees (dryRun: true to preview) |
| POST | /api/events/:id/whatsapp-attendees/schedule |
supervisor+ | Schedule bulk WhatsApp |
| GET | /api/events/attachments/status |
admin | Attachment manifest sync status |
| POST | /api/events/attachments/sync |
admin | Re-sync attachment manifests to DB |
isSoldOut logic (GET /api/events)
isSoldOut is true when every option on the event that has stockLimit > 0 has sold out its full allocation (computed from non-cancelled RegistrationOption quantities). Events with no limited options are never marked sold out.
POST /api/events
{
"title": "Family Camp 2025",
"description": "Annual family camp at the farm.",
"startDate": "2025-07-10T08:00:00.000Z",
"endDate": "2025-07-14T17:00:00.000Z",
"registrationDeadline": "2025-07-01T00:00:00.000Z",
"goLiveAt": "2025-05-01T00:00:00.000Z",
"price": 450,
"picture": "/uploads/events/camp.jpg",
"redirectUrl": "camp2025",
"isHidden": false,
"requiresAuth": true,
"form": {
"isRequired": true,
"fields": [
{ "type": "text", "label": "Dietary requirements", "isRequired": false, "order": 0 }
]
}
}
Event Options & Variants
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/events/:id/options |
supervisor+ | Add option to event |
| PUT | /api/events/options/:id |
supervisor+ | Update option (name, price, stockLimit, variants, earlyBirdTiers) |
| DELETE | /api/events/options/:id |
admin | Delete option (fails if registrations exist) |
| POST | /api/events/options/:id/variants |
supervisor+ | Add variant to option |
| PUT | /api/events/variants/:id |
supervisor+ | Update variant |
| DELETE | /api/events/variants/:id |
admin | Delete variant |
POST /api/events/:id/options
{
"name": "Adult",
"price": 450,
"isMainTicket": false,
"stockLimit": 100
}
PUT /api/events/options/:id — with variants and early-bird tiers
{
"name": "Adult",
"price": 450,
"stockLimit": 100,
"variants": [
{ "name": "Standard", "price": null, "stockLimit": 60, "order": 0 },
{ "name": "VIP", "price": 600, "stockLimit": 40, "order": 1 }
],
"earlyBirdTiers": [
{ "deadline": "2025-06-01T00:00:00.000Z", "price": 350, "stockLimit": 30, "order": 0, "variantId": null }
]
}
stockLimit: 0 means unlimited. Early-bird tiers can be option-level (variantId: null) or variant-level (variantId: "<uuid>").
Event Attachments
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/events/:id/attachments |
public | List attachments for event |
| POST | /api/events/:id/attachments |
supervisor+ | Upload attachment (multipart/form-data, field file) |
| DELETE | /api/events/:eventId/attachments/:id |
supervisor+ | Delete attachment |
Accepted file types: .pdf, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .txt, .csv, .zip. Max 10 MB.
Registrations
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/registrations |
optional | Create registration |
| GET | /api/registrations/myregistrations |
user+ | Own registrations |
| GET | /api/registrations/:id |
optional | Get registration by ID |
| PUT | /api/registrations/:id/options |
user+ | Update selected options |
| DELETE | /api/registrations/:id |
user+ | Cancel registration (blocked if positive payments exist unless admin) |
| GET | /api/registrations/:id/forms/draft |
optional | Get form draft |
| PUT | /api/registrations/:id/forms/draft |
optional | Save form draft |
| POST | /api/registrations/:id/forms/responses |
optional | Submit form responses |
| PUT | /api/registrations/:id/forms/responses |
supervisor+ | Replace form responses |
| GET | /api/registrations |
staff+ | All registrations |
| PUT | /api/registrations/:id |
staff+ | Update registration status |
| GET | /api/registrations/event/:eventId |
staff+ | Registrations for an event |
| POST | /api/registrations/manual |
supervisor+ | Manual registration (accepts couponCode) |
| PUT | /api/registrations/:id/attendees |
user+ | Replace the named attendees ({ attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }); owner or staff |
| POST | /api/registrations/:id/attendees/:attendeeId/send-form-link |
user+ | Email/WhatsApp an attendee the link to their own form |
| POST | /api/registrations/:id/coupon |
user+ | Apply a code to an existing registration (owner, or staff on someone's behalf) |
| DELETE | /api/registrations/:id/coupon |
staff+ | Remove the code (refused once a ticket on a paid registration has been scanned) |
POST /api/registrations
{
"eventId": "ev-uuid-...",
"options": [
{ "eventOptionId": "opt-uuid-...", "quantity": 2 },
{ "eventOptionId": "opt-uuid-...", "quantity": 1, "variantId": "variant-uuid-..." }
]
}
Add "couponCode": "STAFF50" to apply a discount code. The code is checked before anything is saved (400 with the reason if it can't be used). If the last use is taken by someone else in the moment between checking and saving, the registration is still created at full price and the response includes couponError.
Group tickets: on events with allowNamedAttendees, POST /api/registrations and /manual accept attendees: [{ eventOptionId, variantId?, name, email?, phone? }] (main-ticket places only, no more than the places bought) and ticketDelivery: "payer" | "each".
- Each named attendee gets their own quantity-1 ticket; unnamed places share one quantity ticket.
- An attendee whose email/phone matches one existing account is linked to it, so the ticket also appears in that person's
GET /api/tickets/mytickets. - With
"each", tickets are emailed/WhatsApped to attendees directly, falling back to the payer. - Form responses can carry
attendeeId.
Registration responses include discountTotal (the coupon discount), subtotalComputed (items before the discount), totalDueComputed (after it) and couponRedemptions (the active code).
For unauthenticated (guest) registration on events with requiresAuth: false:
{
"eventId": "ev-uuid-...",
"options": [...],
"guestName": "Jane Smith",
"guestEmail": "jane@example.com",
"guestPhone": "+27821234567"
}
Cancellation rules
| Actor | Condition | Result |
|---|---|---|
| Owner | No positive payments | 200 — status set to cancelled |
| Owner | Any positive payment exists | 403 — must contact organisation |
| Admin | Any state | 200 — always allowed |
| Anyone | Already cancelled | 400 |
Impersonation
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/admin/impersonate/:userId |
admin (2FA on) | { reason } → { token, session }. The token acts as the user, is read-only, and lasts 30 minutes. Refused for admins and inactive users |
| POST | /api/admin/impersonate/end |
impersonation token | End the session |
| POST | /api/admin/impersonate/log |
impersonation token | { path } — page viewed (audit trail) |
| GET | /api/admin/impersonate/current |
impersonation token | The live session |
| GET | /api/admin/impersonation-sessions |
admin | Sessions with their request trail |
Read-only enforcement:
- A guard mounted before every route returns
403 { code: "IMPERSONATION_READ_ONLY" }for any non-GET/HEAD/OPTIONS request made with an impersonation token. The only exceptions areendandlog. Blocked attempts are logged. - An ended or expired session gets
401 { code: "IMPERSONATION_ENDED" }. - GET handlers skip side-effect writes (pricing refresh) while
req.readOnlyis set.
Registration funnel
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/analytics/track |
public (rate-limited) | { sessionId, eventId, stage: 'view'|'start', source? } → always 204. Bots are ignored; device type comes from the User-Agent |
| GET | /api/analytics/funnel?eventIds=&from=&to= |
supervisor+ | { totals, conversion, trend, bySource, byDevice, byOption, perEvent } |
How the stages are recorded:
POST /api/registrationsacceptsanalyticsSessionId. It's stored on the registration and records thesubmitstage.paidis read from that registration's status.- Counts are sessions that reached a stage or a later one.
Data handling:
- Sessions inactive for 90 days are rolled up into
AnalyticsDailyby a daily job (ANALYTICS_ROLLUP_ENABLED). /api/stats/overviewnow includesfunnel: { views, paid, conversion }for live events over the past 30 days.
Post-event feedback
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/feedback/event/:eventId |
supervisor+ | The event's feedback form (with invited / responses counts), or null |
| PUT | /api/feedback/event/:eventId |
supervisor+ | { sendAt, audience: 'checked_in'|'all', anonymous, intro, fields[] } — fields can't change once answered (409) |
| DELETE | /api/feedback/event/:eventId |
supervisor+ | Remove the form |
| POST | /api/feedback/event/:eventId/send-now |
supervisor+ | Send now (also invites anyone not yet invited) |
| GET / POST | /api/feedback/public/:token |
link holder | Load the form / submit { answers: { fieldId: value } } once |
| GET | /api/feedback/summary?eventIds=a,b |
supervisor+ | Per event: invited, responses, response rate, and per question: rating average + distribution, choice counts, text answers (names only when not anonymous) |
Notes:
- New field types
rating(answers"1"–"5") andmultiple_choice(options: array of strings). They can be used on attendee forms too. - Only HMACs of link tokens are stored, and a reminder issues a new link.
- A job every 15 minutes (
FEEDBACK_JOB_ENABLED,FEEDBACK_JOB_INTERVAL_MS) sends due forms, and sends one reminder 3 days later to people who haven't answered.
Sponsor a spot
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/sponsorships/event/:eventId |
public | { enabled, open, spotPrice, showProgress, goalSpots, spotsFunded, sponsors } — never recipients |
| POST | /api/sponsorships/checkout |
public (optional login) | { eventId, spots? | amount?, anonymous?, sponsorName?, message?, name?, email? } → Yoco redirectUrl. Guests give a name and email |
| GET | /api/sponsorships/event/:eventId/pool |
supervisor+ | { received, assigned, remaining, sponsors, spotsFunded, spotPrice, goalSpots } |
How the money is handled:
- A spot costs the cheapest paid main ticket.
- Yoco checkout metadata carries
type: 'sponsorship'. The webhook records the payment asisDonation+isSponsorshipagainst the event, together withsponsorName,sponsorMessageandsponsorAnonymous. - Pool funds are assigned with
PUT /api/payments/assign-donation.
Other changes:
- Event fields:
allowSponsorships,sponsorshipGoalSpots,showSponsorshipProgress. POST /api/events/:id/email-attendeesacceptsfilter.audience: 'sponsors'.POST /api/automations/schedulejobs acceptaudience: 'sponsors'.
Rooms & groups
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/allocations/event/:eventId |
supervisor+ | Board: sets (groups, assignments), named attendees, possible leaders, unnamed places |
| POST | /api/allocations/sets |
supervisor+ | { eventId, name, showOnTicket? } |
| PUT / DELETE | /api/allocations/sets/:id |
supervisor+ | Rename / show on tickets; delete |
| POST | /api/allocations/sets/:id/groups |
supervisor+ | { name, capacity?, gender? ('male'|'female'), leaderUserId? } |
| PUT / DELETE | /api/allocations/groups/:id |
supervisor+ | Edit (can't drop below current members or break gender); delete (members become unassigned) |
| PUT | /api/allocations/sets/:id/assignments |
supervisor+ | { attendeeId, groupId } (null unassigns); enforces capacity and gender |
| DELETE | /api/allocations/sets/:id/assignments |
supervisor+ | Clear the set |
| POST | /api/allocations/sets/:id/auto |
supervisor+ | { mode: 'together' | 'split' } — places everyone not yet placed |
| GET | /api/allocations/sets/:id/lists?medical=true |
supervisor+ | Groups with members for printing (medical view is audited) |
| GET | /api/allocations/my-groups |
staff+ | Groups the caller leads (medical details for supervisors only) |
Related changes:
Event.collectGendermakes a gender (male/female) required for each named attendee.- Email and WhatsApp attendees accept
filter.allocationGroupId.
Ticket transfer
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/tickets/:id/transfer |
payer, holder or supervisor+ | { name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? } → 201. Creates an offer and sends a link to the details given. Nothing changes on the ticket yet. A new offer replaces an open one |
| DELETE | /api/tickets/:id/transfer |
payer, holder or supervisor+ | Withdraw the open offer |
| GET | /api/tickets/transfer-offer/:token |
link holder | { status, toName, fromName (first name), event, option, expiresAt } |
| POST | /api/tickets/transfer-offer/:token/accept |
link holder | Accept — the transfer happens now (all checks run again) |
| POST | /api/tickets/transfer-offer/:token/decline |
link holder | Decline |
| GET | /api/tickets/:id/transfers |
payer, holder or staff+ | Transfer history |
Privacy:
- There is no user search. The sender's request is checked (rules, required fields) without looking up any account, and no response reveals whether the email/phone belongs to an account.
- Offers expire after 7 days or when the event starts.
- Only an HMAC of the link token is stored.
Rules:
- Only before the event starts, and only for tickets that have never been scanned.
Event.allowTicketTransferturns it off for non-supervisors.
What a transfer does:
- The new holder becomes the ticket's attendee and gets a new QR code. A shared ticket for several places has one place split off into its own ticket.
- An account is matched by email/phone, or created inactive with an activation link.
- The previous holder's attendee form answers are deleted, and the medical details on their consent are cleared. A minor on a youth event gets a new consent request.
Old QR codes:
scan-previewandscanreturn410 { code: "TICKET_TRANSFERRED" }.- Offline sync reports them as
transferred. - Each transfer is audited as
ticket_transferred.
Offline scanning
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/scan-locks?eventId= |
staff+ | Active offline locks for an event |
| POST | /api/scan-locks |
staff+ | Lock { eventId, sectionId?, deviceId, deviceName? } to this device (409 if the scope is held elsewhere) |
| GET | /api/scan-locks/:id/snapshot?deviceId= |
staff+ | Tickets valid in the lock's scope (lock holder only) |
| POST | /api/scan-locks/:id/sync |
staff+ | { deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] } — idempotent; returns accepted, duplicates, conflicts |
| DELETE | /api/scan-locks/:id |
staff+ | Release ({ deviceId }, lock holder only) |
| POST | /api/scan-locks/:id/force-release |
supervisor+ | Release a lost device's lock |
| GET | /api/scan-locks/reports?eventId=&unreviewed= |
supervisor+ | Sync reports |
| POST | /api/scan-locks/reports/:id/review |
supervisor+ | Mark a report reviewed |
How locks work:
- One device per scope. A whole-event lock (
sectionIdnull) clashes with every section lock on that event. POST /api/tickets/scan/:qrCodenow acceptssectionIdanddeviceId. It enforces the section on the server, and returns423 { code: "SCAN_LOCKED" }when another device holds a lock covering the ticket.
How syncing works:
- Synced scans never redeem more than is left on a ticket.
- Conflicts are recorded in the sync report:
not_found,cancelled,wrong_scope,already_used,partly_usedandconsent. - Check-in messages are sent after the sync.
- Locks, releases, force-releases and syncs are written to the admin audit log.
Multi-day events & attendance
An event with scheduleType: "sessions" runs on several dates (e.g. every Wednesday for
5 weeks). People register and pay once and get one ticket; check-in is per session.
startDate/endDate always follow the first and last active session.
Create/update fields (POST/PUT /api/events):
| Field | Notes |
|---|---|
scheduleType |
single (default) or sessions. Switching back to single is refused once there are session check-ins |
sessions |
[{ id?, label?, startsAt, endsAt, location?, cancelled? }] — the full list. Rows left out are deleted (refused when they have check-ins — send cancelled: true instead). The event and its sessions save together, so a refused change saves nothing |
lateJoinEnabled, lateJoinUntilSessionIndex |
Registration stays open until that session (index into the sent list, sorted by start) starts; otherwise it closes when the first session starts |
latePricing, lateFixedPrice |
full, prorata (price × sessions left ÷ sessions, nearest R10, at least R10) or fixed (only ever lowers the price). Main tickets only |
checkInWindow |
whole_day (default — any time on the session's date, in APP_TIMEZONE, default Africa/Johannesburg) or session_only (2 h before start until the end) |
sessionReminders |
null (off) or { offsets: [60 | 180 | 1440], channels: ["email", "whatsapp"] } |
certificatesEnabled, certificateConfig |
{ template: classic | modern | church | kids, title, body ({name} {event} {sessionsAttended} {totalSessions} {endDate}), signatureName, signatureRole, signatureImage, rule: { type: all | count | percent, value }, sendOnIssue } |
Responses for events include sessions[] (with cancelled and hasCheckIns),
registrationClosesAt, nextSession and lateJoin: { active, mode, sessionsLeft, totalSessions, fixedPrice }.
PUT also returns sessionChanges: { added, moved, cancelled, restored, removed }.
Scanning: GET /api/tickets/scan-preview/:qr?sessionId= and POST /api/tickets/scan/:qr
({ sessionId? }) default to today's session and return session plus remaining for it;
sessionError / 400 NO_SESSION when no session is open for check-in. Offline snapshots
are for the session running when downloaded; synced scans count for the session at their scan time.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/attendance/:eventId |
staff+ | Register: columns (sessions), rows (one per main ticket: checkIns per session, attended, total, completed, certificate), totals |
| GET | /api/attendance/:eventId/export?format=xlsx|pdf |
staff+ | Download the register |
| POST | /api/attendance/:eventId/mark |
supervisor+ | { ticketId, sessionId, present } — single-date events use sessionId: "event" |
| POST | /api/attendance/:eventId/certificates |
supervisor+ | Issue now to everyone eligible, or { ticketId } for one (even if not eligible) |
| POST | /api/attendance/:eventId/certificates/send |
supervisor+ | Send unsent certificates, or { certificateId } |
| GET | /api/attendance/:eventId/certificates.pdf |
supervisor+ | All certificates in one PDF |
| POST | /api/attendance/certificates/preview |
supervisor+ | Sample PDF: { title, config } |
| POST | /api/attendance/certificates/:id/revoke |
supervisor+ | Revoke (never re-issued automatically) |
| GET | /api/attendance/certificates/mine |
user+ | My certificates |
| GET | /api/attendance/certificates/:id/pdf |
user+ | Download one (its person, the booker, or a supervisor) |
| GET | /api/attendance/certificates/verify/:code |
public | { valid, revoked?, name, event, completedOn, issuedAt } |
A job every 5 minutes (SESSION_JOBS_ENABLED, SESSION_JOBS_INTERVAL_MS) sends due session
reminders (each once; when several are due at once only the closest is sent) and issues
certificates for events with certificates on that ended in the last 14 days.
Waitlist
Events with waitlistEnabled (plus waitlistOfferHours, default 24, and waitlistPartialOffers, default true) take a queue for sold-out lines.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/waitlist |
optional | Join: { eventId, eventOptionId, variantId?, quantity } (+ guestName, guestEmail, guestPhone when not logged in on a guest event). Only when the line can't cover the quantity |
| GET | /api/waitlist/mine |
user+ | My active entries |
| DELETE | /api/waitlist/:id |
user+ | Leave (an open offer moves on) |
| GET | /api/waitlist/claim/:token |
public | What a claim link offers |
| GET | /api/waitlist/events/:eventId |
staff+ | The queue for an event |
| POST | /api/waitlist/:id/offer |
supervisor+ | Offer now ({ quantity? }), even over the stock limit |
| POST | /api/waitlist/:id/move |
supervisor+ | { direction: "up" | "down" } |
| POST | /api/waitlist/:id/remove |
supervisor+ | Take someone off |
How offers work:
- Places held for a live offer count as taken everywhere stock is counted:
availableCount,isSoldOutand registration stock checks. - Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit.
- A job every 10 minutes (
WAITLIST_JOB_ENABLED,WAITLIST_JOB_INTERVAL_MS) expires unclaimed offers and re-offers. - Claiming means registering with
POST /api/registrationspluswaitlistToken. The token stands in for logging in, and the held places are released to the claimer. - Manual registration and adding places to an existing booking are now checked against stock too.
Parental consent
Youth events (requiresParentalConsent, plus consentText, consentCollectMedical, consentCollectEmergency and consentCollectPhoto on the event) need every main place named with a dateOfBirth. Attendees under 18 on the start date also need parentName, parentEmail and parentPhone. Changing consentText bumps consentTextVersion.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/consent/:token |
public | What the parent sees: attendee, event, consent text, fields to fill in |
| POST | /api/consent/:token |
public | { decision: "give"|"decline", signedName, isGuardian: true, medical?, emergency?, photoConsent? } |
| GET | /api/consent/events/:eventId |
staff+ | Consent status per attendee, with counts |
| POST | /api/consent/requests/:id/resend |
user+ | Re-send one request with a new link (registrant or staff) |
| POST | /api/consent/events/:eventId/resend-pending |
supervisor+ | Re-send every pending request |
| POST | /api/consent/events/:eventId/rerequest |
supervisor+ | New requests for consent given on older text, and minors with none |
| GET | /api/consent/events/:eventId/medical |
supervisor+ | Decrypted medical & emergency list (audited as medical_list_viewed) |
How requests work:
- Requests go to the parent by both email and WhatsApp, whatever anyone's notification preference.
- Links are random tokens; only an HMAC is stored. They expire when the event ends.
- The answer stores the signed name, time, IP, device, and the exact text and version shown.
- Medical and emergency details are AES-256-GCM encrypted. A daily job (09:00,
CONSENT_JOBS_ENABLED=falseto disable) sends reminders (day 2, then every 3 days) and deletes themconsent_medical_retention_days(default 30) after the event.
Check-in rules:
GET /api/tickets/scan-preview/:qrCodereturnsconsentStatus(not_required,missing,pending,givenordeclined).POST /api/tickets/scan/:qrCodereturns403 { code: "CONSENT_REQUIRED" }unless consent is given.- Supervisors can pass
overrideConsent: true, which is audited asconsent_override.
Coupons
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/coupons/validate |
optional | Check a code against a basket ({ code, eventId, options, forUserId? }) |
| GET | /api/coupons |
supervisor+ | List codes with use counts and total discount |
| POST | /api/coupons |
supervisor+ | Create a code |
| GET | /api/coupons/:id |
supervisor+ | One code with its uses |
| PUT | /api/coupons/:id |
supervisor+ | Edit or turn off a code |
| GET | /api/coupons/redemptions |
supervisor+ | Uses for the Discounts report (eventIds, from, to) |
- Types:
percent(value 1–100),fixed(rand amount),comp(free). - Scope:
eventIds(none = every event) andoptions: [{ eventOptionId, variantId? }](none = the whole registration). - Restrictions:
userIds,restrictRolesandrestrictDomain. A match on any one is enough, and restricted codes need a known account. - Limits:
validFrom,validUntil,maxUses,maxUsesPerUser(default 1) andminQuantity. - Early-bird:
stackEarlyBird: falsemeans the better price wins;truediscounts the early-bird price. - Locked amount: the discount is locked when applied. Editing items recalculates percentage and comp codes, but fixed codes keep their amount. Cancelling releases the use.
Options and variants also accept an optional staffPrice, charged automatically to Staff and above when it's lower than the normal price.
Payments
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/payments |
supervisor+ | Record manual payment (cash/EFT) |
| POST | /api/payments/yoco-checkout |
user+ | Initiate Yoco checkout; re-evaluates early-bird pricing first — returns { priceUpdated: true, newTotal } if prices changed |
| GET | /api/payments/mypayments |
user+ | Own payments |
| GET | /api/payments/:id |
user+ | Payment by ID |
| GET | /api/payments/registration/:registrationId |
user+ | Payments for a registration |
| GET | /api/payments |
supervisor+ | All payments |
| GET | /api/payments/event/:eventId |
staff+ | Payments for an event |
| PUT | /api/payments/assign-donation |
supervisor+ | Assign a donation payment to a registration |
| POST | /api/payments/refund |
supervisor+ | Create refund record |
| GET | /api/payments/admin/stats |
admin | Payment statistics |
POST /api/payments (manual)
{
"registrationId": "reg-uuid-...",
"amount": 450,
"method": "cash",
"note": "Paid at the door"
}
POST /api/payments/yoco-checkout
{
"registrationId": "reg-uuid-...",
"successUrl": "https://events.yourchurch.org/payment/success",
"cancelUrl": "https://events.yourchurch.org/payment/cancel",
"failureUrl": "https://events.yourchurch.org/payment/failure"
}
Returns: { redirectUrl } or { priceUpdated: true, newTotal, message } if early-bird prices changed.
Tickets
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/tickets/mytickets |
user+ | Own tickets (excludes cancelled registrations) |
| GET | /api/tickets/:id |
user+ | Ticket by ID |
| POST | /api/tickets/email |
user+ | Email own tickets as PDF |
| POST | /api/tickets/send-to |
staff+ | Send tickets to a specific user |
| GET | /api/tickets/scan-preview/:qrCode |
staff+ | Preview scan (no mark) |
| GET | /api/tickets/qr/:qrCode |
staff+ | Ticket by QR code |
| POST | /api/tickets/scan/:qrCode |
staff+ | Scan/redeem ticket |
| GET | /api/tickets/event/:eventId |
staff+ | All tickets for an event |
| GET | /api/tickets/scans/recent |
staff+ | Recent scan activity |
| GET | /api/tickets/scans/stats |
staff+ | Scan statistics |
| POST | /api/tickets/generate |
supervisor+ | Generate tickets for a registration |
| GET | /api/tickets |
supervisor+ | All tickets |
| PUT | /api/tickets/email-sent |
admin | Bulk mark tickets as emailed |
Broadcasts (Email)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/broadcasts/preview |
supervisor+ | Dry-run — returns recipient count and sample |
| POST | /api/broadcasts/send |
supervisor+ | Send email broadcast immediately |
| POST | /api/broadcasts/schedule |
supervisor+ | Schedule email broadcast |
POST /api/broadcasts/send
{
"subject": "Camp 2025 — Final reminder",
"body": "Hi {{name}}, just a reminder that camp starts tomorrow at 08:00.",
"userIds": ["user-uuid-1", "user-uuid-2"],
"extraEmails": ["extra@example.com"]
}
Dynamic placeholders: {{name}}, {{event.title}}, {{event.start}}, {{event.link}}, {{balance}}, {{promo.title}}, {{promo.link}}
WhatsApp Broadcasts
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/whatsapp-broadcasts/preview |
supervisor+ | Dry-run |
| POST | /api/whatsapp-broadcasts/send |
supervisor+ | Send WhatsApp broadcast immediately |
| POST | /api/whatsapp-broadcasts/schedule |
supervisor+ | Schedule WhatsApp broadcast |
Scheduled Messages
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/scheduled-emails |
supervisor+ | List all queued/scheduled jobs |
| PATCH | /api/scheduled-emails/:id |
supervisor+ | Edit scheduled job (reschedule, update content) |
| DELETE | /api/scheduled-emails/:id |
supervisor+ | Cancel a queued job |
Automations
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/automations/schedule |
supervisor+ | Schedule event lifecycle automations (pre-event reminder, thank-you, promo, etc.) |
Reports
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/reports/pdf |
user+ | Generate and download PDF attendance/payment report |
| POST | /api/reports/email |
user+ | Email PDF report to current user |
Uploads
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/uploads/event-image |
supervisor+ | Upload event cover image (multipart/form-data, field image). Returns { url }. Max 5 MB. Formats: jpg, jpeg, png, webp, gif. |
| POST | /api/uploads/logo |
admin (or unauthenticated during setup) | Upload site logo (multipart/form-data, field image). Returns { url }. Max 2 MB. Formats: jpg, jpeg, png, webp, svg. |
Sections
Groups of event options shown as labelled sections on the registration page (e.g. "Tickets", "Add-ons", "Accommodation").
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/sections |
staff+ | List sections |
| POST | /api/sections |
supervisor+ | Create section |
| PUT | /api/sections/:id |
supervisor+ | Update section |
| DELETE | /api/sections/:id |
admin | Delete section |
POST /api/sections
{
"eventId": "ev-uuid-...",
"name": "Accommodation",
"allowedOptionIds": ["opt-uuid-1", "opt-uuid-2"]
}
Banner
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/banner |
public | Get current site-wide banner (text, colour, active flag) |
| POST | /api/banner |
supervisor+ | Set site banner |
POST /api/banner
{
"text": "Camp registrations close Friday!",
"color": "#f59e0b",
"isActive": true
}
Forms
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/forms/responses |
staff+ | List all submitted form responses across all events; supports filtering |
Yoco Transactions
Raw webhook events from Yoco. Used for reconciling payments that weren't matched automatically.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/yoco-transactions |
supervisor+ | All Yoco webhook transactions |
| GET | /api/yoco-transactions/unreconciled |
supervisor+ | Transactions not yet matched to a payment |
| POST | /api/yoco-transactions/:id/reconcile |
supervisor+ | Manually link transaction to a registration |
| POST | /api/yoco-transactions/:id/ignore |
supervisor+ | Mark transaction as intentionally ignored |
WhatsApp Admin
Manages the WAWP WhatsApp API instance. Credentials are stored in AppSetting (preferred) or .env (fallback).
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/whatsapp/config |
admin | Get current WAWP credentials |
| POST | /api/whatsapp/config |
admin | Save WAWP credentials to DB |
| GET | /api/whatsapp/status |
admin | WAWP session status |
| GET | /api/whatsapp/qr |
admin | QR code for WhatsApp device linking |
| POST | /api/whatsapp/create-instance |
admin | Create a new WAWP instance |
| POST | /api/whatsapp/delete-instance |
admin | Delete WAWP instance |
| POST | /api/whatsapp/request-code |
admin | Request pairing code (alternative to QR) |
| POST | /api/whatsapp/logout |
admin | Logout current WAWP session |
| POST | /api/whatsapp/start |
admin | Start WAWP instance |
| POST | /api/whatsapp/restart |
admin | Restart WAWP instance |
Webhooks
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/webhooks/yoco |
HMAC-verified | Yoco payment webhook — verifies X-Yoco-Signature, creates Payment + YocoTransaction, updates registration status, generates tickets |
| POST | /api/webhooks/whatsapp |
— | WAWP inbound event webhook |
Settings
Runtime configuration stored in the AppSetting table. Sensitive values (SMTP credentials, Yoco secrets) are stored AES-256-GCM encrypted. Yoco secrets are never returned by /api/settings/all and can't be written through PUT /api/settings; use /api/yoco below.
For what each individual setting key defaults to when it's never been saved, see SETTINGS.md at the repo root.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/settings |
public | Public settings (org name, tagline, colour, logo, legal keys) |
| GET | /api/settings/all |
admin | All settings — smtp_pass masked as ••••••••, smtp_user decrypted |
| GET | /api/settings/needs-setup |
public | { needsSetup: bool } — true until setup wizard completes |
| PUT | /api/settings |
admin | Upsert settings; smtp_user/smtp_pass encrypted before storage; sending •••••••• for smtp_pass keeps existing value |
| POST | /api/settings/test-smtp |
admin | Test SMTP with provided credentials; sends real test email to admin on success. The admin JWT returned by POST /api/setup/register also qualifies during the setup wizard. |
Yoco
Managed from Admin → Site Settings → Yoco. YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in .env are fallbacks used only when the database has no value.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/yoco/config |
admin | Yoco settings with secrets masked card-style (sk_test_***…aba), the source of each (db/env/none), test/live mode and the default webhook URL |
| PUT | /api/yoco/config |
admin | Save secretKey and/or webhookSecret (AES-256-GCM encrypted). A new key is checked with Yoco first; '' clears a value |
| GET | /api/yoco/webhooks |
admin | Webhooks on the Yoco account, flagged isActive (its secret is the stored one) and pointsHere |
| POST | /api/yoco/webhooks |
admin | Register a webhook at Yoco ({ name?, url? }, URL defaults to BACKEND_URL/api/webhooks/yoco, must be public https) and store the secret it returns |
| DELETE | /api/yoco/webhooks/:id |
admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
POST /api/settings/test-smtp
{
"host": "smtp.office365.com",
"port": 587,
"secure": false,
"user": "noreply@yourchurch.org",
"pass": "app-password-here",
"from": "noreply@yourchurch.org"
}
Success (200):
{ "message": "SMTP connection verified — a test email has been sent to admin@yourchurch.org" }
Failure (400):
{
"message": "Authentication failed — check your SMTP username and password.",
"raw": "530 5.7.57 Client not authenticated to send mail. [JN3P275CA0121...]"
}
The message field is always a human-readable sentence. The raw field is included on errors only and contains the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message.
Setup
One-time first-run wizard. Both endpoints are blocked once any user exists and setup is complete.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/setup/register |
public (one-time) | Create the first admin account; returns a short-lived setup JWT |
| POST | /api/setup |
setup token or admin | Save initial site settings (org name, branding, SMTP) |
Static Files
| Path | Description |
|---|---|
/uploads/events/* |
Event cover images |
/uploads/branding/* |
Site logo |
/uploads/event-files/* |
Event attachments (PDFs, docs, etc.) |
/uploads/tickets-temp/* |
Temporarily hosted ticket PDFs for WhatsApp delivery (auto-deleted after 5 min) |
Auth error responses
| Status | Body | Meaning |
|---|---|---|
| 401 | { message: "Not authorised, no token" } |
Missing or malformed Authorization header |
| 401 | { message: "Not authorised, token failed" } |
Invalid or expired JWT |
| 401 | { message: "Session has been revoked" } |
Token version mismatch (sessions revoked) |
| 403 | { message: "Not authorised as an admin" } |
Insufficient role |
For full interactive docs with expandable examples visit GET /docs?token=<admin-jwt> on the running backend.