| name | peek-backoffice-api |
| description | How to read and write Peek Pro back-office data with the PeekAccessService SDK (from @peektravel/app-utilities) — the concrete Peek capability surface and the data rules around it. Use when calling the Peek API (products/activities, availability/timeslots, bookings, orders, payments, customers) and for booking/order ID normalization, installDataId scoping, the timeslot wall-clock hazard, PII handling, and Peek's raw-GraphQL escape hatch. The installed package's own type definitions are the authoritative SDK surface — read them (see javascript-app-utilities). Triggers on "Peek API", "getAllActivities", "getAllProducts", "searchBookings", "getAllAccountUsers", "getTimeslotsForDay", "assignTimeslotGuide", "PeekAccessService", "SDK methods", "list bookings", "Peek GraphQL", "activity data", "booking data", "timeslot", "timeslot timezone", "startTime", "wall-clock", "installDataId". |
Talking to the Peek back-office API
This is the concrete Peek data mechanism. The generic discipline — use the platform's official
SDK, never a raw client; normalize IDs; minimize PII; discover capabilities from the installed SDK
rather than assuming — lives in backoffice-data. Read that for the why; this skill is the
authoritative how it works on Peek.
Authenticated requests get a ready-to-use, install-scoped PeekAccessService instance (called
peek by convention). The auth pipeline builds it for you: inside a route wrapped in
withAppAuthentication<PeekAccessService> (the current, brand-agnostic wrapper — it verifies once
and branches on the token's platform claim to the Peek accessor), the second argument is the
authenticated PeekAccessService (see peek-embed-and-auth).
export const GET = withAppAuthentication<PeekAccessService>(
async (_request: NextRequest, peek: PeekAccessService) => {
const products = await peek.getAllActivities();
return NextResponse.json({ activities: products });
},
);
The installed types are the source of truth
The complete, authoritative SDK surface ships with the package — read it directly instead of
guessing. PeekAccessService is the Peek client; every public method is fully typed there with
TSDoc and exact return shapes. How to introspect the installed package (paths, confirming a
current version, enumerating the client's methods) is a stack concern — see
javascript-app-utilities. This skill states the capability boundary and the data rules.
The SDK surface is version-evolving — always confirm a method or field name against the
installed types before calling it; don't rely on the examples below as the limit or on model
memory. TODO(verify) anything the types/docs don't pin.
Methods confirmed in use by this starter kit
Concrete examples from the shipped routes (app/examples/peek-pro/main/api/ and
app/examples/dashboard/api/) — a starting point, not the limit:
peek.getAllActivities() → activity products ({ productId, name, color, … }).
peek.getAllProducts() → all products; filter out add-ons with the exported
ADD_ON_PRODUCT_TYPE constant (products.filter(p => p.type !== ADD_ON_PRODUCT_TYPE)).
peek.searchBookingsByTimeRange({ start, end, searchBy }) — start/end are ISO strings;
searchBy is "activityDate" or "purchaseDate". Bookings expose fields like isCanceled
and valueAmount (a string — parseFloat it for math).
peek.getAllAccountUsers() → account staff (with nested fields like
assignedResources[].accountUserId).
peek.getTimeslotsForDay(...) → the day's timeslots (see the wall-clock hazard below).
peek.assignTimeslotGuide(...) → assign a guide/resource to a timeslot.
The Peek capability boundary — raw GraphQL is a flagged last resort
This is Peek-specific and load-bearing:
- Peek exposes a GraphQL API underneath the SDK. On Peek, raw GraphQL exists as a flagged
last-resort escape hatch — if a capability is genuinely absent from the typed SDK, you
can drop to raw GraphQL. But raw GraphQL against an installed account is risky (misuse can
harm the account's underlying infrastructure), so flag it to the user first and confirm
before using it. Default: never hand-write GraphQL — use
PeekAccessService.
- This escape hatch is a PEEK-ONLY affordance. On other platforms (
cng, acme) there is
no GraphQL — the typed SDK is their hard ceiling. Don't carry Peek's "you can drop to
GraphQL in a pinch" assumption to another platform.
- Before reaching for GraphQL, confirm the capability is truly missing from the installed
types (
javascript-app-utilities) — far more of the API lives in the types than the handful of
methods the examples use. Only if it's genuinely absent, and only after flagging the risk.
All server-side (Node) interaction with Peek goes through @peektravel/app-utilities
(PeekAccessService) — from authenticated routes, the MCP endpoint, webhook handlers, scripts,
cron. It's already wired in lib/peek-service.ts.
Core resources (the domain map)
Field-level specifics live in the SDK's return types — read them in the installed package; don't
invent field names. The domain centers on:
- Products / activities — bookable experiences (tours, activities, rentals).
- Availability / timeslots — when a product can be booked; capacity per slot.
- Bookings — a customer's reserved spot(s) on a timeslot. Central to most apps.
- Orders — the commercial wrapper around bookings (line items, totals).
- Payments — charges, refunds, status. Treat as sensitive.
- Customers / guests — PII-bearing. Minimize what you store; prefer referencing Peek IDs.
Timeslots are local wall-clock time — no timezone
A Timeslot carries no timezone. Its time fields are the operator's local wall-clock:
date — YYYY-MM-DD
startTime — a 12-hour local time string like "5:00 PM" (the SDK exposes it as
node.start), with AM/PM — not 24-hour, not ISO, not UTC.
durationMin — length in minutes.
Peek attaches no offset and no zone; the slot means exactly what it reads in the operator's
locale. So:
- Read the wall-clock literally. Parse
date + startTime as-is (handle AM/PM). To compute
an end time, add durationMin to the parsed local time. Compare slots by their literal
date/startTime.
- Never convert a timeslot through a timezone. Do not feed it to
new Date(...),
Intl.DateTimeFormat, toISOString(), or any tz-aware conversion — those apply the server's
zone (UTC on Vercel) and silently shift the slot by hours. This caused a real half-day
reconciliation bug: a "5:00 PM" slot round-tripped through UTC and landed on the wrong half of
the day.
If you must build a real Date (e.g. to sort across days), keep the components local and never
assume the process timezone equals the operator's. When in doubt, treat the timeslot as opaque
local strings.
Booking & order IDs — normalize on input
Booking/order IDs arrive in two formats:
- Internal / canonical — lowercase + underscore:
b_123abc (booking), o_123abc (order).
- Display — uppercase + dash:
B-123ABC, O-123ABC (for humans only).
Whenever you receive an ID — from the SDK, a webhook, a URL, user input — normalize it to the
internal form first (lowercase the string, replace - with _, e.g. B-123ABC → b_123abc).
Store, compare, and key caches/lookups on the canonical form; use display form only for showing
humans. These IDs never change, so they're your stable keys. Mixing formats causes duplicate
or missed records.
Identity & scoping persisted data (when you add a DB)
Three identifiers matter, and they differ in permanence — key your data on the right one:
accountId (a.k.a. the partner id) is the permanent anchor — consistent across installs of the
app for that account, it does not change. Anything that must survive an uninstall→reinstall —
anything tied to the Peek account — key on accountId.
installId identifies a specific install (account + app). It is consistent for a given install
but may change — do not treat it as an immortal key for account-permanent data. It is the
handle you build a service client from — together with the install's apiUrl (below).
apiUrl — the per-install back-office endpoint (api.url). Persist it and build this install's
PeekAccessService against it as given, not a hardcoded/app-level URL (see "Build the client
from apiUrl" below).
- user id — who's acting on a given request (embed pipeline only).
Where each comes from. Every authenticated request's verified token gives you installId (+ the
acting user) — see the "What's in the token" note in peek-embed-and-auth — but not accountId.
accountId, accountName, platform, apiUrl, and timezone arrive only on the install
webhook, which this kit now scaffolds (app/examples/webhooks/install-status/route.ts; on
JavaScript, verify + parse with the SDK's (@peektravel/app-utilities) parseInstallWebhook — on a
non-JS stack there is no such package, so replicate it by hand per the roll-your-own in webhooks —
see peek-webhooks). That delivery is the single source of the account identity and the endpoint;
persist it on install.
Phase 0 has no database, so there's nothing to scope yet. When you add persistence:
installDataId — mint your own per-install marker (installId + an install-time stamp),
stored as e.g. currentInstallDataId on the install/account record, and scope working data to
it. Its purpose is a clean wipe on a fresh (re)install: on reinstall, mint a new
installDataId and drop everything under the old one so the app starts fresh. (Account-permanent
data instead lives under accountId and is meant to survive.)
- Hang install-lifecycle handling on the install webhook, as a full-snapshot upsert by
installId.
On install/update, upsert the account/install record (capturing accountId, accountName,
platform, timezone, and apiUrl — always the latest) and stamp a fresh installDataId; on
uninstall, tear down / mark for wipe. Every event redelivers the full record, so overwrite apiUrl
each time — a later update_installed can move it. You can still lazily get-or-create on the first
authenticated request from auth.installId for the install handle, but accountId/apiUrl only
become available once the install webhook has fired.
Build the client from apiUrl. To act on an install, construct its PeekAccessService from the
persisted apiUrl — pass it as the config's apiUrl (used as given), or hand the whole record
to createAccessServiceForInstall({ platform, apiUrl, installId }, { jwtSecret, issuer }) which
wires it. The config's baseUrl/appId/mode are deprecated (they rebuild the URL from a
hardcoded gateway default that can't be right for every install); apiUrl takes precedence, and the
hardcoded fallback will be removed — a URL will become required, so source it from the webhook now.
These identity fields always arrive on the install delivery and are never null — model them as
non-nullable columns (installId, accountId, accountName, platform, isTest). apiUrl and
timezone may be "" on a given delivery, so keep the last non-empty value. The only null is the
version-mismatch sentinel on platform/status (an unrecognized wire value) — fail loud on it and
resolve it before writing; never persist the null.
Build the installDataId indirection in from the start of any persistence work — retrofitting it is
painful. For exact install-payload field names, check the installed package types + docs/
(javascript-app-utilities); TODO(verify) anything not pinned there.
Security & PII
Peek data routinely includes sensitive PII (guest names, emails, phones, payment metadata):
fullCustomerAccess — PII is OFF by default; treat pulling it as an opt-in. Build the
PeekAccessService with accessOptions: { fullCustomerAccess: boolean } (passed once at
construction — there is no per-call override; it applies to every service the client hands out).
It defaults to false (omitting accessOptions, {}, or undefined are all identical), and
false has two distinct effects:
- Customer PII is never requested — it's filtered out of the GraphQL selection at the
gateway, so identity fields (primary-guest
customerName/email/phone/postalCode,
per-guest identity, custom questionAnswers/fieldResponses, the customer portalUrl; review
customerName/customerEmail; waiver guestName/fileUrl) arrive null/empty. Silent
gotcha: no error, no warning — a redacted name looks exactly like a guest who left it blank.
Debugging "why is customerName null?" — check this flag first.
- Payment & booking-modification ops are disabled —
getPaymentsOnFile, makePayment,
refund, createInvoiceLink, addAddon, removeAddon throw an exported
PiiAccessDisabledError (its .operation names the blocked method) before any network
call. Everything else (create incl. markAsPaid, getById, getGuests, searchBy*,
cancel, appendNote, setCheckinStatus, timeslots, notes, availability, promo codes,
pricing) works without the flag.
- Recommend the default for operations/logistics tooling and demand/occupancy/revenue reporting
— it's fully functional PII-free and removes the compliance load of holding PII. Set
only when a concrete feature must contact customers or move money, and
only when the install is entitled (the gateway is the final authority and may still reject). Set
it where the kit constructs the (); need both modes for
one install? Construct two clients. Authoritative details (redaction table, blocked ops) live in
the package's "Access options / PII" section and the installed types:
(see
).
Related skills
backoffice-data (global) — the generic SDK-not-raw / ID-normalization / PII / discover-
don't-assume discipline behind this skill.
javascript-app-utilities (stack) — how to introspect the installed @peektravel/app- utilities package: types/docs/ paths, confirming a version, enumerating the client surface.
peek-embed-and-auth — how the authenticated peek client gets built per request, and
createPeekServiceForInstall for server paths with no user token.
peek-pricing — adjusting ticket prices via the pricing engine + overrides API on this same
PeekAccessService client (getPricingService()); Peek-only.
peek-webhooks — the inbound path; reuse the same ID normalization + installDataId
scoping to act on events.
peek-manifest-and-deploy — the peek_backoffice_api@v1 extendable in app.json grants
this API access.