|
| 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. |
0 commit comments