Skip to content

Commit 6ef9d9c

Browse files
Merge pull request #7 from peek-travel/booking-hook
Booking hook
2 parents 2596038 + d555de9 commit 6ef9d9c

12 files changed

Lines changed: 657 additions & 6 deletions

File tree

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,39 @@ both `import` and `require` consumers (including the Node 22 / CommonJS Firebase
196196
Functions runtime) resolve correctly. Its only runtime dependency is
197197
`jsonwebtoken`.
198198

199+
## Webhooks
200+
201+
Receiver apps can consume Peek **booking** and **waiver** webhooks without
202+
hand-writing a payload parser. Each has a pure parser (construct nothing — no
203+
auth/network) that returns a clean model:
204+
205+
```ts
206+
import {
207+
parseBookingWebhook,
208+
parseWaiverWebhook,
209+
type Booking,
210+
type Waiver,
211+
} from "@peektravel/app-utilities";
212+
213+
app.post("/booking-webhook", (req, res) => {
214+
const booking: Booking = parseBookingWebhook(req.body);
215+
res.sendStatus(200);
216+
});
217+
218+
app.post("/waiver-webhook", (req, res) => {
219+
const waiver: Waiver = parseWaiverWebhook(req.body);
220+
res.sendStatus(200);
221+
});
222+
```
223+
224+
Both tolerate the delivery envelope / a bare node / a JSON string and never throw
225+
on malformed input. They differ on registration: a **booking** webhook's payload
226+
shape is set by a GraphQL query configured **once in an external system** (the
227+
App Store `broadcast_to_url` config) — this package documents and drift-guards
228+
the exact query to paste there — whereas a **waiver** webhook has a fixed payload,
229+
so you just subscribe to its event with no query. **The query to register and the
230+
full guide: [`docs/webhooks.md`](docs/webhooks.md) (shipped).**
231+
199232
## UI components (`/ui`)
200233

201234
The package also ships framework-agnostic **Web Components** ported from the Peek
@@ -326,6 +359,7 @@ src/ui/ Web Components + Odyssey CSS (barrel: src/ui/index.ts
326359
test/ vitest unit tests (test/ui/* run under happy-dom)
327360
examples/ui-gallery.html component gallery (npm run sample)
328361
dist/ build output (generated, git-ignored)
362+
docs/webhooks.md booking-webhook consumer guide (shipped)
329363
docs/internal/ maintainer docs (ARCHITECTURE.md — not shipped)
330364
llms.txt AI-agent quickstart (shipped in the package)
331365
```

docs/internal/ARCHITECTURE.md

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,52 @@ Resources: `products`, `account-users`, `resource-pools`, `timeslots`,
9494
`resellers`, `promo-codes`, `daily-notes`, `availability`, `memberships`,
9595
`bookings`, `reviews`. Clean data shapes live in `src/models/`.
9696

97+
`waivers` is a **webhook-only resource**: it has no GraphQL reads (so no
98+
queries/service/converter triad), just `src/internal/waivers/waiver-webhook.ts`
99+
and the `src/models/waiver.ts` model. See the webhook notes below.
100+
97101
A resource may split into more than one triad when it carries a distinct
98102
sub-domain. `bookings` does: alongside `booking-queries`/`booking-converter`,
99103
the add-on flows live in `addon-queries.ts` (the `sales` add-ons query + raw
100104
node shapes) and `addon-converter.ts` (raw node → the internal `AddonItem`
101-
detail model and the clean public `BookingAddon`). The detailed `AddonItem`
105+
detail model and the clean public `BookingAddon`).
106+
107+
`bookings` also carries the webhook surface (`booking-webhook.ts`). A Peek
108+
booking webhook's payload shape is defined by the GraphQL field selection
109+
registered with it, so the registered query and the parser must stay in lockstep.
110+
Crucially, that registration is done **once in an external system** (the App
111+
Store `broadcast_to_url` config), not from consumer code — so the package
112+
registers nothing at runtime, and the two halves split:
113+
114+
- **The query is a setup-time artifact, not a runtime API.**
115+
`BOOKING_WEBHOOK_GQL_QUERY` is the single maximal selection set (guests + full
116+
price breakdown always included) built from the same field fragments the read
117+
path uses (`bookingQueryFields`, `bookingGuestsFields`, `PRICE_BREAKDOWN_FIELDS`,
118+
exported from `booking-queries` for reuse). It is the bare selection set (no
119+
`query`/`sales` wrapper — the webhook system supplies that; whitespace
120+
collapsed so it drops into a JSON config string). It is **internal**
121+
surfaced for humans/AI through `docs/webhooks.md` and pinned by a drift-guard
122+
test that snapshots the exact string, so a field change is caught here before
123+
it diverges from the external config (e.g. the connector's `app.json`).
124+
- **The parser is the only public runtime export.** The pure
125+
`parseBookingWebhook(body)` unwraps the `{booking:…}` delivery envelope (or a
126+
bare node / JSON string) and runs the existing `fromBookingNode` converter,
127+
auto-detecting guests/price-breakdown from the payload (nothing to keep in sync
128+
with the registered query) and never throwing on malformed input. Parsing needs
129+
no auth, network, or client, so it is a **standalone function, not a method on
130+
`PeekAccessService`** (a receiver may not hold gateway credentials).
131+
132+
The **waiver** webhook (`waivers/waiver-webhook.ts`) is the simpler sibling and
133+
deliberately diverges from the booking shape because the upstream webhook does
134+
too: it has **no GraphQL query** (the App Store `waiver_webhook_data` output
135+
format ships a fixed payload, `output_fields_gql_query` is null), so there is no
136+
query constant and no drift-guard — only a parser. `parseWaiverWebhook(body)`
137+
unwraps the `{waiver:…}` envelope (or a bare node / JSON string) and runs the
138+
pure `fromWaiverNode` converter, which maps the fixed `snake_case` payload to the
139+
flat clean `Waiver` model (defaulting missing fields to `""`/`null`/`false`, so
140+
it never throws). Same standalone-pure-function rationale as bookings. Because
141+
there are no reads, `waivers` carries no queries/service triad — just the
142+
webhook module and the model. The detailed `AddonItem`
102143
model (refids + reservation statuses) is **internal only** — consumers see just
103144
the grouped `BookingAddons`; the internal model exists solely so add/remove can
104145
build their mutation payloads.
@@ -148,7 +189,10 @@ The barrel re-exports only the public contract: `PeekAccessService` + its config
148189
each resource service class (and the options/result types callers need), all
149190
data-model **types**, the `Logger` interface + `noopLogger`, and the three typed
150191
error classes. Query strings and raw response interfaces are deliberately kept
151-
internal.
192+
internal — including the booking-webhook registration query
193+
(`BOOKING_WEBHOOK_GQL_QUERY` stays internal, documented via `docs/webhooks.md`).
194+
The webhook-related public exports are the two parsers `parseBookingWebhook` and
195+
`parseWaiverWebhook` (plus the `Waiver` model type; see the webhook notes above).
152196

153197
### 6. UI components — the `./ui` subpath
154198
`src/ui/`

docs/webhooks.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Webhooks
2+
3+
A guide for wiring a receiver app up to Peek "backoffice" webhooks using
4+
`@peektravel/app-utilities`. Two webhook types are supported today — **booking**
5+
and **waiver** — and each has a parser that turns the delivered payload into a
6+
clean data model. They differ in one important way: a booking webhook is
7+
configured with a GraphQL query (so the package documents the exact query to
8+
register), while a waiver webhook has a fixed payload (so there's nothing to
9+
register beyond subscribing to the event).
10+
11+
| Webhook | Register | Parse the delivery |
12+
| --- | --- | --- |
13+
| Booking | paste a GraphQL query into the external config (below) | `parseBookingWebhook(body)``Booking` |
14+
| Waiver | subscribe to the event — **no query needed** | `parseWaiverWebhook(body)``Waiver` |
15+
16+
Both parsers are pure transforms — no auth, no network, construct nothing.
17+
18+
# Booking webhooks
19+
20+
## The problem this solves
21+
22+
A Peek booking webhook is unusual: its payload shape is **not fixed**. The
23+
webhook is configured with a GraphQL field selection (`output_fields_gql_query`);
24+
each time it fires, the gateway runs that selection against the booking and POSTs
25+
the result to your receiver. So two things must agree — the registered selection
26+
and the code that parses the payload.
27+
28+
That registration is done **once, in an external system** (the Peek App Store
29+
`broadcast_to_url` config / your app's `app.json`), not from your application
30+
code. So the two halves split cleanly:
31+
32+
| Half | Where it lives | This package provides |
33+
| --- | --- | --- |
34+
| Register the query | external config, set once | the canonical query string to paste (below) |
35+
| Parse the delivery | your receiver's code | `parseBookingWebhook(body)` → a clean `Booking` |
36+
37+
The query string below and the parser are derived from the **same** field
38+
selection this package uses for booking reads, and a test pins the string, so the
39+
two can't silently drift.
40+
41+
## Step 1 — register this query (external, one-time)
42+
43+
Paste the following into the webhook's `output_fields_gql_query` field (with
44+
`output_format: "gql"`). It is the **maximal** selection — it includes guests and
45+
the full price breakdown, so one registration captures everything; the parser
46+
auto-detects whatever a given payload carries.
47+
48+
```
49+
{ displayId id primaryGuest { name email phone optinMarketing optinSms isGdpr postalCode } activitySnapshot { type name id } ticketQuantities { quantity resourceOptionSnapshot { name id } } reservationStatus checkinStatus returnStatus fulfillmentStatusOverride { status } timeSnapshot { id legacyId } purchasedAt purchasedAtUtc startsAt startsAtUtc endsAt endsAtUtc availabilityTimeId bookingPortalUrl operatorNotes value { convenienceFee { amount formatted } deposit { amount formatted } discount { amount formatted } discountedPrice { amount formatted } fees { amount formatted } flatPartnerFee { amount formatted } price { amount formatted } retailPrice { amount formatted } taxes { amount formatted } tips { amount formatted } total { formatted amount } } balance { total { amount formatted } } tips { price { amount formatted } } order { displayId id promoCodes { code } channelSnapshot { id name agent { name } } initialQuote { source { actor { app } } } } questionAnswers { answer questionText questionLocationSnapshot { latitude longitude } } tickets { questionAnswers { answer questionText } } resourcePoolAssignments { quantity resourcePool { name shortName resources { name } } resourceAssignments { resource { id name } } } bookingGuests { id name country dateOfBirth email isGdpr isParticipant optinSms optinMarketing phone postalCode fieldResponses { id text fieldLocation { field { name } } } } primaryGuest { id name country dateOfBirth email isGdpr isParticipant optinSms optinMarketing phone postalCode fieldResponses { id text fieldLocation { field { name } } } } }
50+
```
51+
52+
> This package owns this string internally and snapshots it in
53+
> `test/bookings/booking-webhook.test.ts`. If the booking fields ever change, the
54+
> test fails here first — update the test snapshot, this doc, and the external
55+
> config together.
56+
57+
## Step 2 — parse the delivered webhook into a `Booking`
58+
59+
This is the only part that lives in your code. Hand the raw request body to
60+
`parseBookingWebhook`; it returns the same clean
61+
[`Booking`](../README.md#resources) model the read services return.
62+
63+
```ts
64+
import { parseBookingWebhook, type Booking } from "@peektravel/app-utilities";
65+
66+
app.post("/booking-webhook", (req, res) => {
67+
const booking: Booking = parseBookingWebhook(req.body);
68+
// booking.bookingId, booking.customerName, booking.startsAt, booking.isCanceled, …
69+
res.sendStatus(200);
70+
});
71+
```
72+
73+
`parseBookingWebhook`:
74+
75+
- **Needs no auth, network, or `PeekAccessService`** — it is a pure transform.
76+
Construct nothing; just call it. (This is why it's a standalone function, not a
77+
method on the access service, and why the receiver needs no gateway
78+
credentials to parse.)
79+
- **Tolerates the delivery envelope.** It accepts the `{ booking: … }` wrapper
80+
the webhook sends, a bare booking node, or a JSON string body.
81+
- **Auto-detects** guests and the price breakdown from the payload, so there is
82+
nothing to keep in sync with the registered query — `booking.guests` and
83+
`booking.taxes`/`booking.fees`/… populate when present.
84+
- **Never throws on malformed input** — a missing/garbled body yields a `Booking`
85+
with empty fields rather than an exception, so a bad delivery can't crash your
86+
handler.
87+
88+
## Notes
89+
90+
- The webhook fires on booking **create** and **update**; both deliver the same
91+
booking payload, and `parseBookingWebhook` handles them identically. It does
92+
not currently surface which event fired.
93+
- Authenticating the delivery (verifying it really came from Peek) is the
94+
receiver's responsibility and out of scope for this parser.
95+
96+
# Waiver webhooks
97+
98+
A waiver webhook fires when a participant signs a liability agreement. Unlike the
99+
booking webhook, its payload is **fixed** — Peek delivers a predefined shape (the
100+
`waiver_webhook_data` output format), so there is **no GraphQL query to
101+
register**.
102+
103+
## Step 1 — subscribe to the event (external, one-time)
104+
105+
Register the webhook for the `agreement_signature_created` event in the external
106+
config. Leave `output_fields_gql_query` null; the `output_format` is
107+
`waiver_webhook_data`. There is nothing query-shaped to paste.
108+
109+
## Step 2 — parse the delivered webhook into a `Waiver`
110+
111+
```ts
112+
import { parseWaiverWebhook, type Waiver } from "@peektravel/app-utilities";
113+
114+
app.post("/waiver-webhook", (req, res) => {
115+
const waiver: Waiver = parseWaiverWebhook(req.body);
116+
// waiver.bookingId, waiver.templateId, waiver.fileUrl, waiver.signedAt,
117+
// waiver.guestName, waiver.isSignedByGuardian, …
118+
res.sendStatus(200);
119+
});
120+
```
121+
122+
`parseWaiverWebhook` mirrors the booking parser: a pure transform (no
123+
auth/network/`PeekAccessService`), it tolerates the `{ waiver: … }` envelope / a
124+
bare node / a JSON string, maps the raw `snake_case` payload to the clean
125+
camelCase [`Waiver`](#waiver-webhooks) model, and never throws on malformed input
126+
(missing fields become `""` / `null` / `false`).
127+
128+
The resulting `Waiver` is flat:
129+
130+
| Field | Type | From |
131+
| --- | --- | --- |
132+
| `templateId` | `string` | `agreement_template_id` |
133+
| `bookingId` | `string` | `booking_id` |
134+
| `fileUrl` | `string` | `file_url` |
135+
| `signedAt` | `string` | `signed_at` (ISO) |
136+
| `isSignedByGuardian` | `boolean` | `signed_by_guardian` |
137+
| `guestName` | `string \| null` | `waiver_data.participant_name` |
138+
| `isOptinMarketing` | `boolean` | `waiver_data.participant_optin_marketing` |
139+
| `isOptinSms` | `boolean` | `waiver_data.participant_optin_sms` |
140+
141+
Authenticating the delivery is the receiver's responsibility, as with bookings.

llms.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ const peek = new PeekAccessService({
6868
importable; branch with `instanceof`.
6969
- Plain `Error` for validation/precondition failures thrown before any request.
7070

71+
## Webhooks (booking + waiver)
72+
73+
Two webhook parsers, each a pure transform — needs no auth/network/
74+
`PeekAccessService`; accepts the `{<key>:…}` envelope, a bare node, or a JSON
75+
string; never throws on malformed input. Registration is done **once in an
76+
external system** (App Store config), not from code.
77+
78+
- `parseBookingWebhook(body): Booking` — auto-detects guests/breakdown. Booking
79+
payload shape is defined by a GraphQL selection registered externally; paste
80+
the canonical maximal selection into `output_fields_gql_query`. That string is
81+
documented in `docs/webhooks.md` and pinned by a drift-guard test — it is NOT a
82+
runtime export.
83+
- `parseWaiverWebhook(body): Waiver` — maps the fixed `snake_case`
84+
`waiver_webhook_data` payload to the clean `Waiver` model. **No query to
85+
register** — just subscribe to the `agreement_signature_created` event.
86+
87+
**Full guide + booking query-to-register: `docs/webhooks.md` (shipped).**
88+
7189
## UI components (`@peektravel/app-utilities/ui`)
7290

7391
Browser-only, framework-agnostic Web Components (Custom Elements, light DOM,

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@
3939
"files": [
4040
"dist",
4141
"llms.txt",
42-
"docs/ui.md"
42+
"docs/ui.md",
43+
"docs/webhooks.md"
4344
],
4445
"sideEffects": [
4546
"**/ui/**",

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export type {
3636
CancelBookingResult,
3737
} from "./internal/bookings/booking-service.js";
3838

39+
export { parseBookingWebhook } from "./internal/bookings/booking-webhook.js";
40+
41+
export { parseWaiverWebhook } from "./internal/waivers/waiver-webhook.js";
42+
3943
export { ReviewService } from "./internal/reviews/review-service.js";
4044

4145
export { ADD_ON_PRODUCT_TYPE } from "./models/product.js";
@@ -110,6 +114,7 @@ export type {
110114
BookingAddonsMutationResult,
111115
} from "./models/booking-addon.js";
112116
export type { Guide, Review } from "./models/review.js";
117+
export type { Waiver } from "./models/waiver.js";
113118

114119
export { noopLogger } from "./logger.js";
115120
export type { Logger } from "./logger.js";

src/internal/bookings/booking-queries.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const guestFields = `
3535
}
3636
`;
3737

38-
const bookingGuestsFields = `
38+
export const bookingGuestsFields = `
3939
bookingGuests {
4040
${guestFields}
4141
}
@@ -44,7 +44,7 @@ const bookingGuestsFields = `
4444
}
4545
`;
4646

47-
const bookingQueryFields = `
47+
export const bookingQueryFields = `
4848
displayId
4949
id
5050
primaryGuest {
@@ -158,7 +158,7 @@ const bookingQueryFields = `
158158
}
159159
`;
160160

161-
const PRICE_BREAKDOWN_FIELDS = `
161+
export const PRICE_BREAKDOWN_FIELDS = `
162162
convenienceFee { amount formatted }
163163
deposit { amount formatted }
164164
discount { amount formatted }

0 commit comments

Comments
 (0)