Files
hope-events/backend/API_DOCUMENTATION.md
T
joshuaandClaude Opus 5.5 39558577f7 Add multi-day events (frontend), attendance register and certificates
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>
2026-10-22 14:53:39 +02:00

44 KiB
Raw Blame History

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

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 with enable/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 are end and log. Blocked attempts are logged.
  • An ended or expired session gets 401 { code: "IMPERSONATION_ENDED" }.
  • GET handlers skip side-effect writes (pricing refresh) while req.readOnly is 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/registrations accepts analyticsSessionId. It's stored on the registration and records the submit stage.
  • paid is 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 AnalyticsDaily by a daily job (ANALYTICS_ROLLUP_ENABLED).
  • /api/stats/overview now includes funnel: { 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") and multiple_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 as isDonation + isSponsorship against the event, together with sponsorName, sponsorMessage and sponsorAnonymous.
  • Pool funds are assigned with PUT /api/payments/assign-donation.

Other changes:

  • Event fields: allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress.
  • POST /api/events/:id/email-attendees accepts filter.audience: 'sponsors'.
  • POST /api/automations/schedule jobs accept audience: '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.collectGender makes 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.allowTicketTransfer turns 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-preview and scan return 410 { 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 (sectionId null) clashes with every section lock on that event.
  • POST /api/tickets/scan/:qrCode now accepts sectionId and deviceId. It enforces the section on the server, and returns 423 { 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_used and consent.
  • 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, isSoldOut and 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/registrations plus waitlistToken. 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.

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=false to disable) sends reminders (day 2, then every 3 days) and deletes them consent_medical_retention_days (default 30) after the event.

Check-in rules:

  • GET /api/tickets/scan-preview/:qrCode returns consentStatus (not_required, missing, pending, given or declined).
  • POST /api/tickets/scan/:qrCode returns 403 { code: "CONSENT_REQUIRED" } unless consent is given.
  • Supervisors can pass overrideConsent: true, which is audited as consent_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) and options: [{ eventOptionId, variantId? }] (none = the whole registration).
  • Restrictions: userIds, restrictRoles and restrictDomain. A match on any one is enough, and restricted codes need a known account.
  • Limits: validFrom, validUntil, maxUses, maxUsesPerUser (default 1) and minQuantity.
  • Early-bird: stackEarlyBird: false means the better price wins; true discounts 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.