API for agencies — setting up a BlueNavyCabo account
This guide in Spanish: /api-operador.
This guide online: https://bluenavycabo.com/operator-api · Plain text (for your AI): https://bluenavycabo.com/api-operador/GUIDE.md · OpenAPI specification: https://bluenavycabo.com/api-operador/openapi.json
If you plan to use an AI to do the setup: a chat AI without tools cannot call this API. Use Claude Code, an agent with terminal access, or a developer with the
curlcommands in this guide. Give the AI the link to the OpenAPI specification and the key, and ask it to follow the "Full walkthrough" (section 5).
This guide is for the developer or agency that a BlueNavyCabo operator has given access to configure their own account (catalog, boats, widget) from another system, without sharing their password.
1. What it is and what it covers
BlueNavyCabo (https://bluenavycabo.com) is the operator's booking system. Each account can generate an API key from its own dashboard. That key lets you automate the account's configuration — loading a tour catalog from a spreadsheet, syncing prices with another system, creating widgets — without the operator having to do it by hand or lend out their login.
With the key you can
- Read and write the catalog: services (tours), their durations, ticket types, prices, extras, group rates, seasons and schedule exceptions.
- Set how many people a whole-boat ticket type includes (
personasIncluidas) and translate the name and description of services, durations, ticket types and extras into other languages (traducciones) — see 5.5 bis and 5.15. - Create and edit boats and other resources (name, capacity, type, calendar aliases).
- Create and edit widgets (which services they show, colors, domains where they can be embedded, custom CSS, which currencies the approximate equivalent is shown in).
- Read and edit the basic business details (name, time zone, currency). The contact email and phone can be read, not changed.
The key can never
- View or touch bookings, customers or passengers.
- View or move money: charges, commission, refunds, the operator's Stripe account, the monthly subscription.
- Manage users and team, or the calendar integrations (Google Calendar, Teamup, external calendar via ICS).
- Send emails, or touch their configuration.
- Change the business's contact email or phone: that email receives the notice of every new booking and the tourists' replies, and that phone is the one they see at checkout. Only the owner changes them, from their dashboard.
- Manage coupons, gift cards or referrals.
- Touch waivers or the acceptance of legal terms.
- Export or download account data.
- Delete an entire service, boat or widget (it can delete individual catalog pieces — a duration, a ticket type, an extra, a season, an exception or a group-rate tier — see the endpoint reference).
- Create other keys or revoke existing ones — only the account owner can do that, from their personal session.
Each key belongs to a single account. There is no way for a key to reach another operator's data.
2. How to get the key
The key is generated by the account owner (role "Dueño" (Owner) / OWNER) from their own dashboard — never by us or directly by the agency:
- The operator goes to
https://bluenavycabo.com/app/configand opens the API tab (only the account owner sees it; it does not appear for the "Gerente" (Manager), "Capitán" (Captain) or "Oficina" (Office) roles). - Under "Crear llave" (Create key), they enter a name that identifies who it is for (for example
Agencia web – septiembre) and choose the validity period: 7, 30, 90 or 365 days. There is no "never expires" option — every key expires on its own. - When the key is created, the system shows the full value only once: it starts with
bnc_op_followed by a long identifier. The operator must copy it and share it with the agency over a private channel (never by email or unencrypted WhatsApp) — it cannot be viewed again. If it is lost, a new key must be created and the old one revoked. - The key can be revoked at any time from the same screen, effective immediately: the next request using that key stops working. Revoking does not delete the record — the operator can still see when it was created and when it was last used.
Recommendation when working with an agency: ask the operator for a key named after the agency with a validity period that fits the project (for example 30 or 90 days), and have them revoke it as soon as the initial integration is done. If the integration needs to keep running afterwards (continuous sync), use a 365-day key and renew it before it expires — the key does not renew itself; you have to create a new one before the old one expires.
3. Authentication and general rules
Base URL: https://bluenavycabo.com
Header on every call:
Authorization: Bearer bnc_op_...
- All calls go over HTTPS. There is no unencrypted version.
- The key acts as the account owner who created it. If that person stops being the account owner (their role changes, their user is deactivated, or they leave the account), the key stops working on its own, even if it has not expired or been revoked.
- Rate limits: up to 120 requests per minute per key, and up to 300 requests per minute per IP address. If your integration does bulk loads (for example, a catalog of 200 services), space out your calls — no real use case needs to go faster than that.
- If you exceed the limit, the response is the same as for an invalid key (see below) — there is no separate "too many requests" code. If your integration starts receiving bursts of 401 during a bulk load, the most likely cause is the rate limit: wait a minute and retry with fewer requests per second.
Response format
- Success: almost always
{ "ok": true, ... }. Routes that create something (a service, a boat, a duration, etc.) are the exception — they respond with just{ "id": "..." }, withoutok. The endpoint reference (section 5) states exactly what each one returns. - Error: always
{ "error": "human-readable message" }. Some routes also add acodefield (a stable identifier for handling the error programmatically, such asVALIDATIONorNOT_FOUND); others don't include it. Don't build your integration assumingcodeis always present — use the HTTP status as the primary source and treaterroras the text to display or log.
Status codes
| Code | When it appears |
|---|---|
400 |
The request body is missing, is not valid JSON, or a field does not meet its rules (type, range, format). The message says which one. |
401 |
You didn't send the key, the header is not in Bearer ... format, the key doesn't exist, is revoked or expired, its owner is no longer the owner, or you called a route the key cannot reach — by design, this looks exactly like an invalid key (so that anyone trying random keys can't learn which routes exist). Exception: the boat routes (/api/app/recursos…) respond 403 with the message «No tienes permiso para gestionar recursos.» ("You don't have permission to manage resources.") in those same cases; treat it the same as a 401. |
403 |
The account is suspended and the operation you requested is new configuration (see the FAQ about suspended accounts), or the corresponding feature is turned off for that account (applies only to group rates). |
404 |
The service, boat, widget or other resource in the path doesn't exist or doesn't belong to this account. |
409 |
Conflict: a duplicate exception date, a duplicate group-rate tier, an alias that already identifies another boat, or an attempt to operate on something that already has associated bookings. |
500 |
Unexpected server error. Retrying usually resolves it; if it persists, contact the operator so they can review it with us. |
Audit log
Every change you make with the key is recorded in the account's audit log, with api:<key name> as the actor. That's why the key name should say who is using it (Integración Acme, Sincronización ERP): it's the name the operator will read when reviewing what changed and when.
Everything the key can write is recorded: creating and editing services, durations, ticket types, prices, extras, group-rate tiers, seasons, exceptions, boats (including their aliases), widgets and the business details; plus the deletions the key can perform (durations, ticket types, extras, group-rate tiers, seasons and exceptions).
The same record is written when a person makes the change from the dashboard — including the deletions only the dashboard can do (deleting a service, a boat or a widget). In other words: the operator sees a single history, and can tell at a glance what your integration did from what their team did.
What each row stores: the action, the date and time, who did it, the object that changed and a short summary of how it ended up — the name, and the new values of simple fields (for example the duration in minutes, the price in cents, whether the service ended up active, or a widget's allowed domains).
What it does NOT store: long content. For the price matrix it records how many cells were sent and how many changed, not the full table; for a season, how many time slots ended up in the grid; for a widget's custom CSS, only its length in characters. The audit log is the trail of who changed what and when, not a copy of the catalog: the current content is always read with the corresponding GET.
Reads (GET) are not logged — the audit log is for changes.
4. Before you start: rules that prevent surprises
Three routes require the complete object, not just the field you changed. If you send fewer fields than exist, the missing ones are saved as empty — previous values are not preserved:
PATCH /api/app/servicios/{id}— if you omitassetIds, the service is left with no boat assigned. If you omitactive(or send it asfalse), the service is deactivated.PATCH /api/app/widgets/{id}— if you omitproductIds, the widget is left with no services. The two exceptions areallowedDomainsanddisplayCurrencies: if you don't include them in the body, those lists are left untouched (if you do include them, they replace the whole list).PUT /api/app/widgets/{id}/theme— replaces the entire theme. If you only sendprimaryColor, the other theme fields (onPrimaryColor,radius,font) andcustomCssare cleared even if they already had a value.
The safe way to edit any of these three is: first GET to fetch the full current state, change only what you need, and send the whole object back.
Prices are always handled in cents, in the account's currency (MXN or USD, see GET /api/app/config). $850.00 is 85000.
VAT (IVA) is included in the price you load. The system never adds it on top at any step — the price you save is the final price the tourist sees (required by Mexico's Federal Consumer Protection Law, art. 7 Bis).
Stripe rejects charges below a minimum per currency: $10.00 MXN or $0.50 USD. A price of 0 is valid and means "this combination is not sold" (it isn't offered to the tourist); any price greater than 0 but below the minimum is rejected when saved, with a message stating the current minimum.
A service is created deactivated (active: false) and stays that way until you explicitly activate it with a PATCH. While it's deactivated it doesn't appear in any widget and can't be booked — you can build it at your own pace (durations, prices, schedules) and activate it only when it's ready.
5. Full walkthrough: onboarding an operator from scratch
This walkthrough builds a sellable service end to end, in the same order you would do it by hand from the dashboard. Replace $TOKEN with the real key and adjust the example values.
5.1 Business details
curl -s https://bluenavycabo.com/api/app/config \
-H "Authorization: Bearer $TOKEN"
{
"ok": true,
"config": {
"name": "Baja Therapy",
"timezone": "America/Mazatlan",
"currency": "MXN",
"contactEmail": "",
"contactPhone": "",
"termsUrl": "",
"privacyNoticeUrl": "",
"cancellationPolicy": ""
}
}
To update the name, send the complete object (name, time zone and currency included, even if they don't change):
curl -s -X PATCH https://bluenavycabo.com/api/app/config \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "Baja Therapy",
"timezone": "America/Mazatlan",
"currency": "MXN"
}'
{ "ok": true }
If the account has already had any booking, changing timezone or currency responds 409 — those two fields are frozen as soon as there is real activity (rewriting them would shift the time or currency of sales already made).
The contact email and phone can't be changed with the key: if you omit them they aren't touched, and if you send them they must be identical to the ones GET returns (otherwise 403). The owner sets or changes them from their dashboard (Configuración → Negocio).
5.1 bis Documents the customer accepts when booking
Three fields of the same endpoint store what the buyer sees on the payment screen: termsUrl (the operator's terms and conditions), privacyNoticeUrl (their privacy notice) and cancellationPolicy (text displayed in full, not as a link).
All three are optional and follow the "if you don't send it, it isn't touched" rule, unlike name, timezone and currency. A PATCH with only the five fields above leaves them exactly as they were. To clear one, send it as null or as an empty string.
curl -s -X PATCH https://bluenavycabo.com/api/app/config \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "Baja Therapy",
"timezone": "America/Mazatlan",
"currency": "MXN",
"termsUrl": "https://bajatherapycabo.com/terminos",
"privacyNoticeUrl": "https://bajatherapycabo.com/aviso-de-privacidad",
"cancellationPolicy": "Cancelaciones sin costo hasta 24 horas antes de la salida.\nDentro de las 24 horas no hay reembolso.\nSi cancelamos por mal clima, se reagenda sin costo o se devuelve el 100%."
}'
{ "ok": true }
Rules for the two URLs: they must start with https:// (http:// is rejected with 400), have a real domain with a dot (https://localhost won't work), contain no username or password, and be 500 characters or less. cancellationPolicy is free text up to 1,500 characters and keeps line breaks exactly as you send them — that's how they're shown on the payment screen.
They're worth loading: without termsUrl, the checkbox the buyer ticks still mentions the operator's terms but can't link to them, and the only thing it can open is BlueNavyCabo's Booking Terms, which cover the platform and not the service.
5.2 Boat
curl -s -X POST https://bluenavycabo.com/api/app/recursos \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "name": "Marlin III", "kind": "Barco", "capacity": 12 }'
{ "id": "aaaaaaaa-1111-4aaa-8aaa-aaaaaaaaaaaa" }
If the operator keeps their departures in a single calendar shared by the whole fleet (instead of one per boat), this boat needs aliases — the names it appears under in those events' titles:
curl -s -X POST https://bluenavycabo.com/api/app/recursos/aaaaaaaa-1111-4aaa-8aaa-aaaaaaaaaaaa/alias \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "aliases": ["Marlin", "M3", "barco 3"] }'
{ "ok": true, "aliases": ["marlin", "m3", "barco 3"] }
If the operator doesn't use a shared fleet calendar, skip this step.
5.3 Service (tour)
It's created blank — no real name yet, inactive — and completed afterwards:
curl -s -X POST https://bluenavycabo.com/api/app/servicios \
-H "Authorization: Bearer $TOKEN"
{ "id": "bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb" }
5.4 Durations (variants)
curl -s -X POST https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb/variantes \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "name": "Medio día (4 horas)", "durationMin": 240 }'
{ "id": "cccccccc-3333-4ccc-8ccc-cccccccccccc" }
5.5 Ticket types (tiers)
curl -s -X POST https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb/tiers \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "name": "Adulto", "description": "", "maxPerBooking": null }'
{ "id": "dddddddd-4444-4ddd-8ddd-dddddddddddd" }
5.5 bis People included in a ticket (whole boat)
Only has an effect on services with mode: "WHOLE_ASSET" (whole boat). In PER_SEAT, each ticket is already one person and this field does nothing. It's meant for a ticket such as "Renta del barco" (boat rental) that covers several people at once:
curl -s -X PATCH https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb/tiers/dddddddd-4444-4ddd-8ddd-dddddddddddd \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "name": "Renta del barco", "description": "Incluye capitán y chalecos.", "maxPerBooking": 1, "personasIncluidas": 12 }'
{ "ok": true }
personasIncluidas is an integer from 1 to 1000, or null (= not set, the original behavior: each ticket counts as one person). On create (POST .../tiers), absent is equivalent to null. On edit (PATCH .../tiers/{tierId}), absent means leave it untouched — so an integration that only renames a ticket doesn't accidentally erase the boat's "12"; an explicit null does clear it.
An out-of-range value (text, decimal, boolean, 0, or greater than 1000) responds 400:
{ "error": "Las personas que incluye cada boleto deben ser un número entero de 1 a 1000, o vacío (null) si cada boleto es una persona.", "code": "VALIDATION" }
(The message reads: "The number of people included in each ticket must be a whole number from 1 to 1000, or empty (null) if each ticket is one person.")
Effect on sales: the widget shows «Incluye hasta 12 personas» ("Includes up to 12 people") next to that ticket, and if the group doesn't fit within the tickets the tourist selected, it won't let them continue. The server enforces the same rule again when creating the hold — if a request with insufficient tickets still arrives, POST /api/w/{widgetId}/holds responds 400:
{ "error": "Tus boletos incluyen hasta 12 personas. Para 18 personas agrega 6 personas adicionales.", "code": "VALIDATION" }
(The message reads: "Your tickets include up to 12 people. For 18 people, add 6 additional people.")
A selected ticket without personasIncluidas counts as 1 person. If NONE of the tickets the tourist selected has the value, nothing is enforced — same as before this field existed.
5.6 Prices
The matrix crosses ticket type × duration. 85000 cents = $850.00 MXN:
curl -s -X PUT https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb/precios \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"prices": {
"dddddddd-4444-4ddd-8ddd-dddddddddddd": {
"cccccccc-3333-4ccc-8ccc-cccccccccccc": 85000
}
}
}'
{ "ok": true }
5.7 Extras
curl -s -X POST https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb/extras \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "name": "Equipo de snorkel", "priceCents": 25000, "stock": null, "imageUrl": null }'
{ "id": "eeeeeeee-5555-4eee-8eee-eeeeeeeeeeee" }
stock: null means unlimited.
5.8 Group rates
Automatic discount starting at a certain number of people. descuentoBps is in basis points (hundredths of a percentage point): 1000 = 10%, the maximum allowed is 5000 (50%):
curl -s -X POST https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb/tarifas-grupo \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "minPersonas": 6, "descuentoBps": 1000 }'
{ "id": "11112222-6666-4111-8111-111122223333" }
This step only works if the operator has the "tarifas de grupo" (group rates) feature turned on (it's on by default).
5.9 Schedules: season
Days of the week (0 = Sunday … 6 = Saturday), the business's local time and capacity per departure:
curl -s -X POST https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb/temporadas \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "Temporada alta",
"startsOn": "2026-11-01",
"endsOn": "2027-04-30",
"cells": [
{ "weekday": 1, "time": "09:00", "capacity": 12 },
{ "weekday": 3, "time": "09:00", "capacity": 12 },
{ "weekday": 5, "time": "09:00", "capacity": 12 }
]
}'
{ "id": "ffffffff-6666-4fff-8fff-ffffffffffff" }
endsOn: null means a season with no end date (it stays in effect until it's edited or deleted).
5.10 Schedules: exception
For a specific day that breaks the season's rule — for example, closed on December 25. An empty cells array means "no departures that day":
curl -s -X POST https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb/excepciones \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "date": "2026-12-25", "cells": [] }'
{ "id": "22223333-7777-4222-8222-222233334444" }
5.11 Activate the service
Here you do send the complete object — including active: true and the list of assigned boats (assetIds). cutoffMinNoBookings and cutoffMinWithBookings already have their default values (60 and 30 minutes) since the service was created in step 5.3; they're repeated because this PATCH doesn't accept a partial body:
curl -s -X PATCH https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "Snorkel en catamarán",
"description": "Medio día navegando y haciendo snorkel frente a Cabo San Lucas.",
"coverImageUrl": null,
"mode": "WHOLE_ASSET",
"cutoffMinNoBookings": 60,
"cutoffMinWithBookings": 30,
"active": true,
"assetIds": ["aaaaaaaa-1111-4aaa-8aaa-aaaaaaaaaaaa"]
}'
{ "ok": true }
mode: "WHOLE_ASSET" sells the whole boat per departure (up to its capacity); the alternative is "PER_SEAT", which sells seat by seat.
5.12 Widget
It's created blank and completed in two calls — general details and appearance:
curl -s -X POST https://bluenavycabo.com/api/app/widgets \
-H "Authorization: Bearer $TOKEN"
{ "id": "99999999-7777-4999-8999-999999999999" }
curl -s -X PATCH https://bluenavycabo.com/api/app/widgets/99999999-7777-4999-8999-999999999999 \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "Widget principal",
"productIds": ["bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb"],
"allowedDomains": ["bajatherapycabo.com", "www.bajatherapycabo.com"],
"displayCurrencies": ["USD", "CAD"]
}'
{ "ok": true, "allowedDomains": ["bajatherapycabo.com", "www.bajatherapycabo.com"], "displayCurrencies": ["USD", "CAD"] }
allowedDomains is the list of sites where this widget may be embedded — without it (or if empty), the widget is not restricted by origin. Write the bare domain, without https:// or paths; *.midominio.mx accepts any subdomain.
displayCurrencies are the currencies in which the widget shows an approximate equivalent next to the price: USD, EUR, CAD or GBP (at most all four, no duplicates). The charge is ALWAYS in Mexican pesos — this is display only; it doesn't change the currency of the charge, the commission, or the receipt. The exchange rate is set by the platform using the European Central Bank's daily reference rate; it can't be configured via the API or from the dashboard. An empty list [] leaves the widget showing pesos only. If you omit the field, the saved list is left untouched (same as allowedDomains). A new widget starts with ["USD"].
curl -s -X PUT https://bluenavycabo.com/api/app/widgets/99999999-7777-4999-8999-999999999999/theme \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"theme": { "primaryColor": "#146E82", "onPrimaryColor": "#FFFFFF", "radius": 12, "font": "system" },
"customCss": ""
}'
{ "ok": true }
5.13 Installing the widget on the operator's site
With the widgetId from step 5.12, the snippet to paste into the operator's site (WordPress, Wix, hand-written HTML — any of them) is these blocks, exactly as they are:
<!-- BlueNavyCabo booking widget -->
<script src="https://bluenavycabo.com/embed/loader.js" defer></script>
And, at the place on the page where the widget should appear, one of these two (not both):
<!-- Embedded directly in the page -->
<div data-thr-embed data-thr-widget="99999999-7777-4999-8999-999999999999"></div>
<!-- As a button that opens a pop-up window -->
<a href="#" data-thr-popup data-thr-widget="99999999-7777-4999-8999-999999999999">Reservar</a>
Notes for whoever installs the snippet:
- The loader (
loader.js, ~2 KB, no dependencies) is the same for every operator — it doesn't change with the widget. data-thr-widgetmust be the widget's realid(the one returned in step 5.12), and the domain of the site where it's installed must be inallowedDomains(step 5.12) — if it doesn't match, the widget won't load.- The iframe built by the loader already includes
allow="payment *", which Apple Pay and Google Pay need to work inside the widget. You don't need to add it by hand. - If the operator's site has its own Content Security Policy (CSP), it must allow loading scripts and
frame-src/connect-srcfrombluenavycabo.com— otherwise the browser silently blocks the widget (no error visible to the tourist, only in the browser console). - Apple Pay inside the widget requires a separate step that the key cannot do via the API — the operator or Santiago does it from the dashboard ("Widgets → Instalación → Apple Pay en tu sitio" (Widgets → Installation → Apple Pay on your site)): uploading a verification file to the exact path
/.well-known/apple-developer-merchantid-domain-associationon the operator's site. Registering the domain with Stripe does happen automatically, as a side effect of step 5.12 (as soon asallowedDomainsis saved), but the file has to be uploaded by hand once.
5.14 Verify that it's complete
curl -s https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb \
-H "Authorization: Bearer $TOKEN"
Returns the service's full bundle — general details, assigned boats, durations, ticket types, price matrix, extras, group rates, seasons and exceptions — so you can confirm everything ended up as expected before signing off on it. Likewise with:
curl -s https://bluenavycabo.com/api/app/widgets/99999999-7777-4999-8999-999999999999 \
-H "Authorization: Bearer $TOKEN"
curl -s https://bluenavycabo.com/api/app/recursos \
-H "Authorization: Bearer $TOKEN"
5.15 Translating your catalog into other languages
The widget speaks six languages (es, en, fr, de, pt, it), but the name and description you entered in the steps above are the primary text — a single language. traducciones stores, separately, whatever has been translated; anything missing is shown in the primary text, never blank.
What each entity supports: service (name, description), duration (name), ticket type (name, description), extra (name). All four entities accept traducciones both on create and on edit, with the same patch semantics in all four:
{ "en": { "name": "Sunset cruise", "description": "Three hours of sailing" }, "fr": null }
- A language you don't mention is left untouched.
language: nulldeletes that entire language.- Within a language, every field you send is overwritten;
""clears it. A field you don't send is left untouched. - A language left with no fields disappears on its own.
- Limits:
name200 characters,description4000 (after trimming whitespace). No control characters;descriptiondoes accept line breaks. - On create (
POST), the patch is applied on top of{}: absent ={}. On edit (PATCH), absent = left untouched — so an integration that only updates the price or the primary name doesn't accidentally erase existing translations.
Translating on create (all four entities accept traducciones in the POST):
curl -s -X POST https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb/extras \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "Equipo de snorkel",
"priceCents": 25000,
"stock": null,
"imageUrl": null,
"traducciones": { "en": { "name": "Snorkel gear" } }
}'
{ "id": "eeeeeeee-5555-4eee-8eee-eeeeeeeeeeee", "traducciones": { "en": { "name": "Snorkel gear" } } }
Translating the whole catalog of an account that's already loaded — the recipe:
GET /api/app/servicios/{id}returnstraduccionesfor the service and for each duration, ticket type and extra (servicio.traducciones,servicio.variants[].traducciones,servicio.tiers[].traducciones,servicio.extras[].traducciones) — so you know what's missing without guessing.- For each missing text, send a
PATCHto that entity addingtraduccioneswith ONLY the language you're loading (no need to repeat the other languages or touch the primary text):- Service:
PATCH /api/app/servicios/{id}— careful, this route requires the complete object (section 4, and the example below): fetch the state withGETfirst, addtraduccionesand send everything together. - Duration:
PATCH /api/app/servicios/{id}/variantes/{variantId}— along withnameanddurationMin, as usual. - Ticket type:
PATCH /api/app/servicios/{id}/tiers/{tierId}— along withname,descriptionandmaxPerBooking, as usual (andpersonasIncluidasif applicable, see 5.5 bis). - Extra:
PATCH /api/app/servicios/{id}/extras/{extraId}— along withname,priceCents,stock,imageUrl.
- Service:
- Each
PATCHresponse already includestraduccioneswith the result; to confirm no language was left half-done, read the full service again withGET.
curl -s -X PATCH https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb/variantes/cccccccc-3333-4ccc-8ccc-cccccccccccc \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "Medio día (4 horas)",
"durationMin": 240,
"traducciones": { "en": { "name": "Half day (4 hours)" }, "fr": { "name": "Demi-journée (4 heures)" } }
}'
{ "ok": true, "traducciones": { "en": { "name": "Half day (4 hours)" }, "fr": { "name": "Demi-journée (4 heures)" } } }
For the service itself (which requires the complete object), the pattern is always GET → add traducciones → send everything back:
curl -s -X PATCH https://bluenavycabo.com/api/app/servicios/bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "Snorkel en catamarán",
"description": "Medio día navegando frente a Cabo San Lucas.",
"coverImageUrl": null,
"mode": "WHOLE_ASSET",
"cutoffMinNoBookings": 60,
"cutoffMinWithBookings": 30,
"active": true,
"assetIds": ["aaaaaaaa-1111-4aaa-8aaa-aaaaaaaaaaaa"],
"traducciones": { "en": { "name": "Snorkeling catamaran tour", "description": "Half a day sailing off Cabo San Lucas." } }
}'
{ "ok": true, "traducciones": { "en": { "name": "Snorkeling catamaran tour", "description": "Half a day sailing off Cabo San Lucas." } } }
PATCH /api/app/servicios/{id} still requires the complete object for the same reasons as always (section 4): if you omit name it responds 400 with «El nombre es obligatorio.» ("The name is required."); if you omit assetIds the service is left with no boat assigned; if you omit active (or send it as false) the service is deactivated. traducciones is the exception within that body — it's the only field that follows the "if you don't send it, it isn't touched" rule instead of being saved as empty.
What the tourist sees, and what does NOT change: the widget shows the translation for the language it detects (or the one set by data-thr-lang / ?lang=, see section 5.16) and, if it's missing, the primary text. The stored order, the confirmation emails and the ticket PDF always stay in the primary text — never in the translation, regardless of the language the tourist booked in.
5.16 Direct button to a service, on the operator's site
This is not an API endpoint — it's one more attribute of the same loader.js from step 5.13, for a «Reservar {nombre del tour}» ("Book {tour name}") button that opens the widget with that service already chosen, skipping «Elige tu tour» ("Choose your tour"). The visitor chose the service by clicking that specific button, so it doesn't break the rule that the widget never auto-selects anything on its own.
It's added to an element that already has data-thr-widget (it only works with data-thr-popup; the fixed embed data-thr-embed has no "button" to click):
<a href="#" data-thr-popup data-thr-widget="99999999-7777-4999-8999-999999999999"
data-thr-servicio="bbbbbbbb-2222-4bbb-8bbb-bbbbbbbbbbbb">Reservar Snorkel en catamarán</a>
data-thr-servicio— theid(UUID) of the service within this same widget. Anything that isn't a UUID, or a service that isn't in the widget, is silently ignored and the button opens normally («Elige tu tour»).data-thr-duracion— optional, theid(UUID) of a duration of that same service. It only does something together withdata-thr-servicio; if the duration doesn't belong to that service (or isn't a UUID), it's ignored and the tourist chooses the duration themselves («Elige la duración», "Choose the duration") — the widget never preselects a duration by elimination, not even if the service has only one.
Where the tourist lands, depending on what the button carries:
| Carries | Where they land |
|---|---|
No data-thr-servicio, or an id that isn't from this widget |
«Elige tu tour» (same as always) |
Valid data-thr-servicio, without data-thr-duracion (or with one that isn't from that service) |
«Elige la duración» for that already-chosen service |
Valid data-thr-servicio and data-thr-duracion, both from that same service |
The calendar, ready to pick a date and time |
The step where the tourist lands remains editable: preselecting the service or duration doesn't take away the "change" button — it only saves them the first click.
Both attributes are passed to the widget as ?servicio= and ?duracion= in the iframe URL, alongside the existing ?lang=. You can build the <a> by hand with these two attributes, but the ready-to-copy code (with the correct name and id for each service) is in the dashboard: "Widgets → editar el widget → pestaña Instalación → Botón directo a un servicio" (Widgets → edit the widget → Installation tab → Direct button to a service) — there you'll find one block per service included in that widget, and if it has more than one duration, a collapsible list with each one's id.
5.17 Selling through an outside seller (referral)
This isn't an endpoint either — it's another attribute of the same loader.js from step 5.13. Use it when someone outside the business (a concierge, a hotel, an OPC) puts your booking popup on their page: every sale that comes in through that button is credited to that referral, so the operator can keep track and pay their commission outside the platform.
<a href="#" data-thr-popup data-thr-widget="99999999-7777-4999-8999-999999999999"
data-thr-ref="ali">Reservar</a>
data-thr-ref— the referral's code exactly as it was created in the panel (Referidos): lowercase letters, digits and hyphens, 3 to 40 characters, no hyphen at the start or end. The loader lowercases and trims it; anything else is silently ignored and the popup opens normally, without crediting the sale.- It works the same on
data-thr-popupanddata-thr-embed, and combines withdata-thr-lang,data-thr-servicioanddata-thr-duracion. - It reaches the widget as
?ref=— the same as the direct link/w/{widgetId}?ref={code}. A code that doesn't exist in the account, or a deactivated referral, doesn't break anything: the sale is simply left without a referral. - If the widget has
allowedDomains(step 5.12), the seller's page domain must be on that list or the popup won't load there.
The ready-to-copy code (with the right widget and code) and the sale-by-sale detail — with a CSV that carries no personal data of the tourist, to send to the referral — are in the panel: Referidos → the referral.
6. Endpoint reference
Paths relative to https://bluenavycabo.com. All of them require the Authorization: Bearer bnc_op_... header.
6.1 Business
| Method | Path | What it does |
|---|---|---|
GET |
/api/app/config |
Business details |
PATCH |
/api/app/config |
Updates the business details |
GET — 200 response: { ok: true, config: { name, timezone, currency, contactEmail, contactPhone, termsUrl, privacyNoticeUrl, cancellationPolicy } }. Fields not filled in come back as an empty string, never as null.
PATCH — body with the first three fields required:
| Field | Type | Constraints |
|---|---|---|
name |
string | Required, non-empty |
timezone |
string | Required, a valid IANA time zone (e.g. America/Mazatlan) |
currency |
string | Required, "MXN" or "USD" |
contactEmail |
string | Optional. Can't be changed with the key: if omitted it isn't touched, and if sent it must be identical to the one GET returns (otherwise 403) |
contactPhone |
string | Optional. Same rule as contactEmail |
termsUrl |
string | null | Optional. If you don't send it, it isn't touched. https:// required, real domain with a dot, no user:password, max. 500 characters. null or "" clears it |
privacyNoticeUrl |
string | null | Optional. Same rules as termsUrl |
cancellationPolicy |
string | null | Optional, same "if you don't send it, it isn't touched". Free text, max. 1,500 characters; keeps line breaks. null or "" clears it |
With the key, omitting a contact or document field preserves it as it is. This is intentional — an integration written before these fields existed can't accidentally erase what the buyer accepts at checkout, and the business contact (where booking notices and tourists' replies arrive) is changed only by the owner from their dashboard, where it must also be a single valid email address.
200 response: { ok: true }. Errors: 400 (missing name, invalid timezone, currency is not MXN/USD, a URL without https:// or malformed, or the policy exceeds 1,500 characters — the message says which), 403 (you sent a contactEmail or contactPhone different from the saved one), 409 (timezone or currency changed and the account already has financial activity — these two fields freeze automatically).
6.2 Boats (resources)
| Method | Path | What it does |
|---|---|---|
GET |
/api/app/recursos |
Lists the account's boats/resources |
POST |
/api/app/recursos |
Creates a resource |
GET |
/api/app/recursos/{id} |
Resource detail, with its schedule blocks |
POST |
/api/app/recursos/{id} |
Edits a resource (note: it's POST, not PATCH) |
POST |
/api/app/recursos/{id}/alias |
Saves the resource's aliases for the shared fleet calendar |
Closed to the key: DELETE /api/app/recursos/{id} (deleting a resource) and everything related to schedule blocks (/api/app/recursos/{id}/bloqueos, /api/app/recursos/bloqueos/{id}/eliminar).
GET (list) — 200: { ok: true, recursos: [{ id, name, kind, capacity, serviciosCount }] }.
POST (create) / POST /{id} (edit) — same body for both:
| Field | Type | Constraints |
|---|---|---|
name |
string | Required |
kind |
string | Free text; if empty, saved as "Barco". Values suggested in the dashboard: Barco, Jetski, Velero, Catamarán, Yate, Tapete flotante — not a closed list |
capacity |
integer | Required, integer greater than 0 |
Create — 200: { id }. Edit — 200: { ok: true }. Errors: 400 (missing name or invalid capacity), 404 (editing an id that doesn't exist).
GET /{id} — 200: { ok: true, recurso: { id, name, kind, capacity, bloqueos: [{ id, startsAtMs, endsAtMs, reason }] } }. 404 if it doesn't exist.
POST /{id}/alias — body: { "aliases": ["text", "text", ...] }. Each alias is normalized (lowercase, accents removed, whitespace collapsed), max. 40 characters each and 12 aliases per boat. 200: { ok: true, aliases: [...] } (the already-normalized list). Errors: 400 (invalid format or length, or more than 12), 404, 409 (the alias already identifies another boat in the same account, by name or by alias — the message says which one it collides with).
6.3 Services — general details
| Method | Path | What it does |
|---|---|---|
GET |
/api/app/servicios |
Lists the account's services |
POST |
/api/app/servicios |
Creates a blank service |
GET |
/api/app/servicios/{id} |
Full service bundle (all its tabs) |
PATCH |
/api/app/servicios/{id} |
Edits the service's general details |
Closed to the key: DELETE /api/app/servicios/{id} (deleting the whole service — to take it off sale, deactivate it with active: false).
GET (list) — 200: { ok: true, servicios: [{ id, name, mode, active, variantCount }] }.
POST (create) — body optional. Without a body, creation works as always: it starts with name: "Nuevo servicio", mode: "WHOLE_ASSET", active: false and no durations, prices or schedules. If you send a body, the only field it currently accepts is traducciones (see 5.15) — it's validated BEFORE anything is created, so a 400 doesn't leave a half-built orphan service. 201: { id, traducciones } (traducciones comes back as {} if you didn't send the field).
GET /{id} — 200: { ok: true, servicio: {...} } with this shape:
{
id, tieneReservas, name, description, traducciones, coverImageUrl, mode, active,
cutoffMinNoBookings, cutoffMinWithBookings, currency,
assignedAssetIds: [id, ...],
allAssets: [{ id, name, kind, capacity }, ...], // ALL of the account's boats, to choose from
variants: [{ id, name, durationMin, traducciones }, ...],
tiers: [{ id, name, description, maxPerBooking, personasIncluidas, traducciones }, ...],
tierPrices: { [tierId]: { [variantId]: centavos } },
extras: [{ id, name, priceCents, stock, imageUrl, traducciones }, ...],
groupRates: [{ id, minPersonas, descuentoBps }, ...],
seasons: [{ id, name, startsOn, endsOn, cells }, ...],
exceptions: [{ id, date, cells }, ...]
}
traducciones (on the service, each duration, each tier and each extra) is an object keyed by language — see 5.15 for its exact shape; it comes back as {} if nothing has been translated. personasIncluidas is an integer from 1 to 1000 or null — see 5.5 bis.
404 if the id doesn't exist or doesn't belong to this account.
PATCH /{id} — complete body (see section 4). Without name it responds 400 with «El nombre es obligatorio.» ("The name is required."); omitting assetIds leaves the service with no boats; omitting active (or sending it as false) deactivates it:
| Field | Type | Constraints |
|---|---|---|
name |
string | Required. Empty or absent → 400 «El nombre es obligatorio.» |
description |
string | Optional (saved as "" if missing) |
coverImageUrl |
string | null | Optional |
mode |
string | "WHOLE_ASSET" or "PER_SEAT"; any other value is saved as "WHOLE_ASSET" (not rejected) |
cutoffMinNoBookings |
integer | Required, integer ≥ 0. Minutes before departure when sales close if there are no bookings yet |
cutoffMinWithBookings |
integer | Required, integer ≥ 0. Minutes before departure when sales close if there are already bookings |
active |
boolean | If omitted, saved as false |
assetIds |
uuid[] | Assigned boats; any that aren't a valid uuid or don't belong to this account are silently discarded; if omitted, becomes [] (no boats) |
traducciones |
object | Optional — it's the exception to the complete body: unlike the other fields, absent = left untouched (not cleared). Only name/description per language (see 5.15); { "language": null } deletes that language |
200: { ok: true, traducciones }. Errors: 400 (missing name, a cutoff that isn't an integer ≥ 0, or traducciones contains an unsupported language), 404.
6.4 Durations (variants)
| Method | Path | What it does |
|---|---|---|
POST |
/api/app/servicios/{id}/variantes |
Creates a duration |
PATCH |
/api/app/servicios/{id}/variantes/{variantId} |
Edits a duration |
DELETE |
/api/app/servicios/{id}/variantes/{variantId} |
Deletes a duration |
Body (create and edit, the same):
| Field | Type | Constraints |
|---|---|---|
name |
string | Required |
durationMin |
integer | Required, integer greater than 0 |
traducciones |
object | Optional. Only the name field per language (see 5.15). On create, absent = {}. On edit, absent = left untouched; { "language": null } deletes that language |
Create — 200: { id, traducciones }. Edit — 200: { ok: true, traducciones }. Delete — 200: { ok: true }, or 409 if it already has recorded bookings (the message suggests editing it instead of deleting it). 404 if the service or the duration doesn't exist. Also 400 if traducciones contains an unsupported language or a field other than name.
6.5 Ticket types (tiers)
| Method | Path | What it does |
|---|---|---|
POST |
/api/app/servicios/{id}/tiers |
Creates a ticket type |
PATCH |
/api/app/servicios/{id}/tiers/{tierId} |
Edits a ticket type |
DELETE |
/api/app/servicios/{id}/tiers/{tierId} |
Deletes a ticket type |
Body (create and edit, the same):
| Field | Type | Constraints |
|---|---|---|
name |
string | Required |
description |
string | Optional (saved as "" if missing) |
maxPerBooking |
integer | null | null = no limit; if a number, an integer greater than 0 |
personasIncluidas |
integer | null | People covered by ONE ticket of this type — only matters for WHOLE_ASSET services (see 5.5 bis). Integer from 1 to 1000, or null (each ticket = 1 person). On create, absent = null. On edit, absent = left untouched; an explicit null clears it |
traducciones |
object | Optional. name and description per language (see 5.15). On create, absent = {}. On edit, absent = left untouched; { "language": null } deletes that language |
Create — 200: { id, traducciones }. Edit — 200: { ok: true, traducciones }. Delete — 200: { ok: true }, or 409 if tickets of this type have already been sold. 404 if the service or the tier doesn't exist. 400 if personasIncluidas is neither an integer from 1 to 1000 nor null — the message: «Las personas que incluye cada boleto deben ser un número entero de 1 a 1000, o vacío (null) si cada boleto es una persona.» ("The number of people included in each ticket must be a whole number from 1 to 1000, or empty (null) if each ticket is one person.") — or if traducciones contains an unsupported language or a field other than name/description.
6.6 Prices
| Method | Path | What it does |
|---|---|---|
PUT |
/api/app/servicios/{id}/precios |
Merges the price matrix (ticket type × duration): only touches the cells you send |
Body: { "prices": { "<tierId>": { "<variantId>": cents_or_null } } }. You only need to include the cells you want to change — the ones you don't mention are left untouched. null in a cell deletes that price (that combination is no longer sold). Each tierId/variantId must belong to the service; each price, if not null, is an integer number of cents ≥ 0, and if it's greater than 0 it must meet the payment gateway's minimum ($10.00 MXN or $0.50 USD, depending on the account's currency).
200: { ok: true }. Errors: 400 (tier or variant doesn't belong to the service, invalid format, or a price below the minimum — the message says what the minimum is), 404 (nonexistent service).
6.7 Extras
| Method | Path | What it does |
|---|---|---|
POST |
/api/app/servicios/{id}/extras |
Creates an extra |
PATCH |
/api/app/servicios/{id}/extras/{extraId} |
Edits an extra |
DELETE |
/api/app/servicios/{id}/extras/{extraId} |
Deletes an extra |
Body (create and edit, the same):
| Field | Type | Constraints |
|---|---|---|
name |
string | Required |
priceCents |
integer | Required, integer ≥ 0 (the Stripe minimum doesn't apply: an extra can cost less than $10) |
stock |
integer | null | null = no limit; if a number, an integer ≥ 0 |
imageUrl |
string | null | Optional |
traducciones |
object | Optional. Only the name field per language (see 5.15). On create, absent = {}. On edit, absent = left untouched; { "language": null } deletes that language |
Create — 200: { id, traducciones }. Edit — 200: { ok: true, traducciones }. Delete — 200: { ok: true }, or 409 if it has already been sold in a booking. 404 if the service or the extra doesn't exist. Also 400 if traducciones contains an unsupported language or a field other than name.
6.8 Group rates
| Method | Path | What it does |
|---|---|---|
POST |
/api/app/servicios/{id}/tarifas-grupo |
Creates a group discount tier |
DELETE |
/api/app/servicios/{id}/tarifas-grupo/{groupRateId} |
Deletes a tier |
Body (create):
| Field | Type | Constraints |
|---|---|---|
minPersonas |
integer | Required, integer ≥ 2 |
descuentoBps |
integer | Required, integer from 1 to 5000 (basis points: 100 = 1%, 5000 = 50%) |
Create — 200: { id }, or 409 if a tier with the same minPersonas already exists for the service. Delete — 200: { ok: true } (never returns 409 because of usage: an applied group rate is frozen in the order, so deleting the tier doesn't affect past sales). 404 if the service or the tier doesn't exist. Both routes respond 403 if the account has the "tarifas de grupo" (group rates) feature turned off (it's on by default). This is the only pair of catalog endpoints that is recorded in the account's audit log.
6.9 Schedules — seasons
| Method | Path | What it does |
|---|---|---|
POST |
/api/app/servicios/{id}/temporadas |
Creates a season |
PATCH |
/api/app/servicios/{id}/temporadas/{seasonId} |
Edits a season |
DELETE |
/api/app/servicios/{id}/temporadas/{seasonId} |
Deletes a season |
Body (create and edit, the same):
| Field | Type | Constraints |
|---|---|---|
name |
string | Required |
startsOn |
string | Required, date YYYY-MM-DD |
endsOn |
string | null | null = no end date; if present, YYYY-MM-DD |
cells |
array | Schedule grid (see format below). Can be empty [] |
cells format: an array of { weekday, time, capacity }, where weekday is an integer from 0 (Sunday) to 6 (Saturday), time is "HH:MM" in the business's local time, and capacity is an integer ≥ 0 or null (no limit of its own — limited by the assigned boat's capacity).
Create — 200: { id }. Edit and delete — 200: { ok: true }. Errors: 400 (empty name, invalid date, or a cell in cells doesn't match the format), 404.
6.10 Schedules — exceptions
| Method | Path | What it does |
|---|---|---|
POST |
/api/app/servicios/{id}/excepciones |
Creates an exception for a specific date |
PATCH |
/api/app/servicios/{id}/excepciones/{exceptionId} |
Edits an exception |
DELETE |
/api/app/servicios/{id}/excepciones/{exceptionId} |
Deletes an exception |
Body (create and edit, the same):
| Field | Type | Constraints |
|---|---|---|
date |
string | Required, date YYYY-MM-DD |
cells |
array | Same format as in seasons. [] = no departures that day (closed day) |
Create — 200: { id }, or 409 if an exception already exists for that date in this service. Edit and delete — 200: { ok: true }. 400 if the date or a cell is invalid. 404 if the service or the exception doesn't exist.
6.11 Widgets
| Method | Path | What it does |
|---|---|---|
GET |
/api/app/widgets |
Lists the account's widgets |
POST |
/api/app/widgets |
Creates a blank widget |
GET |
/api/app/widgets/{id} |
Widget detail |
PATCH |
/api/app/widgets/{id} |
Edits name, included services, allowed domains and reference currencies |
PUT |
/api/app/widgets/{id}/theme |
Edits the visual theme and custom CSS |
Closed to the key: DELETE /api/app/widgets/{id} — deleting it would shut down the checkout already embedded on the operator's site.
GET (list) — 200: { ok: true, widgets: [{ id, name, productCount }] }.
POST (create) — no body. 201: { id }. Starts with no services and a blank theme.
GET /{id} — 200: { ok: true, widget: { id, name, productIds, theme, customCss, allowedDomains, displayCurrencies, allProducts: [...] } }, where allProducts includes ALL of the account's services (to choose which ones to include), each with { id, name, active, description, coverImageUrl, mode, cutoffMinNoBookings, cutoffMinWithBookings }. 404 if it doesn't exist.
PATCH /{id} — body (see the note in section 4 about allowedDomains):
| Field | Type | Constraints |
|---|---|---|
name |
string | Required |
productIds |
uuid[] | Services the widget shows; any that aren't a valid uuid or don't belong to this account are silently discarded; if omitted, becomes [] |
allowedDomains |
string[] | Optional — if not included, the saved list is left untouched. If included: max. 20 domains, each up to 253 characters, format midominio.mx or *.midominio.mx (no https:// or paths); an empty list [] is saved as "no restriction" |
displayCurrencies |
string[] | Optional — if not included, it's left untouched. Currencies in which the approximate equivalent is shown: only USD, EUR, CAD, GBP (accepted in lowercase and saved in uppercase), no duplicates, max. 4. [] = the widget shows pesos only. It doesn't change the charge currency: the charge is always in MXN |
200: { ok: true }, plus allowedDomains: [...] (the normalized list) and/or displayCurrencies: [...] if you sent those fields. Saving new domains triggers, in the background, Apple Pay registration for those domains on the operator's Stripe account (if connected) — it doesn't slow down the response and doesn't appear in it. 400 (empty name, a domain doesn't match the format or there are more than 20, a currency outside the catalog or more than 4), 404.
PUT /{id}/theme — complete body (see section 4):
| Field | Type | Constraints |
|---|---|---|
theme.primaryColor |
string | Optional, 3- or 6-digit hex (#146E82 or #0E8) |
theme.onPrimaryColor |
string | Optional, same format — text/icon color on top of primaryColor |
theme.radius |
number | Optional, ≥ 0 (rounded to an integer) — corner radius in px |
theme.font |
string | Optional, one of "default", "system", "serif", "mono" |
customCss |
string | Optional (saved as "" if missing), up to 20,000 characters. Cannot contain < (it's used inside a <style>) nor anything that loads external files or addresses: url(), image-set(), src(), image(), cross-fade(), element(), expression(), -moz-binding, @import, @font-face, @namespace, or strings holding an address ("https://…"). Only CSS functions and @ rules that load nothing are accepted (colors, calc(), var(), gradients, transforms, filters, @media, @supports, @keyframes, @container, @layer…); anything outside that list is rejected and the message says which one |
200: { ok: true }. 400 (a color isn't valid hex, negative radius, unrecognized font, or the CSS exceeds the length or uses something prohibited — the message says which), 404.
7. Frequently asked questions
What happens if the key expires mid-setup?
Calls start responding 401 as if the key didn't exist. Nothing already saved is lost — just ask the operator for a new key and pick up where you left off.
Can I delete a service, a boat or a widget?
Not with the key. To take a service off sale, deactivate it (PATCH with active: false) instead of deleting it — that preserves the history if it has already had bookings. If something really needs to be deleted, the operator does it from the dashboard with their personal session.
How do I test the integration without making real sales?
A service is created with active: false and stays that way until you activate it — you can build durations, prices, extras and schedules at your own pace. While it's inactive it doesn't appear in any widget and can't be booked. To test the full widget before activating the service, give access to the widget's id in your own test environment: the checkout still exists, it just doesn't show inactive services.
Why did my PATCH "erase" fields I didn't touch?
It's almost certainly one of the three endpoints in section 4 (servicios/{id}, widgets/{id}, widgets/{id}/theme): they require the complete object, not a partial update. Fetch the current state with GET before every PATCH/PUT.
My account was suspended — what happens to the key?
A suspended account keeps read access: every GET in this guide keeps working the same. What's frozen is creating or changing configuration — the same rule as if the owner tried to do it from the dashboard.
Can I have more than one active key at a time? Yes, with no limit. Each one is managed and revoked separately — it's the recommended way to give each agency or integration its own key, so you can shut one off without affecting the others.
Can the key be used to read or export bookings, reports or money? No, never — see section 1. For that, the operator has to use the dashboard with their own session.
Can I translate a service that already exists, or a ticket type (tier)?
Yes. All four entities (service, duration, ticket type, extra) accept traducciones both on create and on edit, with the same patch semantics in all four. For the service, remember that its PATCH requires the complete object (section 4): fetch the state with GET, add traducciones and send everything back. See 5.15.
Does personasIncluidas work for PER_SEAT services (sold per seat)?
No. In PER_SEAT each ticket is already one person; the field is saved if you send it, but it has no effect on sales — the widget and the hold only use it in WHOLE_ASSET. See 5.5 bis.