ResytechResytech Docs
Plugins

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

Endpointhttps://octo.resytech.com (no trailing slash; also returned by GET /supplier)
AuthenticationAuthorization: Bearer {token} — the operator issues you an rpat_… token from their dashboard
Content typeapplication/json
CapabilitiesOcto-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, availability
  • octo:book — the entire /bookings surface

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": "…" }
CodeHTTPWhen
UNAUTHORIZED401No bearer token sent
FORBIDDEN403Invalid/revoked token, paused reseller, plugin disabled, or missing scope
INVALID_PRODUCT_ID / INVALID_OPTION_ID / INVALID_UNIT_ID / INVALID_AVAILABILITY_ID / INVALID_BOOKING_UUID400The referenced id doesn't exist or isn't yours
BAD_REQUEST400Malformed body, bad dates, oversized range
UNPROCESSABLE_ENTITY400Valid request that can't be honored — departure sold out, party too large, hold expired
TOO_MANY_REQUESTS429Rate limit
INTERNAL_SERVER_ERROR500Our fault — safe to retry with backoff

Products

GET /supplier
GET /products
GET /products/{id}

A product is one bookable activity. Mapping notes:

  • availabilityType is always START_TIME; availabilityRequired is always true — you must obtain an availabilityId before 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 as unitId); flat-priced products expose a single unit with id guest.
  • availabilityLocalStartTimes lists 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/maxUnits on the option reflect the operator's per-booking guest minimum/limit (minUnits: 0 means no minimum).
  • requiredContactFields is firstName, lastName, emailAddress, phoneNumber — collect all four before confirming.
  • cancellationCutoff is 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}/cancel returns 400 UNPROCESSABLE_ENTITY; the booking response's cancellable flag is the per-booking signal (departed trips are never cancellable). Holds are always releasable.
  • deliveryMethods is ["VOUCHER"] with deliveryFormats: ["PDF_URL"] and redemptionMethod: "MANIFEST" — one voucher per booking, and the traveler is checked off the operator's manifest by name. There are no per-ticket barcodes and unitItems[].ticket is always null; 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 CANCELLED banner, 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:

FieldMeaning
idThe availabilityId you pass to book. Deterministic per departure, but always re-check before booking — it's revalidated server-side either way.
localDateTimeStart / localDateTimeEndDeparture window with local offset
statusAVAILABLE, LIMITED (≤ 3 spots), SOLD_OUT, or CLOSED (operator blackout)
vacanciesRemaining spots when the product tracks them; null when untracked
maxUnitsMost units a single booking can carry right now
utcCutoffAtBooking 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:

SurfaceFields
ProductdefaultCurrency, 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 checkunitPricing 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 calendarunitPricingFrom / pricingFrom — the day's lowest, same rules
Bookingpricing — 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 }
  ]
}
FieldWhat 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 / durationMinutesToShortest and longest bookable option. durationMinutesTo is absent when the product has a single fixed duration
options[].durationMinutesThat 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

TypeSource
BOOKING_TERMThe 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_TERMThe 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_INFORMATIONArrival 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_ENTITY naming 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 returns UNPROCESSABLE_ENTITY naming it — PATCH the answer in and confirm again.
  • A null or empty value clears an optional answer. A required answer can be replaced but never blanked (BAD_REQUEST).
  • An unknown or duplicated questionId is BAD_REQUEST — re-poll the catalog if your form is stale.
  • Format rules by field type: dates must be YYYY-MM-DD, times HH:MM (24-hour), emails and URLs must parse, phone numbers accept international formats (+44 20 7946 0958 is 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 questionAnswers during 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": "…" }
}
  • uuid is your idempotency key. Retrying with the same uuid returns the reservation's current state and never double-books. Always send one.
  • One unitItems entry per traveler. unitId is a unit id from the product (guest for single-unit products).
  • expirationMinutes is clamped to 5–1440; default 30. The response's utcExpiresAt is authoritative.
  • contact is optional at reserve — send it at confirm if you don't have it yet. firstName + lastName is the documented shape; a single fullName is also accepted.
  • A successful reserve blocks the operator's live inventory immediately. An unconfirmed hold expires automatically at utcExpiresAt and the inventory frees itself; the booking then reads EXPIRED.

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 item uuids preserved).
  • availabilityId — moves the booking to another departure. If unitItems isn't sent, the existing party re-books unchanged. productId/optionId, when sent, must agree with the availabilityId (same rule as reserve) — and changing the product requires an availabilityId plus unitItems using the new product's unit ids.
  • expirationMinutes — holds only; resets the TTL exactly like extend.
  • Updating a CONFIRMED booking to a higher total records the difference as collected by you (merchant of record), same as confirm. A move that no longer fits returns UNPROCESSABLE_ENTITY and leaves the booking exactly as it was.

Confirm, extend, cancel

  • Confirm finalizes the booking (CONFIRMED). Include contact — 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 returns UNPROCESSABLE_ENTITY and leaves the reservation on hold, so you can retry with fewer units. Unit item uuids of units you keep are preserved.
  • Extend ({ "expirationMinutes": 60 }) pushes the hold deadline of an ON_HOLD booking. Expired holds cannot be extended — reserve again.
  • Cancel ({ "reason": "…" }) works on holds and confirmed bookings. The response's cancellation.refund is 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

StatusMeaning
ON_HOLDReserved, inventory blocked, awaiting confirm before utcExpiresAt
CONFIRMEDFinalized
EXPIREDHold lapsed without confirmation; inventory released
CANCELLEDCancelled 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.

On this page