Introduction
Welcome to the PeekPlus GraphQL API!
You can use our API to access thousands upon thousands of things to do, curated by us.
With the PeekPlus API you can
- Search For Activities
- Check Real Time Availability
- Create Quotes / Orders
💥💥 One Endpoint 💥💥
https://api.peek.com/gql
Why GraphQL?
We chose GraphQL over some older standards because it is the best tool for us to expose our data to you in an elegant, self-documenting, flexible and performant way.
If you are new to GraphQL: we are confident you will never think about APIs the same way again.
If you are already familiar with GraphQL: we can feel your excitement; we're right there with you.
Smaller, Customizable Payloads
In a traditional "here is the endpoint to return activities" scenario, the original creators would include everything they felt "reasonable" - maybe something like:
- name
- description
- image
Then someone comes along and makes a compelling case for exposing the location. So, for them, we add a few new fields for lat
and lng
.
Then someone comes along later and asks for review meta data because they want to expose it. Reasonable. So again we go and add a few new fields for the count
and average_rating
.
Then someone comes along and says they want a field that is actually pretty heavy. They are aware that it is heavier but are comfortable only asking for a few activities. So we add it.
Now the original users who just want the name
and description
are telling us the api is too slow; they were querying for a ton of activities and that heavy field is now a problem. So we hack together some query params where you can white-list fields:
http://example.com/activities?fields=name,description...
Great, but when you ask for activities, we actually want to also be able to get the tickets.. so now you are all
http://example.com/activities?fields=name,description,tickets.name...
..and everything starts to feel really dirty.
With GraphQL, we simply expose our object graph:
- Activity
- Name
- Description
- Tickets
- Ticket
- Name
And you, the owner of your experience, ask for what you need and we return it to you. Simple as that. Furthermore, if you find your self wanting 4 or 5 different things, you can ask for them all at once saving many round trips to the server.
Wins. All. Around.
Strongly Typed
With a traditional REST API, you find yourself stringing together complex query params to filter content:
http://example.com/activities?sort=price&filter[state]=NY...
Inevitably the question comes in "what are possible values for SORT", or "what should I use for the STATE?". In those traditional APIs, you'd be digging for the email that linked to the docs or some light guessing and checking. It is then up to the api server to gracefully handle all possible mistakes to get you a reasonable error message if you deviate from what's expected. This is a lot of work for both parties, and is error prone.
With GraphQL, every query is Strongly Typed - you get immediate feedback of what the value can be, and if you deviate from it, the request doesn't even make it to our application logic; you are immediately given a good error explaining your mistake.
My client is catching a typo for me:
Self Documenting
Piggy-backing on the strongly type point from above, GraphQL was written from the ground up to be easy to "introspect". There are a host of great free clients out there; we are big fans of https://insomnia.rest/graphql/ - simply by downloading that client and pointing to our sandbox url, you have every query, field and mutation documented. We find crafting your queries there and then moving them into your app is a solid way to work.
Further Reading
Authentication
JWT
When you are granted access to the API, you will be given an Api Key ("your-api-key") and a Secret ("your-secret"). Every request you make will require you to create a JWT that includes your API Key in the sub
field and a unique ID in the jti
field, which is then signed w/ your secret:
{"sub": "your-api-key", "jti": "unique-id", "exp": 1516239022}
If you're new to JWTs, https://jwt.io/ is a GREAT place to start:
defmodule Token do
use Joken.Config
@doc """
Generate a signed token w/ default claims
"""
def generate do
{:ok, claims} =
# NOTE: Joken automatically adds a uniq `jti` + `exp` for every token generated.
Token.generate_claims(%{
"sub" => "your-api-key"
})
{:ok, token, _claims} = encode_and_sign(claims, signer())
token
end
defp signer, do: Joken.Signer.create("HS256", "your-secret")
end
Token.generate()
The reason we chose JWT is because there exists a client for (nearly?) every language imaginable. No need to write your own logic, just find the library (all listed on the above site) for your language and it should be straightforward from there.
We are an Elixir Shop, so our library of choice is called Joken
; on the right is how you might choose to implement the above w/ Joken
.
Add Token as Header
The Token you generated above has to be included in the header for every request:
Authorization: Bearer TOKEN
Idempotency
Since every request that you make must contain a unique request id inside the signed token, if you make a second request with the same request id we will assume this you replaying a request and will return the original response.
Versioning
Another advantage of GraphQL is that the need for versioning largely goes away; the schema evolves overtime without the need for strict versions.
Backwards Compatible Changes
99% of our GraphQL changes are backwards compatible; they involve deprecating a field which just results in a warning if used but doesn't stop working. Or it's a new field altogether and since you aren't asking for it, it doesn't affect you.
Breaking Changes
We do everything possible to avoid these, and at the time of writing this, see no reason why we'll ever have them. That said, let's say a field has been deprecated for 6 months and an integration continues to use it even after being told that it's going away, eventually that field will have to go, which yes, would be a breaking change. Beyond this we don't see a reason why we can't evolve this API through clean deprecations. We will notify all clients using deprecated fields of the potential breaking change and give ample time to update before removing completely.
Entities
Activity
An Activity represents something people do to have fun. An Activity have a list of dates, the date when an instance of an Activity occurs. And, each date, have a list of times, the specific times for the given date when the activity starts and ends.
Attributes
Attribute | Description |
---|---|
id |
id The unique identifier for the object. |
name |
string The name of the activity. |
dates |
array<AvailabiltyDate> List of dates when the activity occurs. |
tickets |
array<Ticket> List of the supported tickets types for the activity. |
requiresAdult |
boolean Indicates if the quote must have 1 Adult or 1 Senior or 1 Traveler ticket types along the selected tickets sent. |
minTravelersPerBooking |
integer Regarding all the ticket types and quantities selected, you must, at least, have this number of travelers selected for the booking. |
maxTravelersPerBooking |
integer Regarding all the ticket types and quantities selected, you must, at most, have this number of travelers selected for the booking. |
ActivityVariant
An ActivityVariant is a variation for the Activity that represents little differences between the original activity.
Any particular activity may consist of a number of variants, each of which might represent different departure times, tour routes, or packaged extras like additional meals, transport and so forth.
Attributes
Attribute | Description |
---|---|
id |
id The unique identifier for the object. |
title |
string Human readable text with the short name of the variant. |
description |
string Human readable text that contains specific information and details about the variant. |
ActivityVariantQuestion
An ActivityVariantQuestion is a booking question that may be required to answer abd represents vital information specified by the supplier about the individual travelers or the group as a whole to be sent to the supplier as part of the request in order to complete a booking.
Implementations (or Types)
Type name |
---|
StringQuestion |
DateQuestion |
TimeQuestion |
SingleChoiceQuestion |
LocationQuestion |
NumberAndUnitQuestion |
Attributes
Attribute | Description |
---|---|
id |
id The unique identifier for the question. This must be sent with the answers when booking an activity. |
required |
boolean Indicates if answering the question is required (true) or not (false). |
group |
array<ActivityVariantQuestionGroup> Indicates how the question should be answered: once PER_TRAVELER or once PER_BOOKING. |
hint |
string A string that contains information on how the field is expected to be answered. It may be not present. |
label |
non-null string A string that contains the human readable name of the question being asked. Examples: First Name, Last Name, Pickup Location, etc. |
Other fields may be present depending on the question type. These are the specific fields for each question type:
StringQuestion
Attributes
Attribute | Description |
---|---|
max_length |
integer The maximum allowed length for the answer to this question. |
Validation
The answer to this question must be a string that have at most max_length
in size.
DateQuestion
Attributes
no other attributes present
Validation
The answer to this question must be a valid string representing a "Date" in the format described by ISO 8601:2019.
TimeQuestion
no other attributes present
Validation
The answer to this question must be a valid string representing a "Local time" in the format described by ISO 8601:2019.
SingleChoiceQuestion
Attributes
Attribute | Description |
---|---|
options |
array<ActivityVariantQuestionOption> The unique identifier for the question. This must be sent with the answers when booking an activity. |
Validation
The answer to this question must be a string that is contained within the provided options value
attribute.
LocationQuestion
Attributes
Attribute | Description |
---|---|
max_length |
integer The maximum allowed length for the answer to this question. |
options |
array<ActivityVariantQuestionOption> The unique identifier for the question. This must be sent with the answers when booking an activity. |
Validation
The answer to this question must be a string that is contained within the provided options value
attribute.
NumberAndUnitQuestion
Attributes
Attribute | Description |
---|---|
units |
array<string> List of units for the question. The selected unit must be sent together with the answer for this type of question. |
Validation
The answer to this question must be a string that correctly represents a number (1000
, 105.56
, 0.1234
are valid numbers) and the unit must be one of the values contained withing the units
array.
ActivityVariantQuestionGroup
Indicates how the question should be answered: once PER_TRAVELER or once PER_BOOKING.
Values
Value | Description |
---|---|
PER_BOOKING |
Indicates that the answer to the question must be answered for each traveler, i.e., answered once per ticket added to the quote . |
PER_TRAVELER |
Indicates that the answer to the question must be answered for the booked group as a whole, i.e., answered once per booking. |
ActivityVariantQuestionOption
An ActivityVariantQuestionOption represents a possible answer for a given question inside a list of possible answers (or options). Each option may have a list of followup questions, i.e. questions that must be answered for a given answer to the parent question of this option.
Attributes
Attribute | Description |
---|---|
name |
string Human readable text that indicates the value of this option. |
value |
string The actual value for the option that must be sent as an answer for the parent question. |
questions |
array<ActivityVariantQuestion> A list of follow up questions for the option, the may or may not be required to answer (based on each follow up question required attribute) |
AvailabilityDate
An AvailabilityDate is the representation of a date when an Activity occurs. For a given date, multiple AvailabilityTime can be related to the same date, indicating possible start and end times for the activity on the given date.
Attributes
Attribute | Description |
---|---|
date |
string Human readable text that indicates the value of this option. |
isAvailable |
string The actual value for the option that must be sent as an answer for the parent question. |
times |
array<AvailabilityTime> A list of follow up questions for the option, the may or may not be required to answer (based on each follow up question required attribute) |
AvailabilityTime
An AvailabilityTime represents a start and an end time for a given date when the parent Activity happens.
Attributes
Attribute | Description |
---|---|
id |
string The unique identifier for this object. |
localDateTimeStart |
string NaiveDateTime ISO8601 formatted string that indicates which date and time the activity starts, without timezone. |
localDateTimeEnd |
string NaiveDateTime ISO8601 formatted string that indicates which date and time the activity ends, without timezone. |
status |
AvailabilityTimeStatus If the given time is available and if not, why. |
variant |
ActivityVariant The activity variant that have the availability for this specific time. |
AvailabilityTimeStatus
The status of a given AvailabilityTime.
Values
Value | Description |
---|---|
AVAILABLE |
Currently available for sale, but has a fixed capacity. |
CLOSED |
Currently not available for sale, but not sold out (e.g. temporarily on stop-sell) and may be available for sale soon. |
FREESALE |
Always available. |
LIMITED |
Currently available for sale, but has a fixed capacity and may be sold out soon. |
SOLD_OUT |
Currently sold out, but additional availability may free up. |
CancellationReason
The reason for the cancellation.
Values
Value | Description |
---|---|
BOOKED_WRONG_TOUR_DATE |
Booked the wrong tour date. |
CANCELED_ENTIRE_TRIP |
Cancelled the entire trip. |
CHOSE_A_DIFFERENT_CHEAPER_TOUR |
Chose a different but cheaper tour. |
DUPLICATE_BOOKING |
Duplicate booking. |
SIGNIFICANT_GLOBAL_EVENT |
Significant global event. |
SUPPLIER_NO_SHOW |
Supplier no show. |
UNEXPECTED_MEDICAL_CIRCUMSTANCES |
Unexpected medical circumstances. |
WEATHER |
Weather. |
Ticket
A Ticket represents the supported ticket type for a given Activity, which may have different configurations and prices supported by the activity provider. They can be Adult, Child, Traveler, etc
Attributes
Attribute | Description |
---|---|
id |
string The unique identifier for this object. |
name |
string The name that represents the ticket, like Adult, Child, Traveler, etc. |
minQuantity |
integer The minimum number of this ticket that can be booked at once. |
maxQuantity |
integer The maximum number of this ticket that can be booked at once. |
Queries
As mentioned earlier, the source of truth for what's available can be browsed from directly inside your GraphQL Client. Duplicating and maintaining the spec would defeat the advantages of GraphQL. With that in mind, this section will be very high level to help shape your understanding of what's available, not the source of truth.
Activities
The Activities
query is the most powerful query in the API in that it can be used for two major use-cases:
- Provide activities based on filters/sorting (location / price / keywords / etc). This could power real-time experiences where you need very specific activities for a given use-case.
- You want to "ingest" our entire inventory through crawling every page of the results, pulling every field that is of importance to you.
query Activities($filter: ActivitiesFilter!, $pagination: Pagination!, $sort: ActivitySort) {
activities(filter: $filter, pagination: $pagination, sort: $sort) {
entries {
name
}
}
}
For the purposes of this example, let's say you just need each Activity's name
. Your query for both use-cases would be identical.
In the two examples above, the main difference is what you pass in for variables. For a very specific query you'd pass in filters and sort values based on your desires. For crawling the whole inventory, you'd also likely not include any filters and you'd ask for totalPages
so you can crawl through all of them, etc.
Example Response:
{
"activities": [{
"entries": [
{ "name": "Activity 1" },
{ "name": "Activity 2" },
]
}]
}
Availability
Determining whether something is available can be thought of as a two part process.
- Get high-level cacheable dates + times to get an idea of, roughly speaking, when a given activity is available.
- Given a specific set of tickets (1x Adult, 2x Child) for a specific time, is it available and what will the price be.
The first is what we're going to talk about here. The second is in the next section as we call that a BookingQuote Mutation.
See ActivityVariants.
query DocExampleGetActivityById($id: ID!, $startDate: Date!, $endDate: Date!, $quantity: Int) {
activity(id: $id) {
name
dates(startDate:$startDate, endDate: $endDate, quantity: $quantity) {
date
times {
id
localDateTimeStart
localDateTimeEnd
status
variant {
id
title
description
}
}
}
}
}
With GraphQL, pulling availability is simply a "field" you can request for an activity.
Notice with GraphQL, "fields" can accept arguments (in this case the desired availability).
The power of the above is that you can do the exact same thing with a search result and in a single query to the server, answer the question "What are the top 10 things to do close by and what dates/times are available for them".
ActivityVariants
An ActivityVariant is a variation for the Activity that represents little differences between the original activity.
Any particular activity may consist of a number of variants, each of which might represent different departure times, tour routes, or packaged extras like additional meals, transport and so forth.
Attributes
Attribute | Description |
---|---|
id |
id The unique identifier for the activity variant. |
title |
string Human readable text with the short name of the variant. |
description |
string Human readable text that contains specific information and details about the variant. |
fragment SimpleOptionsFields on ActivityQuestionOption {
name
value
}
fragment CompleteOptionsFields on ActivityQuestionOption {
name
questions {
__typename
id
group
required
label
hint
... on NumberAndUnitQuestion {
units
}
... on LocationQuestion {
allowCustomPickup
maxLength
options {
...SimpleOptionsFields
}
}
... on SingleChoiceQuestion {
options {
...SimpleOptionsFields
}
}
... on StringQuestion {
maxLength
}
}
value
}
query DocExampleGetActivityVariantById($id: ID!) {
activityVariant(id: $id) {
... on ActivityVariant {
title
description
questions {
__typename
id
group
required
label
hint
... on NumberAndUnitQuestion {
units
}
... on LocationQuestion {
allowCustomPickup
maxLength
options {
...SimpleOptionsFields
}
}
... on SingleChoiceQuestion {
options {
...CompleteOptionsFields
}
}
... on StringQuestion {
maxLength
}
}
}
}
}
CreateBookingQuote
A quote holds a set of inventory while locking in a price. You can think of a quote as a draft booking or a reservation.
There are times when something you thought would be available based on the Availability Calls is not available when making the quote.
This can happen for a few reasons:
1) The availability call was out of sync. Given this makes a real-time call to check the supplier's inventory, there's always a window where our initial call returned availability but this call didn't. Our goal is to make this window as small as possible
2) Certain suppliers have very complex rules on how they accept bookings. It might be that your specific tickets aren't available, or there are complex rules around time-cutoffs.
3) There are ticket rules to respect when asking for a quote. The specific activity and ticket fields are described below and how they affect the mutation result:
Activity
Attribute | Description |
---|---|
requiresAdult |
boolean Indicates if the quote must have 1 Adult or 1 Senior or 1 Traveler ticket types along the selected tickets sent. |
maxTravelersPerBooking |
integer Regarding all the ticket types and quantities selected, you must, at least, have this number of travelers selected for the booking. |
minTravelersPerBooking |
integer Regarding all the ticket types and quantities selected, you must, at most, have this number of travelers selected for the booking. |
Ticket
Attribute | Description |
---|---|
minQuantity |
integer The minimum number of this ticket that can be booked at once. |
maxQuantity |
integer The maximum number of this ticket that can be booked at once. |
With those caveats aside, making these requests might look like this:
mutation CreateQuote($request: BookingQuoteInput!) {
createBookingQuote(quoteInput: $request) {
__typename
... on BookingQuote {
id
priceBreakdown {
price {
formatted
}
total {
formatted
}
taxes {
formatted
}
}
snapshot {
activity {
variant {
id
title
}
}
}
}
... on Error {
message
}
}
}
Request variables
{
"request": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 4,
"ticketId": "<ticket-id>"
}
]
}
}
And a Response like this
{
"data": {
"createBookingQuote": {
"__typename": "BookingQuote",
"id": "<booking-quote-id>",
"priceBreakdown": {
"price": {
"formatted": "$620.00"
},
"taxes": {
"formatted": "$0.00"
},
"total": {
"formatted": "$620.00"
}
},
"snapshot": {
"activity": {
"variant": {
"id": "<variant-id>",
"title": "Solvang Food and Photo Tour"
}
}
}
}
}
}
CreateBookingQuote Errors
BadRequestError
Given the provided activity properties on the side and summarized below, we will show different inputs with values that do not match the activity and/or tickets requirements, with the according error messages for each one:
Activity Response
{
"data": {
"activities": {
"entries": [
{
"id": "<activity-id>",
"maxTravelersPerBooking": 10,
"minTravelersPerBooking": 2,
"name": "Solvang Food & Photo Tour: Explore Danish Cuisine & Photography in Santa Ynez Valley",
"requiresAdult": true,
"tickets": [
{
"id": "<adult-ticket-id>",
"maxQuantity": 10,
"minQuantity": 1,
"name": "Adult"
},
{
"id": "<youth-ticket-id>",
"maxQuantity": 4,
"minQuantity": 0,
"name": "Youth"
}
]
}
],
"pageNumber": 1,
"pageSize": 200,
"totalEntries": 1,
"totalPages": 1
}
}
}
Activity
Attribute | Value |
---|---|
requiresAdult |
true |
minTravelersPerBooking |
2 |
maxTravelersPerBooking |
10 |
Adult Ticket
Attribute | Value |
---|---|
minQuantity |
1 |
maxQuantity |
10 |
Youth Ticket
Attribute | Value |
---|---|
minQuantity |
0 |
maxQuantity |
4 |
"Requires at least one adult or senior"
When it happens?
Not providing the ADULT ticket, only the YOUTH ticket.
requiresAdult |
true |
Sending ADULT ticket in request | false |
Range minTravelersPerBooking - maxTravelersPerBooking |
2 - 10 |
Quantity sent on Request | 2 |
Adult Ticket
Range minQuantity - maxQuantity |
1 - 10 |
Quantity sent on Request | 0 |
Youth Ticket
Range minQuantity - maxQuantity |
0 - 4 |
Quantity sent on Request | 2 |
Request input
{
"request": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 2,
"ticketId": "<youth-ticket-id>"
}
]
}
}
Response
{
"data": {
"createBookingQuote": {
"__typename": "BadRequestError",
"message": "Requires at least one adult or senior"
}
}
}
"Minimum # of guests is <num> and requires at least one adult or senior"
When it happens?
Not providing the ADULT ticket and not meeting the minTravelersPerBooking
for the activity.
requiresAdult |
true |
Sending ADULT ticket in request | false |
Range minTravelersPerBooking - maxTravelersPerBooking |
2 - 10 |
Quantity sent on Request | 1 |
Adult Ticket
Range minQuantity - maxQuantity |
1 - 10 |
Quantity sent on Request | 0 |
Youth Ticket
Range minQuantity - maxQuantity |
0 - 4 |
Quantity sent on Request | 1 |
Request input
{
"request": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 1,
"ticketId": "<youth-ticket-id>"
}
]
}
}
Response
{
"data": {
"createBookingQuote": {
"__typename": "BadRequestError",
"message": "Minimum # of guests is 2 and requires at least one adult or senior"
}
}
}
"Minimum # of guests for ticket '<ticket-id>' is <num>"
When it happens?
Providing the ADULT ticket, the YOUTH ticket and not meeting the minQuantity
for the YOUTH ticket.
requiresAdult |
true |
Sending ADULT ticket in request | true |
Range minTravelersPerBooking - maxTravelersPerBooking |
3 - 10 |
Quantity sent on Request | 2 |
Adult Ticket
Range minQuantity - maxQuantity |
1 - 10 |
Quantity sent on Request | 1 |
Youth Ticket
Range minQuantity - maxQuantity |
2 - 4 |
Quantity sent on Request | 1 |
Request input
{
"request": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 1,
"ticketId": "<adult-ticket-id>"
},
{
"quantity": 1,
"ticketId": "<youth-ticket-id>"
}
]
}
}
Response
{
"data": {
"createBookingQuote": {
"__typename": "BadRequestError",
"message": "Minimum # of guests for ticket '<youth-ticket-id>' is 2"
}
}
}
"Maximum # of guests for ticket '<ticket-id>' is <num>"
When it happens?
Providing the ADULT ticket, the YOUTH ticket and not meeting the maxQuantity
for the YOUTH ticket.
requiresAdult |
true |
Sending ADULT ticket in request | true |
Range minTravelersPerBooking - maxTravelersPerBooking |
2 - 10 |
Quantity sent on Request | 10 |
Adult Ticket
Range minQuantity - maxQuantity |
1 - 10 |
Quantity sent on Request | 5 |
Youth Ticket
Range minQuantity - maxQuantity |
0 - 4 |
Quantity sent on Request | 5 |
Request input
{
"request": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 5,
"ticketId": "<adult-ticket-id>"
},
{
"quantity": 5,
"ticketId": "<youth-ticket-id>"
}
]
}
}
Response
{
"data": {
"createBookingQuote": {
"__typename": "BadRequestError",
"message": "Maximum # of guests for ticket '<youth-ticket-id>' is 4"
}
}
}
"Maximum # of guests is <num>"
When it happens?
Providing the ADULT ticket, the YOUTH ticket and not meeting the maxTravelersPerBooking
for the activity.
requiresAdult |
true |
Sending ADULT ticket in request | true |
Range minTravelersPerBooking - maxTravelersPerBooking |
2 - 10 |
Quantity sent on Request | 11 |
Adult Ticket
Range minQuantity - maxQuantity |
1 - 10 |
Quantity sent on Request | 7 |
Youth Ticket
Range minQuantity - maxQuantity |
0 - 4 |
Quantity sent on Request | 4 |
Request input
{
"request": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 7,
"ticketId": "<adult-ticket-id>"
},
{
"quantity": 4,
"ticketId": "<youth-ticket-id>"
}
]
}
}
Response
{
"data": {
"createBookingQuote": {
"__typename": "BadRequestError",
"message": "Maximum # of guests is 10"
}
}
}
UpdateBookingQuote
It is useful to update a quote as a customer changes the tickets, switches their desired time, add required booking question answers, etc.
This call is nearly identical to the CreateBookingQuote:
mutation EditBookingQuote($request: EditBookingQuoteInput!) {
editBookingQuote(input: $request) {
__typename
... on BookingQuote {
id
priceBreakdown {
price {
formatted
}
taxes {
formatted
}
fees {
formatted
}
total {
formatted
}
}
}
... on Error {
message
}
}
}
Request variables
{
"request": {
"bookingQuoteId": "<booking-quote-id>",
"quoteInput": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 5,
"ticketId": "<ticket-id>"
}
]
}
}
}
Booking Questions
Some activities may require questions answers in order to be possible to book.
The questions may be
- PER_BOOKING, meaning you must answer it once;
- PER_TRAVELER, meaning you must answer them for each traveler.
Let's consider the example on the side. You have the <available-time-id> with the <activity-variant-id> for which it belongs to. So you can query that specific ActivityVariant and see if there are questions that must be answered. See ActivityVariants do recall the query that produces the json output replicated on the side.
Request variables
{
"id": "<activity-variant-id>"
}
Response
{
"data": {
"activityVariant": {
"description": "Skip the tourist traps and head out with a local foodie for a fun-filled, behind-the-scenes cultural food tour in the unique Danish village nestled in the heart of the Santa Ynez Valley (30 miles north of downtown Santa Barbara). \n\nOur Solvang food tour starts along Mission Drive and will walk you through the the downtown neighborhood streets where you will taste traditional Danish and local artisan food and drink unique to Solvang and the Central Coast. Indulge in bites of traditional Danish comfort fare and freshly baked goods as well as craft beer, local wine, & artisan eats. Along the way, you’ll get immersed in the local culture and hear interesting stories about each place you’ll visit.\n\nYou’ll also learn easy-to-shoot food photo tips from your pro tour guide on this foodie photography tour and take your photo skills to the next level using your smartphone’s camera app. By the end of your 3-4 hour tour, you’ll be eating like a local and shooting like a pro!",
"questions": [
{
"__typename": "DateQuestion",
"group": "PER_TRAVELER",
"hint": null,
"id": "<date-of-birth-question-id>",
"label": "Date of birth",
"required": true
},
{
"__typename": "StringQuestion",
"group": "PER_TRAVELER",
"hint": null,
"id": "<first-name-question-id>",
"label": "First name",
"maxLength": 50,
"required": true
},
{
"__typename": "StringQuestion",
"group": "PER_TRAVELER",
"hint": null,
"id": "<last-name-question-id>",
"label": "Last name",
"maxLength": 50,
"required": true
},
{
"__typename": "StringQuestion",
"group": "PER_BOOKING",
"hint": null,
"id": "<special-requirements-question-id>",
"label": "Special requirements",
"maxLength": 1000,
"required": false
},
{
"__typename": "SingleChoiceQuestion",
"group": "PER_BOOKING",
"hint": "Select the language you want for this tour",
"id": "<tour-language-question-id>",
"label": "Tour language",
"options": [
{
"followupQuestions": [],
"name": "English (Guide)",
"value": "GUIDE|en|en/SERVICE_GUIDE"
}
],
"required": true
}
]
}
}
}
In order to be able to book this activity, i.e., send the quote id to the createBooking mutation and it succeeds, we will need to update the quote with the answers for each type of question.
As we can see:
- The "Tour language" question IS REQUIRED to answer, only once (PER_BOOKING)
- The "Special requirements" question IS NOT REQUIRED to answer, only once (PER_BOOKING)
- The "Date of birth", "First Name" and "Last Name" questions ARE REQUIRED to answer, for each traveler (PER_TRAVELER)
For the updated graphql query below (with the answers fields added for the purpose of this documentation)
mutation DocExampleEditBookingQuote($request: EditBookingQuoteInput!) {
editBookingQuote(input: $request) {
__typename
... on BookingQuote {
id
priceBreakdown {
price {
formatted
}
taxes {
formatted
}
fees {
formatted
}
total {
formatted
}
}
snapshot {
activity {
variant {
id
title
}
bookingAnswers {
question
answer
}
}
tickets {
id
quantity
guestsAnswers {
guestAnswers {
question
answer
}
}
}
}
}
... on Error {
message
}
}
}
The
editBooking
request variables would be like this:
{
"request": {
"bookingQuoteId": "<booking-quote-id>",
"quoteInput": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 2,
"ticketId": "<adult-ticket-id>",
"guestsAnswers": [
{
"guestAnswers": [
{
"question": "<date-of-birth-question-id>",
"answer": "Adult1 Date of Birth"
},
{
"question": "<first-name-question-id>",
"answer": "Adult1 First Name"
},
{
"question": "<last-name-question-id>",
"answer": "Adult1 Last Name"
}
]
},
{
"guestAnswers": [
{
"question": "<date-of-birth-question-id>",
"answer": "Adult2 Date of Birth"
},
{
"question": "<first-name-question-id>",
"answer": "Adult2 First Name"
},
{
"question": "<last-name-question-id>",
"answer": "Adult2 Last Name"
}
]
}
]
}
],
"bookingAnswers": [
{
"question": "<special-requirements-question-id>",
"answer": "No special requirements"
},
{
"question": "<tour-language-question-id>",
"answer": "GUIDE|en|en/SERVICE_GUIDE"
}
]
}
}
}
And the Response would be like
{
"data": {
"editBookingQuote": {
"__typename": "BookingQuote",
"id": "qb098ej",
"priceBreakdown": {
"fees": {
"formatted": "$0.00"
},
"price": {
"formatted": "$310.00"
},
"taxes": {
"formatted": "$0.00"
},
"total": {
"formatted": "$310.00"
}
},
"snapshot": {
"activity": {
"bookingAnswers": [
{
"answer": "No special requirements",
"question": "<special-requirements-question-id>"
},
{
"answer": "GUIDE|en|en/SERVICE_GUIDE",
"question": "<tour-language-question-id>"
}
],
"variant": {
"id": "<activity-variant-id>",
"title": "Solvang Food and Photo Tour"
}
},
"tickets": [
{
"guestsAnswers": [
[
{
"guestAnswers": [
{
"answer": "Adult1 Date of Birth",
"question": "<date-of-birth-question-id>"
},
{
"answer": "Adult1 First Name",
"question": "<first-name-question-id>"
},
{
"answer": "Adult1 Last Name",
"question": "<last-name-question-id>"
}
]
}
],
[
{
"guestAnswers": [
{
"answer": "Adult2 Date of Birth",
"question": "<date-of-birth-question-id>"
},
{
"answer": "Adult2 First Name",
"question": "<first-name-question-id>"
},
{
"answer": "Adult2 Last Name",
"question": "<last-name-question-id>"
}
]
}
]
],
"id": "<adult-ticket-id>",
"quantity": 2
}
]
}
}
}
}
UpdateBookingQuote Errors
BadRequestError
Given the provided activity properties on the side and summarized below, we will show different inputs with values that do not match the activity and/or tickets requirements, with the according error messages for each one:
Activity Response
{
"data": {
"activities": {
"entries": [
{
"id": "<activity-id>",
"maxTravelersPerBooking": 10,
"minTravelersPerBooking": 2,
"name": "Solvang Food & Photo Tour: Explore Danish Cuisine & Photography in Santa Ynez Valley",
"requiresAdult": true,
"tickets": [
{
"id": "<adult-ticket-id>",
"maxQuantity": 10,
"minQuantity": 1,
"name": "Adult"
},
{
"id": "<youth-ticket-id>",
"maxQuantity": 4,
"minQuantity": 0,
"name": "Youth"
}
]
}
],
"pageNumber": 1,
"pageSize": 200,
"totalEntries": 1,
"totalPages": 1
}
}
}
Activity
Attribute | Value |
---|---|
requiresAdult |
true |
minTravelersPerBooking |
2 |
maxTravelersPerBooking |
10 |
Adult Ticket
Attribute | Value |
---|---|
minQuantity |
1 |
maxQuantity |
10 |
Youth Ticket
Attribute | Value |
---|---|
minQuantity |
0 |
maxQuantity |
4 |
"Requires at least one adult or senior"
When it happens?
Not providing the ADULT ticket, only the YOUTH ticket.
Activity
requiresAdult |
true |
Sending ADULT ticket in request | false |
Range minTravelersPerBooking - maxTravelersPerBooking |
2 - 10 |
Quantity sent on Request | 2 |
Adult Ticket
Range minQuantity - maxQuantity |
1 - 10 |
Quantity sent on Request | 0 |
Youth Ticket
Range minQuantity - maxQuantity |
0 - 4 |
Quantity sent on Request | 2 |
Request input
{
"request": {
"bookingQuoteId": "<booking-quote-id>",
"quoteInput": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 2,
"ticketId": "<youth-ticket-id>"
}
]
}
}
}
Response
{
"data": {
"editBookingQuote": {
"__typename": "BadRequestError",
"message": "Requires at least one adult or senior"
}
}
}
"Minimum # of guests is <num> and requires at least one adult or senior"
When it happens?
Not providing the ADULT ticket and not meeting the minTravelersPerBooking
for the activity.
Activity
requiresAdult |
true |
Sending ADULT ticket in request | false |
Range minTravelersPerBooking - maxTravelersPerBooking |
2 - 10 |
Quantity sent on Request | 1 |
Adult Ticket
Range minQuantity - maxQuantity |
1 - 10 |
Quantity sent on Request | 0 |
Youth Ticket
Range minQuantity - maxQuantity |
0 - 4 |
Quantity sent on Request | 1 |
Request input
{
"request": {
"bookingQuoteId": "<booking-quote-id>",
"quoteInput": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 1,
"ticketId": "<youth-ticket-id>"
}
]
}
}
}
Response
{
"data": {
"editBookingQuote": {
"__typename": "BadRequestError",
"message": "Minimum # of guests is 2 and requires at least one adult or senior"
}
}
}
"Minimum # of guests for ticket '<ticket-id>' is <num>"
When it happens?
Providing the ADULT ticket, the YOUTH ticket and not meeting the minQuantity
for the YOUTH ticket.
Activity
requiresAdult |
true |
Sending ADULT ticket in request | true |
Range minTravelersPerBooking - maxTravelersPerBooking |
3 - 10 |
Quantity sent on Request | 2 |
Adult Ticket
Range minQuantity - maxQuantity |
1 - 10 |
Quantity sent on Request | 1 |
Youth Ticket
Range minQuantity - maxQuantity |
2 - 4 |
Quantity sent on Request | 1 |
Request input
{
"request": {
"bookingQuoteId": "<booking-quote-id>",
"quoteInput": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 1,
"ticketId": "<adult-ticket-id>"
},
{
"quantity": 1,
"ticketId": "<youth-ticket-id>"
}
]
}
}
}
Response
{
"data": {
"createBookingQuote": {
"__typename": "BadRequestError",
"message": "Minimum # of guests for ticket '<youth-ticket-id>' is 2"
}
}
}
"Maximum # of guests for ticket '<ticket-id>' is <num>"
When it happens?
Providing the ADULT ticket, the YOUTH ticket and not meeting the maxQuantity
for the YOUTH ticket
Activity
requiresAdult |
true |
Sending ADULT ticket in request | true |
Range minTravelersPerBooking - maxTravelersPerBooking |
2 - 10 |
Quantity sent on Request | 10 |
Adult Ticket
Range minQuantity - maxQuantity |
1 - 10 |
Quantity sent on Request | 5 |
Youth Ticket
Range minQuantity - maxQuantity |
0 - 4 |
Quantity sent on Request | 5 |
Request input
{
"request": {
"bookingQuoteId": "<booking-quote-id>",
"quoteInput": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 5,
"ticketId": "<adult-ticket-id>"
},
{
"quantity": 5,
"ticketId": "<youth-ticket-id>"
}
]
}
}
}
Response
{
"data": {
"editBookingQuote": {
"__typename": "BadRequestError",
"message": "Maximum # of guests for ticket '<youth-ticket-id>' is 4"
}
}
}
"Maximum # of guests is <num>"
When it happens?
Providing the ADULT ticket, the YOUTH ticket and not meeting the maxTravelersPerBooking
for the activity
Activity
requiresAdult |
true |
Sending ADULT ticket in request | true |
Range minTravelersPerBooking - maxTravelersPerBooking |
2 - 10 |
Quantity sent on Request | 11 |
Adult Ticket
Range minQuantity - maxQuantity |
1 - 10 |
Quantity sent on Request | 7 |
Youth Ticket
Range minQuantity - maxQuantity |
0 - 4 |
Quantity sent on Request | 4 |
Request input
{
"request": {
"bookingQuoteId": "<booking-quote-id>",
"quoteInput": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 7,
"ticketId": "<adult-ticket-id>"
},
{
"quantity": 4,
"ticketId": "<youth-ticket-id>"
}
]
}
}
}
Response
{
"data": {
"editBookingQuote": {
"__typename": "BadRequestError",
"message": "Maximum # of guests is 10"
}
}
}
Invalid Questions Error
If you forget to provide required questions answers or provide invalid questions answers, you will receive the InvalidQuestionsError with details about the invalid questions detected.
Request (no required questions answers provided)
{
"request": {
"bookingQuoteId": "<booking-quote-id>",
"quoteInput": {
"availabilityTimeId": "<available-time-id>",
"tickets": [
{
"quantity": 2,
"ticketId": "<adult-ticket-id>"
}
],
"bookingAnswers": [
{
"question": "<special-reqs-question-id>",
"answer": "No special requirements"
}
]
}
}
}
Response
{
"data": {
"createBooking": {
"__typename": "InvalidQuestionsError",
"message": "Missing guest answers for tickets: <ticket-id>. Missing required questions: <question1-id>, <question2-id>, ..., <questionN-id>"
}
}
}
CreateBooking
This will create a booking out of a provided quote. At this point, your booking is submitted and there's nothing left to do on your end.
mutation CreateBooking($request: CreateBookingInput!) {
createBooking(input: $request) {
__typename
... on Booking {
id
}
... on Error {
message
}
... on InvalidCustomerError {
invalidFields
}
}
}
Request variables
{
"request": {
"bookingQuoteId": "<booking-quote-id>",
"customer": {
"name": "Product Demo",
"email": "valid.email.address@peek.com",
"phone": "0196488932",
"country": "US",
"postalCode": "94501"
}
}
}
Response
{
"data": {
"createBooking": {
"__typename": "Booking",
"id": "<booking-id>"
}
}
}
CreateBooking Errors
Invalid Customer
If you provide invalid customer details, you will receive the InvalidCustomerError with details about the invalid fields detected.
Request
{
"request": {
"bookingQuoteId": "<booking-quote-id>",
"customer": {
"name": "Product Demo",
"email": "invalid_email_address",
"phone": "0196488932",
"country": "US",
"postalCode": "94501"
}
}
}
Response
{
"data": {
"createBooking": {
"__typename": "InvalidCustomerError",
"invalidFields": [
"email"
],
"message": "Please provide valid customer details. Invalid: email."
}
}
}
Invalid Questions Error
If you forget to provide required questions answers to the quote you want to proceed and make a booking for, you will receive the InvalidQuestionsError with details about the invalid questions detected.
Request
{
"request": {
"bookingQuoteId": "<booking-quote-id>",
"customer": {
"name": "Product Demo",
"email": "invalid_email_address",
"phone": "0196488932",
"country": "US",
"postalCode": "94501"
}
}
}
Response
{
"data": {
"createBooking": {
"__typename": "InvalidQuestionsError",
"message": "Missing guest answers for tickets: <ticket-id>. Missing required questions: <question1-id>, <question2-id>, ..., <questionN-id>"
}
}
}
Source Error
Sometimes, when creating a booking, the activity provider may have any kind of error while processing the booking request. In this case you will receive an error like this one.
{
"data": {
"createBooking": {
"__typename": "SourceError",
"message": "Something unexpected happened, please retry your request. Engineers are notified and will report back."
}
}
}
Request variables
{
"request": {
"bookingQuoteId": "qb0nm9r",
"customer": {
"name": "Peek Plus Demo",
"email": "name@example.com",
"phone": "0196488932",
"country": "US",
"postalCode": "12345"
}
}
}
CancelBooking
This will cancel the booking in our system and notify the provider that the customer will no longer be coming. We are assuming a refund will be issued on your end and update the customer in our system to indicate a refund has been granted.
mutation CancelBooking($input: CancelBookingInput!) {
cancelBooking(input: $input) {
__typename
... on Booking {
status
}
... on Error {
message
}
}
}
Request variables
{
"input": {
"bookingId": "<booking-id>",
"cancellationReason": "WEATHER"
}
}
Response
{
"data": {
"cancelBooking": {
"__typename": "Booking",
"status": "CANCELED"
}
}
}
CancelBooking Errors
CancellationRejectionError
If you do not provide a cancellationReason
you will get this error.
Request variables
{
"input": {
"bookingId": "<booking-id>"
}
}
Response
{
"data": {
"cancelBooking": {
"__typename": "CancellationRejectionError",
"message": "You must provide a valid cancellation reason when cancelling a booking."
}
}
}
Webhooks
Booking Cancellation
Typically, when you send a booking into PeekPlus and the customer needs to cancel, they will reach out to your company and your company will cancel the booking. This cancellation will make its way to the underlying supplier to let them know the customer will no longer be coming.
However, there are times when the supplier has to cancel the booking and they need to let YOU know that it happened so you can do whatever it is you need to on your end (refund the customer, update any records in your system, etc).
The first step is to reach out to your contact at PeekPlus and provide the url to hit (for both sandbox and production) for these webhooks (ex https://peekplus.example.com/webhooks).
Once that's been done, anytime we need to notify you of a cancellation, we will POST to [YOUR_URL_PROVIDED_ABOVE]/peek-plus-webhooks/bookings/[BOOKING_ID]/cancel
.
For authentication we will send an auth header w/ a JWT that is signed w/ your api key + secret; just like you send to us for your requests.
To test this process, we added the ability to "Trigger" one of these async webhooks yourself:
mutation TriggerCancellationWebHook($bookingId: ID!) {
triggerCancellationWebHook(bookingId: $bookingId) {
message
}
}
If you open your GQL Client, make a booking and then use the above mutation, it'll give you real-time feedback on what your server returned to help you with debugging.
Try It Out
Once you have been granted an API Key + API Secret click the button below.
This will get you an entire working api ready to click through.
Go Live Process
- The first step is ensuring that your sandbox env can successfully make/cancel bookings + respond to the webhook for handling downstream cancellations.
- The next step is to do the exact same process against production using our test activity (this activity can be found by query for activities w/ the filter param of "test=true" - but for convenience, the id is "a08ypb").
- The final step is to make a production booking for the test activity using your UI and we will see it come through, ensure things look correct, and cancel it which will trigger the webhook to your system (presumably, already tested w/ step 2) and we can ensure that it works in practice.
- We ask that you also send a screenshot of the email you send to the customer so we can double check that things match what is shown in our system.
- Once that happens, we will flip on your integration and you can book against any production activity!
Updates
Date | Description |
---|---|
Nov 6, 2023 | Removed section: Mutations. Added new sections: Entities CreateBookingQuote, CreateBookingQuote Errors, UpdateBookingQuote, UpdateBookingQuote Errors, CreateBooking, CreateBooking Errors, CancelBooking, CancelBooking Errors and Updates |
Jun 7, 2022 | Added new sections: Go Live Process and Webhooks |
May 13, 2020 | Added new section: Try It Out |
Jan 31, 2020 | Initial Docs |