BlueNavyCabo
ES|EN
Back to home

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 curl commands 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:

  1. The operator goes to https://bluenavycabo.com/app/config and 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).
  2. 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.
  3. 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.
  4. 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": "..." }, without ok. The endpoint reference (section 5) states exactly what each one returns.
  • Error: always { "error": "human-readable message" }. Some routes also add a code field (a stable identifier for handling the error programmatically, such as VALIDATION or NOT_FOUND); others don't include it. Don't build your integration assuming code is always present — use the HTTP status as the primary source and treat error as 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 omit assetIds, the service is left with no boat assigned. If you omit active (or send it as false), the service is deactivated.
  • PATCH /api/app/widgets/{id} — if you omit productIds, the widget is left with no services. The two exceptions are allowedDomains and displayCurrencies: 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 send primaryColor, the other theme fields (onPrimaryColor, radius, font) and customCss are 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-widget must be the widget's real id (the one returned in step 5.12), and the domain of the site where it's installed must be in allowedDomains (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-src from bluenavycabo.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-association on the operator's site. Registering the domain with Stripe does happen automatically, as a side effect of step 5.12 (as soon as allowedDomains is 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: null deletes 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: name 200 characters, description 4000 (after trimming whitespace). No control characters; description does 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:

  1. GET /api/app/servicios/{id} returns traducciones for 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.
  2. For each missing text, send a PATCH to that entity adding traducciones with 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 with GET first, add traducciones and send everything together.
    • Duration: PATCH /api/app/servicios/{id}/variantes/{variantId} — along with name and durationMin, as usual.
    • Ticket type: PATCH /api/app/servicios/{id}/tiers/{tierId} — along with name, description and maxPerBooking, as usual (and personasIncluidas if applicable, see 5.5 bis).
    • Extra: PATCH /api/app/servicios/{id}/extras/{extraId} — along with name, priceCents, stock, imageUrl.
  3. Each PATCH response already includes traducciones with the result; to confirm no language was left half-done, read the full service again with GET.
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 — the id (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, the id (UUID) of a duration of that same service. It only does something together with data-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-popup and data-thr-embed, and combines with data-thr-lang, data-thr-servicio and data-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.

BlueNavyCabo · bluenavycabo.comTermsBooking termsPrivacy