OCTO API reference
Authentication, endpoints, booking lifecycle, and error contract for resellers integrating against Resytech's OCTO supplier API.
This page is for the developer on the reseller side — an OTA, marketplace, or channel manager connecting to a Resytech operator through OCTO, the open connectivity standard for tours, activities, and attractions. Operator-side setup lives on the plugin page.
Resytech implements the OCTO core specification — supplier, products, availability, and the full booking lifecycle — plus the optional octo/pricing, octo/content, and octo/questions capabilities. If you have an existing OCTO client, point it at the endpoint below with your token and it should work unmodified.
Connection
| Endpoint | https://octo.resytech.com (no trailing slash; also returned by GET /supplier) |
| Authentication | Authorization: Bearer {token} — the operator issues you an rpat_… token from their dashboard |
| Content type | application/json |
| Capabilities | Octo-Capabilities: octo/pricing, octo/content, octo/questions opts into pricing, content, and questions. Send them comma-separated; any subset is fine. The response header echoes the capabilities actually honored; unsupported ids are silently dropped. Without the header you get core-spec responses, plus pricing on bookings (always disclosed — see Pricing). _capabilities=octo/pricing,octo/content as a query parameter works too. |
Your token is scoped to one operator location — the supplier — and carries scopes granted by the operator:
octo:read— supplier, products, availabilityocto:book— the entire/bookingssurface
All datetimes are ISO 8601. Availability times carry the local UTC offset of the supplier's timezone (e.g. 2026-09-15T14:00:00-04:00); the product's timeZone field is the IANA id. utc… fields are UTC with a Z suffix.
Rate limit: 120 requests per minute per token. Exceeding it returns 429; back off and retry.
Access is controlled by the operator: they can pause your account or disable the plugin at any time, which makes every request fail with 403 FORBIDDEN immediately. Treat a sudden 403 as "contact the operator", not as a retryable error.
Errors
Failures return the OCTO envelope:
{ "error": "INVALID_PRODUCT_ID", "errorMessage": "The productId was not found", "productId": "…" }| Code | HTTP | When |
|---|---|---|
UNAUTHORIZED | 401 | No bearer token sent |
FORBIDDEN | 403 | Invalid/revoked token, paused reseller, plugin disabled, or missing scope |
INVALID_PRODUCT_ID / INVALID_OPTION_ID / INVALID_UNIT_ID / INVALID_AVAILABILITY_ID / INVALID_BOOKING_UUID | 400 | The referenced id doesn't exist or isn't yours |
BAD_REQUEST | 400 | Malformed body, bad dates, oversized range |
UNPROCESSABLE_ENTITY | 400 | Valid request that can't be honored — departure sold out, party too large, hold expired |
TOO_MANY_REQUESTS | 429 | Rate limit |
INTERNAL_SERVER_ERROR | 500 | Our fault — safe to retry with backoff |
Products
GET /supplier
GET /products
GET /products/{id}A product is one bookable activity. Mapping notes:
availabilityTypeis alwaysSTART_TIME;availabilityRequiredis alwaystrue— you must obtain anavailabilityIdbefore booking.- Options are the activity's durations (a 60-minute and a 120-minute variant are two options). One option is flagged
default. - Units are guest types. Per-person products list the operator's demographics (
ADULT,CHILD, …, with the demographic's uuid asunitId); flat-priced products expose a single unit with idguest. availabilityLocalStartTimeslists the schedule's possible local departure times for that option; the availability endpoints are the source of truth for what's actually open.restrictions.minUnits/maxUnitson the option reflect the operator's per-booking guest minimum/limit (minUnits: 0means no minimum).requiredContactFieldsisfirstName,lastName,emailAddress,phoneNumber— collect all four before confirming.cancellationCutoffis the operator's cancellation window ("24 hours")."0 hours"means no window is configured and cancellations are accepted right up to departure. Inside a configured window,POST /bookings/{uuid}/cancelreturns400 UNPROCESSABLE_ENTITY; the booking response'scancellableflag is the per-booking signal (departed trips are never cancellable). Holds are always releasable.deliveryMethodsis["VOUCHER"]withdeliveryFormats: ["PDF_URL"]andredemptionMethod: "MANIFEST"— one voucher per booking, and the traveler is checked off the operator's manifest by name. There are no per-ticket barcodes andunitItems[].ticketis alwaysnull; see Vouchers for where the PDF comes from.
Vouchers
Once a booking is confirmed, its voucher.deliveryOptions carries the PDF the product advertises:
"voucher": {
"redemptionMethod": "MANIFEST",
"utcRedeemedAt": null,
"deliveryOptions": [
{ "deliveryFormat": "PDF_URL", "deliveryValue": "https://octo.resytech.com/vouchers/{uuid}/{token}" }
]
}- On holds the list is empty — a hold can lapse in minutes, so there is nothing to hand a traveler yet. It appears on the confirm response and every later
GET. - The URL never expires. Store it and render it whenever you like; it is safe to put straight into your own confirmation email or app. It is a capability URL — unguessable, read-only, and it exposes nothing you did not send us. There is no booking data behind it beyond the voucher itself, and no action can be taken through it.
- It always reflects the current booking. The PDF is rendered on demand, so a party change or a rescheduled departure shows up immediately — you never need to re-fetch a stale document.
- After a cancellation the link keeps working and the PDF renders a
CANCELLEDbanner, so you and the traveler keep a record. - It carries no prices. The traveler paid your retail price, which we never see; printing ours would confuse them and undercut your margin.
- If you shared a real traveler email on the booking, we also attach the same PDF to the confirmation email we send them. When you don't share one, the URL is the only delivery path — which is why it exists.
The document shows the activity, local departure window, meeting point and address, party breakdown by unit type, lead traveler name, your reference, the operator's contact details, and the cancellation terms.
Availability
POST /availability/calendar — one object per day, for range views (≤ 92 days/request)
POST /availability — one object per departure; REQUIRED before booking (≤ 31 days/request)Both take { productId, optionId } plus exactly one date selector: localDate, or localDateStart + localDateEnd (yyyy-MM-dd), or — availability check only — availabilityIds to re-check specific departures. Sending more than one is a 400 BAD_REQUEST: they contradict each other, and silently picking one would hide the caller's bug. The dates of an availabilityIds check come from the ids themselves.
Departure objects:
| Field | Meaning |
|---|---|
id | The availabilityId you pass to book. Deterministic per departure, but always re-check before booking — it's revalidated server-side either way. |
localDateTimeStart / localDateTimeEnd | Departure window with local offset |
status | AVAILABLE, LIMITED (≤ 3 spots), SOLD_OUT, or CLOSED (operator blackout) |
vacancies | Remaining spots when the product tracks them; null when untracked |
maxUnits | Most units a single booking can carry right now |
utcCutoffAt | Booking deadline (the departure instant — sales cutoffs are already applied to status) |
Calendar responses are cached for up to 60 seconds — fine for range views, which is why booking requires the uncached availability check.
Pricing (octo/pricing)
Send Octo-Capabilities: octo/pricing and the catalog and availability surfaces gain pricing fields. Prices are integer minor units (currencyPrecision: 2 — divide by 100 for dollars), currency is always USD, and every amount already includes your negotiated markup — what you see is your sell price, not the operator's list price. original always equals retail (no discount mechanics on this surface) and net is null — commission settlement happens outside the API per your agreement with the operator.
One exception to the opt-in: booking responses always carry pricing, capability header or not. That figure is what you are billed, so it is never hidden behind a header.
{ "original": 5750, "retail": 5750, "net": null, "currency": "USD", "currencyPrecision": 2, "includedTaxes": [] }Where pricing appears:
| Surface | Fields |
|---|---|
| Product | defaultCurrency, availableCurrencies, pricingPer (UNIT for per-person products that define guest types, BOOKING otherwise). UNIT products carry an indicative pricingFrom on each unit; BOOKING products carry one on the option itself. The figure is a computed floor: the lowest departure price over the next 90 days, with peak/off-peak adjustments and your markup applied — refreshed nightly and within minutes of operator edits that move prices or open/close departures (pricing, schedules, blackouts, equipment prices). For BOOKING-priced products the floor also includes the cheapest equipment piece actually rentable on that departure, so a rental priced entirely per equipment piece shows its real entry price rather than 0. A product with no sellable departure in that window omits pricingFrom entirely; treat an absent field as "no listing price", never as free |
| Availability check | unitPricing per departure (UNIT products); pricing — the total for the units you sent (UNIT, only when you send units) or the departure price (BOOKING, always) |
| Availability calendar | unitPricingFrom / pricingFrom — the day's lowest, same rules |
| Booking | pricing — the final amount incl. fees and taxes (includedTaxes breaks out tax), on reserve/confirm/get/list responses. Always present, no capability needed |
When a product prices per UNIT, unitPricing is the rate for ONE unit of that type and pricing is the total for the party you sent. The two always reconcile: pricing.retail equals the sum of unitPricing[].retail × quantity for the party in your request, so you can validate line items against the total. (The booking response's pricing remains the authoritative charged amount — on rounding boundaries it can differ from an availability quote by at most a cent.)
Two accuracy notes: availability prices cover the activity component (per-departure rates, peak/off-peak adjustments, your markup) but not operator fees/taxes or equipment surcharges — the booking response's pricing is the exact, final figure, and you see it at reserve time before you commit. For BOOKING-priced products that price per equipment piece, send units and the availability price scales by the number of pieces your party needs; without units you get the single-piece price. The product-level pricingFrom is always the single-piece "from" figure (including the cheapest piece's equipment price, which availability rows leave to the booking response).
Content (octo/content)
Send Octo-Capabilities: octo/content and products gain images, structured copy, and the duration range — enough to build a listing page without asking the operator for assets by email.
{
"media": [
{ "src": "https://…/a/{activity}/hero.jpg", "type": "image/jpeg", "rel": "COVER", "caption": "Sunset paddle" }
],
"features": [
{ "shortDescription": "Liability Waiver: I accept all risks of the activity.", "type": "BOOKING_TERM" },
{ "shortDescription": "Cancel up to 24 hours before the start time.", "type": "CANCELLATION_TERM" },
{ "shortDescription": "Please arrive 15 minutes before the start time.", "type": "PREARRIVAL_INFORMATION" }
],
"durationMinutesFrom": 60,
"durationMinutesTo": 180,
"options": [
{ "id": "…", "internalName": "60 minutes", "durationMinutes": 60 }
]
}| Field | What it carries |
|---|---|
media[] | The operator's activity images in their configured order. The first is rel: "COVER", the rest GALLERY. type is a MIME type (image/jpeg, image/png, image/webp, image/gif, image/svg+xml, video/mp4), not a category. src is an absolute, publicly fetchable URL. Images are served in a display-optimised size rather than the operator's original upload. An image we cannot type with certainty is omitted rather than mislabelled, so media[] can be shorter than what the operator uploaded |
features[] | Structured copy, grouped by type — see below |
durationMinutesFrom / durationMinutesTo | Shortest and longest bookable option. durationMinutesTo is absent when the product has a single fixed duration |
options[].durationMinutes | That specific option's length. Prefer this over parsing internalName, which is human-readable and carries a disambiguating suffix when two options share a length |
title, shortDescription and description are filed under this capability by the spec, but we return them unconditionally — they predate the capability here and existing catalogs depend on them.
Feature types
| Type | Source |
|---|---|
BOOKING_TERM | The operator's active activity agreements — the terms a traveler accepts at checkout. Required agreements are listed first, then alphabetically; the order is stable across polls. An agreement the operator has switched off is not published on any channel and will not appear here. Present these to the traveler and collect acceptance in your own checkout |
CANCELLATION_TERM | The operator's own published cancellation policy when they have written one, otherwise prose form of the advertised cancellationCutoff. Omitted entirely when the operator advertises no cutoff — see below |
PREARRIVAL_INFORMATION | Arrival lead time and the operator's "what to bring" list |
On cancellation: a cancellationCutoff of "0 hours" means no cutoff is advertised, not free cancellation. It is the value we publish both when an operator offers cancellation without a deadline and when they do not offer traveler-initiated cancellation at all — the two are indistinguishable upstream. In that case no CANCELLATION_TERM is emitted, and you should not present the product as freely cancellable on the strength of the 0. Reseller-initiated cancellations are still accepted up to departure regardless (see Cancellation), and the per-booking cancellable flag is the authoritative answer for a given booking.
On agreements: OCTO has no per-agreement acceptance field, so there is nothing to send back — we do not record which boxes a traveler ticked on any channel. Presenting the BOOKING_TERM text is the whole obligation. If you send termsAccepted on a booking we accept the request and ignore the field rather than rejecting it.
An operator who has uploaded no images and written no agreements still gets a valid response: media is [] and features may be [] too. Treat both as normal, not as an error.
Responses vary by the capabilities you negotiate, so they carry Vary: Octo-Capabilities. If you cache catalogue responses behind a shared proxy, key on that header — otherwise a colleague's uncapability'd poll can overwrite your content-bearing copy.
Questions (octo/questions)
Operators can require custom fields on a booking — "Participant weight", "Emergency contact phone", a dietary note. Without this capability, any product carrying a required field is delisted from your catalog entirely — list and single-product read alike (you would have no way to answer, so every reserve could only fail). The one exception: a product you already hold a booking on always resolves on GET /products/{id}, so reconciliation keeps working whatever headers your internal clients send. Send Octo-Capabilities: octo/questions and those products are listed, with the fields advertised as questions on every option:
{
"questions": [
{ "id": "…custom field uuid…", "title": "Participant weight", "required": true, "inputType": "textarea" }
]
}This is a Ventrata extension, not part of the OCTO core spec. Questions are configured per activity, so the same set appears on each of a product's options; ordering is stable (required first, then alphabetical). inputType is always "textarea" — answers travel as strings and are format-checked server-side by field type. Select questions require one complete option label, including any comma in that label. Multi-select requires distinct option labels separated by commas; its labels cannot contain commas. Matching ignores case, stored values use the operator's spelling and option order, and rejection messages quote every allowed label. A structured option list is not currently advertised.
Answering. Booking write bodies (reserve, confirm, PATCH) accept questionAnswers:
{ "questionAnswers": [ { "questionId": "…", "value": "82kg" } ] }- Required questions must be answered by reserve time — a reserve that omits one returns
UNPROCESSABLE_ENTITYnaming the field. Optional ones can be added or changed later at confirm or via PATCH. Confirm re-checks the full required set: if the operator added a required question after your reserve, the confirm returnsUNPROCESSABLE_ENTITYnaming it — PATCH the answer in and confirm again. - A
nullor emptyvalueclears an optional answer. A required answer can be replaced but never blanked (BAD_REQUEST). - An unknown or duplicated
questionIdisBAD_REQUEST— re-poll the catalog if your form is stale. - Format rules by field type: dates must be
YYYY-MM-DD, timesHH:MM(24-hour), emails and URLs must parse, phone numbers accept international formats (+44 20 7946 0958is fine). Free-text answers cap at 5,000 characters; choice answers allow up to 5,098 characters so all 50 maximum-length labels and their separators fit. - Existing choice answers are preserved when an operator renames/removes options or changes a multi-select to a select. Omitting
questionAnswersduring a party/departure change keeps those historical answers; explicitly supplied answers must match the current definition. Missing required answers still fail. - Moving a booking to a different product (PATCH with a new
availabilityId) re-checks the destination's required questions: answers are keyed to the product they were given for and do not carry across, so send the destination's answers in the same PATCH.
Echo. When you negotiate the capability, booking responses carry questionAnswers — one entry per question on the booked product, value: null where unanswered — so you can reserve, read the answer set, present a form, and PATCH the answers in. Only answers you supplied are echoed back; anything the operator's staff or the traveler filled in on the operator's side stays on their side.
Booking lifecycle
Reserve → confirm, with an expiring hold in between:
POST /bookings octo:book — reserve (ON_HOLD)
POST /bookings/{uuid}/confirm octo:book — ON_HOLD → CONFIRMED
POST /bookings/{uuid}/extend octo:book — push the hold deadline
PATCH /bookings/{uuid} octo:book — update contact/notes/party/departure/product
POST /bookings/{uuid}/cancel octo:book — cancel a hold or a confirmed booking
GET /bookings/{uuid} octo:book
GET /bookings?resellerReference=&supplierReference=&localDate= octo:book — your bookings (newest 200; at least one filter required, else 400)Reserve
{
"uuid": "9be6f9c7-…",
"productId": "…", "optionId": "…",
"availabilityId": "…",
"unitItems": [ { "unitId": "…adult uuid…" }, { "unitId": "…adult uuid…" }, { "unitId": "…child uuid…" } ],
"expirationMinutes": 30,
"resellerReference": "YOUR-ORDER-123",
"contact": { "firstName": "…", "lastName": "…", "emailAddress": "…", "phoneNumber": "…" }
}uuidis your idempotency key. Retrying with the same uuid returns the reservation's current state and never double-books. Always send one.- One
unitItemsentry per traveler.unitIdis a unit id from the product (guestfor single-unit products). expirationMinutesis clamped to 5–1440; default 30. The response'sutcExpiresAtis authoritative.contactis optional at reserve — send it at confirm if you don't have it yet.firstName+lastNameis the documented shape; a singlefullNameis also accepted.- A successful reserve blocks the operator's live inventory immediately. An unconfirmed hold expires automatically at
utcExpiresAtand the inventory frees itself; the booking then readsEXPIRED.
The response is the OCTO booking object: status (ON_HOLD), supplierReference (the operator-side booking id — quote it in support conversations), your resellerReference, utcExpiresAt, availabilityId + availability, and your unitItems.
contact (and each unitItems[].contact) is always an object, with null fields when we hold no traveler details yet — never null itself. voucher is always present with redemptionMethod: "MANIFEST" and no deliveryOptions: there is nothing to scan, the traveler is checked off the manifest. utcRedeemedAt (booking, unit item, and voucher) is therefore always null, and unitItems[].ticket is always null. utcUpdatedAt tracks the last state change — creation, confirmation, cancellation, or hold expiry.
Update
PATCH /bookings/{uuid} takes any subset of the reserve fields; omitted fields are untouched. It works on ON_HOLD bookings and on CONFIRMED bookings that have not departed.
contact,notes,resellerReference— applied in place, no pricing impact.unitItems— the party changes: same re-derivation as confirm (inventory re-checked excluding the booking's own hold, price recalculated, retained unit itemuuids preserved).availabilityId— moves the booking to another departure. IfunitItemsisn't sent, the existing party re-books unchanged.productId/optionId, when sent, must agree with theavailabilityId(same rule as reserve) — and changing the product requires anavailabilityIdplusunitItemsusing the new product's unit ids.expirationMinutes— holds only; resets the TTL exactly like extend.- Updating a
CONFIRMEDbooking to a higher total records the difference as collected by you (merchant of record), same as confirm. A move that no longer fits returnsUNPROCESSABLE_ENTITYand leaves the booking exactly as it was.
Confirm, extend, cancel
- Confirm finalizes the booking (
CONFIRMED). Includecontact— the traveler's name, email, and phone attach to the operator's booking and drive their confirmations and manifest. Confirming an already-confirmed booking is idempotent. - Confirm may also carry
unitItems. If they differ from the reservation, the booking is re-derived for the new party: inventory is re-checked (excluding the booking's own hold) and the price is recalculated, so the total you are charged is the one for the units you confirmed. Re-sending the same units changes nothing. A party that no longer fits returnsUNPROCESSABLE_ENTITYand leaves the reservation on hold, so you can retry with fewer units. Unit itemuuids of units you keep are preserved. - Extend (
{ "expirationMinutes": 60 }) pushes the hold deadline of anON_HOLDbooking. Expired holds cannot be extended — reserve again. - Cancel (
{ "reason": "…" }) works on holds and confirmed bookings. The response'scancellation.refundis always"NONE": you are the merchant of record — refunding the traveler is on your side; the cancel releases the operator's inventory and their books.
Statuses
| Status | Meaning |
|---|---|
ON_HOLD | Reserved, inventory blocked, awaiting confirm before utcExpiresAt |
CONFIRMED | Finalized |
EXPIRED | Hold lapsed without confirmation; inventory released |
CANCELLED | Cancelled by you (or by the operator on their side) |
Not implemented (yet)
Optional OCTO capabilities other than octo/pricing, octo/content, and octo/questions — octo/notifications, pickups, dropoffs, promotions — are not yet advertised; requesting them initializes nothing. Availability-change push notifications are on the roadmap; until then, poll the calendar endpoint within the rate limit.
OCTO Channel Distribution
Distribute your activities to OTA resellers through the OCTO open standard — live availability, instant bookings, one integration.
AI Assistant (MCP)
Connect your own AI assistant to Resytech with a read-only MCP token and ask questions about activities, availability, bookings, and business trends.
