@proveanything/smartlinks 2.0.69 → 2.0.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,10 @@
2
2
 
3
3
  Reference for asset types, endpoints, and public (token-based) uploads.
4
4
 
5
+ > **Showing an asset in a page, email or other static content? Use `assetUrl`.** It's the asset's own
6
+ > address and always shows its current file, so replacing the file needs no code change or redeploy. See
7
+ > [Asset addresses: `assetUrl` vs `url`](#asset-addresses-asseturl-vs-url).
8
+
5
9
  ---
6
10
 
7
11
  ## Asset object
@@ -17,7 +21,9 @@ interface Asset {
17
21
  appId: string | null // app that owns this asset, e.g. 'homepage'
18
22
 
19
23
  // File
20
- url: string // CDN URL of the original file
24
+ url: string // address of the current FILE — versioned, changes when the file is replaced
25
+ assetUrl: string | null // the ASSET's own address — always its current file; use in static content
26
+ // (see "Asset addresses" below)
21
27
  thumbnail: string | null // CDN URL of WebP thumbnail (max 512px longest edge, no crop)
22
28
  // Always .webp — null until thumbnail generation has run
23
29
  name: string // original filename
@@ -84,6 +90,12 @@ interface AssetRef {
84
90
  }
85
91
  ```
86
92
 
93
+ An `AssetRef` has no `assetUrl`, on purpose. A product's image is part of the product record: to change it,
94
+ point the record at a new asset (upload, then save the product). That's a record change, so the product's
95
+ change event fires and integrations and syndication pick it up. Retailers cache images by URL, so the new
96
+ address is what makes them fetch the new image. Show product images from the record, never copied into
97
+ static HTML.
98
+
87
99
  Render the thumbnail wherever a compact preview is needed; fall back to `url` if null:
88
100
 
89
101
  ```typescript
@@ -185,7 +197,10 @@ await Api.asset.updateAdmin({
185
197
  POST /asset/:assetId/replace
186
198
  ```
187
199
 
188
- Replaces the file; the previous URL is snapshotted into `versions[]`.
200
+ Replaces the file; the previous URL is snapshotted into `versions[]`. The id and `assetUrl` stay the same,
201
+ and `assetUrl` serves the new file straight away. `url` becomes the new file's address. See
202
+ [Asset addresses](#asset-addresses-asseturl-vs-url). For a large file, use a resumable upload with
203
+ `replaceAssetId`.
189
204
 
190
205
  ```typescript
191
206
  await Api.asset.replaceFile({
@@ -304,6 +319,7 @@ interface CreateResumableUploadOptions {
304
319
  appId?: string
305
320
  admin?: boolean // admin route (default is the public route)
306
321
  token?: string // upload token for public/unauthenticated uploads
322
+ replaceAssetId?: string // admin: replace this asset's file (any size) instead of creating a new asset
307
323
  }
308
324
 
309
325
  interface ResumableUploadHandle {
@@ -321,6 +337,51 @@ interface ResumableUploadHandle {
321
337
  - On completion, `start()`/`resume()` resolves to the finalized `Asset` record.
322
338
  - `pause()` causes the in-flight `start()`/`resume()` promise to reject with an `UploadPausedError`; call `resume()` to continue.
323
339
  - `asset.upload()` also now accepts an `AbortSignal` (`upload({ ..., signal })`) for cancelling a stalled single-shot upload.
340
+ - With `replaceAssetId`, the completed upload becomes a new version of that asset (same id and `assetUrl`), so a
341
+ 200 MB video can be swapped without touching the pages that show it.
342
+
343
+ ---
344
+
345
+ ## Asset addresses: `assetUrl` vs `url`
346
+
347
+ An asset is a named thing in the library, such as "Hero image". Its file can be replaced over time, so it has
348
+ two addresses:
349
+
350
+ | Field | Is the address of | When the file is replaced |
351
+ |---|---|---|
352
+ | `assetUrl` | the **asset**: `https://cdn.smartlinks.app/sites/{collectionId}/a/{assetId}` | stays the same, and shows the new file |
353
+ | `url` | the asset's current **file** | becomes the new file's address (the old one moves to `versions[]`) |
354
+
355
+ ### Which to use
356
+
357
+ | You're showing it in… | Use |
358
+ |---|---|
359
+ | Static content: a built site's HTML or CSS, an email, a banner, anything written once and published | `assetUrl` |
360
+ | A product or collection image (`heroImage`, `additionalImages`, `logoImage`, `headerImage`) | the record's `AssetRef`, read from the record (see [AssetRef](#assetref--slim-embedded-shape)) |
361
+ | Version history, a "compare versions" view, an exact file for an audit | `url` and `versions[]` |
362
+
363
+ ```html
364
+ <!-- In a built page: swap "Hero image" in the library and this shows the new one, no rebuild -->
365
+ <img src="https://cdn.smartlinks.app/sites/my-collection/a/3f0c…" alt="…">
366
+ ```
367
+
368
+ ```typescript
369
+ const hero = await Api.asset.getAdmin('my-collection', heroId)
370
+ const src = hero.assetUrl ?? hero.url // fall back for outside links and older assets
371
+ ```
372
+
373
+ ### How it behaves
374
+
375
+ - **Replace** (`replaceFile`, or a resumable upload with `replaceAssetId`): `assetUrl` shows the new file. The CDN
376
+ copy is cleared at once, and browsers re-check within a minute. There's no redeploy and no extra API call.
377
+ - **Delete**: `assetUrl` stops serving (the asset is withdrawn). **Restore** brings it back.
378
+ - `assetUrl` is `null` for an outside link (an asset that is a link to someone else's file). Fall back to `url`.
379
+ - Assets made before asset addresses existed have `assetUrl: null` until they're given one:
380
+
381
+ ```typescript
382
+ const a = await Api.asset.ensureAssetUrl('my-collection', 'abc123')
383
+ a.assetUrl // https://cdn.smartlinks.app/sites/my-collection/a/abc123
384
+ ```
324
385
 
325
386
  ---
326
387
 
@@ -305,11 +305,16 @@ clearPersistedTokens();
305
305
 
306
306
  ---
307
307
 
308
- ## Step-up MFA (Phase 1)
308
+ ## Step-up MFA
309
309
 
310
- Backend-only for now — **no admin-console UI and no challenge UI ship with this pass.**
311
- Factors: **email OTP, SMS OTP, recovery codes**. WhatsApp OTP, TOTP, and passkeys as
312
- *factors* are deferred to a later phase; don't build against them yet.
310
+ Factors: **email code, SMS code, authenticator app (`totp`), recovery codes**. The shared login UI
311
+ (`@proveanything/smartlinks-auth-ui`) has the challenge and the Settings → Security screens. WhatsApp
312
+ codes and passkeys are planned; don't build against them yet.
313
+
314
+ **Authenticator apps** (Google Authenticator, Microsoft Authenticator, 1Password, Authy…) are the
315
+ recommended second step: nothing is sent (no SMS cost, nothing to intercept), and they are offered first
316
+ when someone has one. A client can switch any factor off with `mfa.factorsEnabled` (e.g.
317
+ `{ totp: false }`).
313
318
 
314
319
  The step-up gate applies to **every login method that issues a session**:
315
320
 
@@ -408,15 +413,25 @@ await authKit.confirmEmailMfa(clientId, mfaSessionToken, code);
408
413
  const { mfaSessionToken: smsToken } = await authKit.enrollSmsMfa(clientId, '+61400000000');
409
414
  await authKit.confirmSmsMfa(clientId, smsToken, code);
410
415
 
416
+ // Enroll an authenticator app: show otpauthUri as a QR code (and secret for typing in),
417
+ // then confirm with the 6-digit code the app shows
418
+ const { mfaSessionToken: appToken, otpauthUri, secret } = await authKit.enrollTotpMfa(clientId);
419
+ await authKit.confirmTotpMfa(clientId, appToken, codeFromApp);
420
+
411
421
  // Recovery codes — plaintext, shown exactly once; nothing else in the API returns them again
412
422
  const { recoveryCodes } = await authKit.generateMfaRecoveryCodes(clientId, password);
413
423
 
414
424
  // Inspect / remove
415
425
  const factors = await authKit.getMfaFactors(clientId);
416
- // factors: { enrolledFactors: { email?, sms? }, recoveryCodesRemaining, mfaEnabledForClient }
426
+ // factors: { enrolledFactors: { email?, sms?, totp? }, recoveryCodesRemaining, mfaEnabledForClient }
417
427
  await authKit.removeMfaFactor(clientId, 'sms', password);
418
428
  ```
419
429
 
430
+ At sign-in, `MFA_REQUIRED` lists `availableFactors` with `preferredFactor` (`totp`, then `sms`, then
431
+ `email`). For `totp`, `mfaChallengeSend(clientId, token, 'totp')` sends nothing; it just says the code
432
+ comes from the app. Then call `mfaChallengeVerify` with that code, as for the others. Each authenticator
433
+ code works once. The secret is sealed on the server and never returned after `enrollTotpMfa`.
434
+
420
435
  > Enrollment endpoints don't currently check the client's `mfa.mode` — enrolling is possible
421
436
  > even when MFA is configured `'off'` for a client. Those factors simply won't be challenged
422
437
  > at login until an admin turns the mode on (via the existing admin AuthKit config update,
@@ -633,6 +648,31 @@ await authKit.setIdentity(collectionId, authKitId, { mode: 'own' })
633
648
  resets) works the same way; nothing changes in the login UI or your code.
634
649
  - `identity` is read-only on `create` / `update`; it is shown on the config (`authKit.get`).
635
650
 
651
+ ### Invitation only
652
+
653
+ A site with its own logins can let in only the people it invites:
654
+
655
+ ```ts
656
+ await authKit.update(collectionId, authKitId, { signUp: 'invite' }) // 'open' (default) | 'invite'
657
+ await authKit.invite(collectionId, authKitId, { email: 'jo@example.com', firstName: 'Jo', customFields: { level: 'member' } })
658
+ ```
659
+
660
+ - **Enforced on the server, for every route.** Nobody without an account can get one: `register`,
661
+ `verifyMagicLink`, `verifyPhoneCode`, Google, Apple, LinkedIn and WhatsApp answer `SIGN_UP_CLOSED`
662
+ (403). `sendMagicLink` and `sendPhoneCode` answer as if sent, but send nothing to someone who isn't a
663
+ member, so nobody can find out who is.
664
+ - **`invite`** makes the person's account if they have none, finds or creates their contact in that
665
+ collection with the fields you pass, links the two, and emails a sign-in link to the site (`redirectUrl`,
666
+ default the kit's) that works once for 7 days. It lands like any magic link (`mode=magicLink&token=…`).
667
+ Inviting someone again sends a fresh link. The link is never returned to the admin.
668
+ - **Invited members then sign in any way the site offers.** A magic link, "forgot password" to set a
669
+ password, or Google/Apple/LinkedIn with the same verified email: an invited account nobody has signed
670
+ in to yet can be claimed that way.
671
+ - `signUp` is on the public config (`load`): hide "Create an account" when it's `invite`. Setting it
672
+ needs own logins first (`OWN_LOGINS_REQUIRED`, 409).
673
+ - On any site, a member signing in with a verified email or phone is attached to the contact the
674
+ collection already has for it, so fields the staff set beforehand (a level) are theirs.
675
+
636
676
  ## Relationship to other parts of the SDK
637
677
 
638
678
  | Concern | Where it lives |
@@ -646,5 +686,6 @@ await authKit.setIdentity(collectionId, authKitId, { mode: 'own' })
646
686
 
647
687
  ## Further reading
648
688
 
689
+ - [members.md](members.md) — members with a level: clubs, leagues, members-only content
649
690
  - [app-data-storage.md](app-data-storage.md) — storing user-specific data after login
650
691
  - [app-manifest.md](app-manifest.md) — `app.admin.json` setup questions for configuring `clientId`
@@ -0,0 +1,324 @@
1
+ # Bookings: slots, bookings and allocations
2
+
3
+ Generic booking objects for **any** booking app: event tickets, classes, tours, table reservations,
4
+ one-to-one appointments, room and equipment hire. The platform gives you the objects and the
5
+ guarantees. Your app owns the rest: schedule, policy, pricing, payment and UI.
6
+
7
+ ```ts
8
+ import { bookings, utils } from '@proveanything/smartlinks'
9
+ ```
10
+
11
+ ---
12
+
13
+ ## What the platform guarantees (and what it doesn't)
14
+
15
+ | The platform | Your app |
16
+ |---|---|
17
+ | **No double-booking** of a person / room / table, however many requests arrive at once | The schedule (opening hours) and its UI |
18
+ | **No overselling** a slot's capacity | Booking policy: notice, durations, who may book |
19
+ | **Atomic** writes: a booking with several parts (a stylist *and* a chair) succeeds whole or not at all | Pricing, ticket types, payment or credits, refunds |
20
+ | **Idempotent** creates (`idempotencyKey`): a retried request never books twice | Queues, waiting rooms, throttling for high-demand sales |
21
+ | **Holds** that expire and give the place back | Confirmation / reminder content (flows on booking events) |
22
+ | Who may book publicly, by **per-app rules** (like records / cases) | |
23
+
24
+ ---
25
+
26
+ ## The three objects
27
+
28
+ - **Slot** — a time window with a **capacity**: an event performance, a class, a tour at 2pm. Seats
29
+ are counted (`booked`, `remaining`).
30
+ - **Booking** — the customer's reservation and its lifecycle. This is what "my bookings" lists.
31
+ - **Allocation** — what a booking actually claims: seats on a slot (`mode: 'capacity'`) or
32
+ exclusive time on a resource (`mode: 'exclusive'`). Most apps never touch these directly: they're
33
+ returned on `booking.allocations`.
34
+
35
+ **Resources are not a new object.** Staff, rooms, tables and equipment are whatever you already use —
36
+ app records, contacts, products — referenced by type + id: `{ resourceType: 'record', resourceId: 'staff-sam' }`.
37
+
38
+ ### Booking statuses
39
+
40
+ | Status | Holds a place? | Meaning |
41
+ |---|---|---|
42
+ | `held` | yes, until `holdExpiresAt` | Claimed while the customer pays / confirms; expires automatically |
43
+ | `pending` | yes | Claimed, waiting for the business to approve |
44
+ | `confirmed` | yes | Booked |
45
+ | `completed` / `no_show` | yes (the time was used) | Outcome after the event (admin) |
46
+ | `cancelled` | no | `cancelReason`: `admin`, `owner`, `hold_expired` or your own |
47
+ | `waitlisted` | no | Waiting; confirming it claims the place if one is free |
48
+
49
+ ---
50
+
51
+ ## Events and classes — slots
52
+
53
+ ```ts
54
+ // Admin (Studio, or your app's server function): publish the sessions
55
+ const slot = await bookings.slots.create(collectionId, appId, {
56
+ startsAt: '2026-11-14T19:30:00Z', endsAt: '2026-11-14T21:00:00Z',
57
+ capacity: 200,
58
+ perContactLimit: 4, // max 4 seats per person on this slot, enforced atomically
59
+ data: { title: 'Autumn concert' },
60
+ })
61
+
62
+ // A class that also needs its instructor free: the slot blocks the instructor's time too
63
+ await bookings.slots.create(collectionId, appId, {
64
+ startsAt, endsAt, capacity: 12,
65
+ resourceType: 'record', resourceId: 'instructor-ana', blockResource: true,
66
+ })
67
+
68
+ // A series, all-or-nothing (max 500)
69
+ await bookings.slots.createMany(collectionId, appId, sessions, { seriesId })
70
+
71
+ // Public: open slots with places left
72
+ const { data } = await bookings.slots.list(collectionId, appId, { from, to, available: true })
73
+ data[0].remaining
74
+ ```
75
+
76
+ Book seats:
77
+
78
+ ```ts
79
+ const booking = await bookings.create(collectionId, appId, { slotId: slot.id, quantity: 2, idempotencyKey })
80
+ ```
81
+
82
+ Recurring events are **expanded into concrete slots** (`seriesId`); there's no recurrence rule
83
+ stored on a slot.
84
+
85
+ ---
86
+
87
+ ## Appointments — exclusive time on a resource
88
+
89
+ ```ts
90
+ await bookings.create(collectionId, appId, {
91
+ resourceType: 'record', resourceId: 'staff-sam',
92
+ startsAt: '2026-11-03T10:00:00Z', endsAt: '2026-11-03T10:30:00Z',
93
+ bufferAfter: 15, // admin: blocks 10:30–10:45 too (clean-up); the booking still shows 10:00–10:30
94
+ }, true)
95
+ ```
96
+
97
+ Several resources at once — all-or-nothing:
98
+
99
+ ```ts
100
+ await bookings.create(collectionId, appId, {
101
+ startsAt, endsAt,
102
+ allocations: [
103
+ { resourceType: 'record', resourceId: 'stylist-jo' },
104
+ { resourceType: 'record', resourceId: 'chair-2' },
105
+ ],
106
+ }, true)
107
+ ```
108
+
109
+ A pool with no fixed times (3 kayaks, bookable any time) is three resources; your app picks a free one.
110
+
111
+ ### Blocked time
112
+
113
+ ```ts
114
+ await bookings.blocks.create(collectionId, appId, { resourceType: 'record', resourceId: 'staff-sam', startsAt, endsAt, data: { reason: 'training' } })
115
+ ```
116
+
117
+ Planned time off usually belongs in your schedule (an override); blocks are for ad-hoc blocked time.
118
+
119
+ ---
120
+
121
+ ## Schedules and free times
122
+
123
+ **Your app owns the schedule.** Store it however you like — usually an app record. For the time maths,
124
+ the SDK ships a pure helper:
125
+
126
+ ```ts
127
+ const schedule = {
128
+ timezone: 'Europe/London',
129
+ weekly: { mon: [{ start: '09:00', end: '12:30' }, { start: '13:30', end: '17:00' }], tue: [{ start: '09:00', end: '17:00' }] },
130
+ overrides: [{ from: '2026-12-24', to: '2026-12-28', hours: [] }, { date: '2026-11-07', hours: [{ start: '10:00', end: '13:00' }] }],
131
+ }
132
+
133
+ const fb = await bookings.freebusy(collectionId, appId, { resources: ['record:staff-sam'], from, to }, true)
134
+ const times = utils.freeTimes({
135
+ hours: schedule, busy: fb.resources[0].busy,
136
+ from, to, duration: 30, step: 30, bufferAfter: 15, minNotice: 240,
137
+ })
138
+ // → [{ start, end }, …] in UTC; render in the booker's timezone
139
+ ```
140
+
141
+ - Times are **local wall-clock** in `timezone`, so "9 to 5" stays 9 to 5 across daylight-saving changes.
142
+ - A missing day is closed; hours never cross midnight (split them); `end` may be `'24:00'`.
143
+ - `overrides` win over `weekly` (`hours: []` = closed).
144
+ - Also exported: `utils.withinSchedule`, `utils.openIntervals`, `utils.subtractIntervals`, `utils.validateSchedule`.
145
+
146
+ `freebusy` returns busy intervals only (bookings, blocks, slots that block a resource) — never who
147
+ booked. It's collection-wide: a resource is busy whichever app booked it.
148
+
149
+ ---
150
+
151
+ ## Who can book — per-app rules
152
+
153
+ Exactly like records, cases and threads:
154
+
155
+ - **Admin calls are unconditional** — Studio users, and **your app's own server functions** (they call
156
+ the admin routes with your app's authority). Pass `admin = true`.
157
+ - **Public calls** (`admin = false`, the default for `create`) follow your app's config in
158
+ `sites/{collectionId}/apps/{appId}` (`POST /admin/collection/:cId/apps/:appId`). With no config,
159
+ **public booking is refused** — only admins (and your functions) can book.
160
+
161
+ Pick a mode per app:
162
+
163
+ | Mode | Config | Suits |
164
+ |---|---|---|
165
+ | **Public booking** | `publicCreate.bookings` allows anonymous and/or signed-in callers | "Book an appointment with me", simple events — no server code |
166
+ | **Admin-only booking** | no `publicCreate.bookings` | Extra validation, queues, payment-first, high demand: book through your server function |
167
+
168
+ ### `publicCreate.bookings`
169
+
170
+ ```json
171
+ {
172
+ "publicCreate": {
173
+ "bookings": {
174
+ "anonymous": {
175
+ "allow": true,
176
+ "enforce": { "status": "held" },
177
+ "limits": { "maxQuantity": 4, "holdMinutes": 15 },
178
+ "rules": { "targets": ["slots"], "minNoticeMinutes": 60, "horizonDays": 90 },
179
+ "edit": { "editToken": true }
180
+ },
181
+ "authenticated": {
182
+ "allow": true,
183
+ "enforce": { "status": "confirmed", "visibility": "owner" },
184
+ "rules": {
185
+ "targets": ["slots", "resources"],
186
+ "schedule": true,
187
+ "resourceTypes": ["record"],
188
+ "durations": [30, 60],
189
+ "minNoticeMinutes": 240,
190
+ "bufferAfter": 15
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ ```
197
+
198
+ | Field | Meaning |
199
+ |---|---|
200
+ | `enforce.status` | `confirmed` (instant), `pending` (business approves), `held` (pay / confirm next). Default `pending`. The caller can't override it. |
201
+ | `enforce.visibility` | Default: `owner` for signed-in, `admin` for anonymous |
202
+ | `limits.maxQuantity` | Max seats / claims per booking |
203
+ | `limits.holdMinutes` | Hold length for public `held` bookings (default 15, max 60) |
204
+ | `rules.targets` | `slots` (only your published slots) and/or `resources` (free-form time). Default `['slots']` |
205
+ | `rules.schedule` | **Required for `resources`.** The platform checks the requested time against the resource's schedule: an app record of **your app** with `recordType: 'availability'`, `parentType` / `parentId` = the resource, `data` in the standard shape above |
206
+ | `rules.resourceTypes` | Which resource types the public may book |
207
+ | `rules.durations` | Allowed lengths in minutes |
208
+ | `rules.minNoticeMinutes`, `rules.horizonDays` | How soon / how far ahead |
209
+ | `rules.bufferBefore`, `rules.bufferAfter` | Applied to every public booking (the caller can't set buffers) |
210
+ | `edit.editToken` | Anonymous only: the create response carries `editToken` **once** — store it so the guest can manage the booking. `edit.windowMinutes` limits how long it works |
211
+
212
+ Public callers can't set `contactId` (signed-in callers get their own contact; guests none), the
213
+ admin zone, buffers, `customId` or `sourceSystem`.
214
+
215
+ ### `ownerActions.bookings` — what the booker may do afterwards
216
+
217
+ ```json
218
+ {
219
+ "ownerActions": {
220
+ "bookings": {
221
+ "cancel": { "allow": true, "cutoffMinutes": 1440 },
222
+ "move": { "allow": true, "cutoffMinutes": 1440 },
223
+ "confirm": { "allow": true }
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ Default: nothing. `cutoffMinutes` = not within that time of the start (`CUTOFF_PASSED`). `move`
230
+ re-checks the create rules for the new time. `confirm` turns the booker's own **held** booking into
231
+ `confirmed` — only for apps that take no payment; `pending` and `waitlisted` are the business's to
232
+ confirm.
233
+
234
+ ```ts
235
+ // Signed-in booker
236
+ await bookings.cancel(collectionId, appId, bookingId)
237
+ const mine = await bookings.mine(collectionId, appId, { from: new Date() })
238
+
239
+ // Guest, with the token from create
240
+ const b = await bookings.get(collectionId, appId, bookingId, false, { editToken })
241
+ await bookings.move(collectionId, appId, bookingId, { slotId: other.id }, false, { editToken })
242
+ ```
243
+
244
+ ### `publicRead.bookings.freebusy`
245
+
246
+ `{ "publicRead": { "bookings": { "freebusy": true } } }` lets public pages call `bookings.freebusy`.
247
+ Off by default — most apps serve free times through their own function.
248
+
249
+ ---
250
+
251
+ ## Payments and high-demand sales
252
+
253
+ The platform takes no payments. It gives you what a payment or credits flow needs:
254
+
255
+ 1. Create the booking **`held`** (claims the place; `holdMinutes`).
256
+ 2. Take payment. Slow step? `bookings.extendHold(…, minutes)` (admin; capped at 24h from creation).
257
+ 3. **Confirm after payment** — from your function or payment webhook handler. Confirm is idempotent, so
258
+ a repeated webhook is harmless. A run-out hold fails with `HOLD_EXPIRED`.
259
+ 4. If it's never confirmed, the hold expires within a minute: the booking becomes `cancelled`
260
+ (`cancelReason: 'hold_expired'`) and the place goes back. Void the payment intent on the
261
+ `booking.hold_expired` event.
262
+
263
+ Keep payment references, amounts, credits and refunds in the booking's `admin` / `owner` zones.
264
+
265
+ **High demand** (queues, lotteries, bot checks): turn public booking **off** so the only way in is
266
+ your server function; it checks the queue token / per-person limit / throttle, then creates a short
267
+ `held` booking. The platform guarantees nothing is oversold however many requests arrive.
268
+
269
+ ---
270
+
271
+ ## Errors
272
+
273
+ Booking calls fail with HTTP 409 and an `errorCode` you can show or act on:
274
+
275
+ | Code | When |
276
+ |---|---|
277
+ | `SLOT_FULL` | Not enough places left (`details.remaining`) |
278
+ | `SLOT_CLOSED` | The slot isn't open |
279
+ | `RESOURCE_TAKEN` | The time isn't free on that resource (`details` names it) |
280
+ | `PER_CONTACT_LIMIT` / `QUANTITY_LIMIT` | Per-person / per-booking limit |
281
+ | `HOLD_EXPIRED` | The hold ran out before confirm / move |
282
+ | `INVALID_STATE` | e.g. confirming a cancelled booking |
283
+ | `SLOT_OVERBOOKED` / `SLOT_HAS_BOOKINGS` | Lowering capacity below what's booked / changing a booked slot's time or deleting it |
284
+ | `TOO_SOON` / `TOO_FAR_AHEAD` / `IN_THE_PAST` / `DURATION_NOT_ALLOWED` | Public time rules |
285
+ | `OUTSIDE_SCHEDULE` / `NO_SCHEDULE` / `SCHEDULE_INVALID` | Public schedule check |
286
+ | `CUTOFF_PASSED` | Owner action inside its cutoff |
287
+
288
+ `403 FORBIDDEN` when the app's rules don't allow the call; `404` for unknown ids.
289
+
290
+ ---
291
+
292
+ ## Events
293
+
294
+ Recorded after each write commits, for flows and app functions (`trigger: { type: 'event', eventTypes: [...] }`):
295
+
296
+ `booking.created`, `booking.confirmed`, `booking.cancelled`, `booking.moved`, `booking.completed`,
297
+ `booking.no_show`, `booking.hold_expired`, `slot.full`, `slot.place_freed` (for waitlist promotion).
298
+ Context: `{ appId, status, slotIds }` (bookings) / `{ appId, remaining }` (slots).
299
+
300
+ Reminders before a booking (`booking.starts_in`) are coming; until then, use a cron function that lists
301
+ upcoming bookings.
302
+
303
+ ---
304
+
305
+ ## API reference
306
+
307
+ All take `(collectionId, appId, …)`; `admin` is the last parameter where both exist.
308
+
309
+ | Call | Admin | Public |
310
+ |---|---|---|
311
+ | `bookings.create(input, admin?)` | any status, any field | per `publicCreate.bookings` |
312
+ | `bookings.list(params?)` | ✓ | — (use `mine`) |
313
+ | `bookings.mine(params?)` | — | signed-in booker |
314
+ | `bookings.get(id, admin?, { editToken }?)` | ✓ | booker / token |
315
+ | `bookings.update(id, input)` | ✓ | — |
316
+ | `bookings.confirm / cancel / move(…, admin?, { editToken }?)` | ✓ | per `ownerActions.bookings` |
317
+ | `bookings.extendHold / complete / noShow` | ✓ | — |
318
+ | `bookings.freebusy(q, admin?)` | ✓ | if `publicRead.bookings.freebusy` |
319
+ | `bookings.slots.create / createMany / update / remove` | ✓ | — |
320
+ | `bookings.slots.list / get(…, admin?)` | ✓ | open, public slots |
321
+ | `bookings.blocks.create / list / remove` | ✓ | — |
322
+ | `utils.freeTimes / withinSchedule / openIntervals / subtractIntervals / validateSchedule` | pure helpers | |
323
+
324
+ REST: `/{admin|public}/collection/:cId/app/:appId/bookings[/slots|/blocks|/freebusy|/mine|/:id[/confirm|/cancel|/move|/extend-hold|/complete|/no-show]]`.