Para operadores turísticos y agencias de viajes

API para partners de Via Turquia

Si opera tours en Turquía, no tiene que volver a escribirlos en nuestro panel ni copiar reservas desde correos. La API para partners permite que su propio sistema cree y actualice sus tours en cuatro idiomas, mantenga los precios por temporada, categoría de hotel y tipo de habitación, lea las reservas que llegan, las acepte o rechace y reciba una notificación firmada en cuanto algo ocurre.

Está disponible para partners aprobados. Envíe su solicitud, revisamos su empresa y, cuando se activa el acceso a la API en su cuenta, usted crea sus propias claves en el panel de partners. Esta página es pública, así que puede leerlo todo antes de decidir: el archivo OpenAPI se carga directamente en Postman o Insomnia.

La referencia técnica de abajo (nombres de campos, códigos y ejemplos) está en inglés, igual que la propia API.

Solicitar ser partner Descargar openapi.json Acceso de partners

1. Qué es y para quién

The Via Turquia partner API is a JSON REST API over HTTPS for the companies that supply tours to Via Turquia: tour operators, travel agencies and licensed guides. What you do day to day in the partner panel — your tours and their prices, your photos, your bookings and your webhook — can be done by your own software instead, and the panel and the API write through the same code, so they cannot disagree.

What you can do

  • Create, change and delete your tours, in English, Spanish, Portuguese and Arabic — one request per language version, tied together as one tour.
  • Keep prices current: a single price for a day tour, or the full season × hotel class × room type matrix for a tour with hotel nights.
  • Upload photos, or give us an address to fetch them from.
  • Read the bookings customers make on your tours, and accept or decline them.
  • Receive a signed webhook the moment a booking is created, confirmed, cancelled, paid or changed, or when a tour of yours is published.
  • Load the whole API into Postman or Insomnia from openapi.json — no account needed.

What you cannot do

  • Create bookings. Customers book on the Via Turquia site; you answer the booking.
  • Publish your own tour. Every new tour is reviewed by Via Turquia first (see the FAQ).
  • Do the account housekeeping: create or revoke API keys, change your password, or upload and delete your documents. Those stay in the partner panel.
  • Change your commission, or see anything that belongs to another partner. Another partner's tour or booking answers 404, exactly like one that does not exist.

How to become a partner

  1. Apply as a partner. We review your company, normally within two working days.
  2. Once you are approved, open API access in your partner panel and send the short request form there: tell us what you will connect. API access is off by default, per partner; we answer by email.
  3. Sign in to the partner panel, open API access and create a key with only the permissions it needs.

Base URL

https://www.viaturquia.com/api/partner/v1

Everything below is relative to it. The current version is v1; see versioning.

A first call

export VT_API_KEY=vtp_live_xxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# 1. Is the service up, and does my header arrive?  (no key needed)
curl https://www.viaturquia.com/api/partner/v1/ping

# 2. Who am I?
curl -H "Authorization: Bearer $VT_API_KEY" https://www.viaturquia.com/api/partner/v1/me

# 3. What are my tours?
curl -H "Authorization: Bearer $VT_API_KEY" https://www.viaturquia.com/api/partner/v1/tours

2. Autenticación y claves

Bearer keys

Every request except /ping and /openapi.json carries a key in the Authorization header:

Authorization: Bearer vtp_live_<key_id>_<secret>

key_id is 16 hexadecimal characters and identifies the key; the secret is a random 43-character string. Over https:// only: plain HTTP is refused with 403 https_required.

  • A key goes in the header, never in the URL. On every endpoint that takes a key (all but /ping and /openapi.json), a request with a key in the query string (?api_key=, ?access_token=, or any value shaped like a key) is refused with 401 api_key_in_query, even if a correct header came with it — the URL ends up in server logs, referrers and browser history, so treat that key as leaked and replace it.
  • Shown once. We store only a hash of the secret. A lost key cannot be recovered; revoke it and create another.
  • Several keys are allowed (up to 25 live keys per partner), so you can rotate without downtime: create the new key, deploy it, revoke the old one. A key made in the panel has no expiry date, so it works until you revoke it (a key that we issued with an expiry answers 401 key_expired after it); revoking it in the panel takes effect on the very next request.
  • Your account status counts on every request. If your partner account is suspended, every one of your keys stops working at once (401 partner_not_approved). If API access is not switched on for you, the answer is 403 partner_api_disabled.

Getting a key

Once your access request has been approved, sign in to the partner panel → API access, tick the permissions the key needs, and press Create key. Copy it from the green box straight away. The same page lists your keys with their last use, lets you revoke any of them, and shows your webhook and your most recent API calls.

Scopes

A key carries one or more scopes, chosen when you create it. Give each integration only what it needs: a price-sync script rarely needs bookings:write. A missing scope is 403 insufficient_scope and the message names it.

ScopeWhat it allows
tours:readRead your tours, their prices and the reference data (GET /tours, /tours/{id}, /tours/{id}/pricing, /reference).
tours:writeCreate, change and delete tours; write prices; add and remove images; open a tour in another language.
bookings:readRead the bookings made on your tours (GET /bookings, /bookings/{ref}).
bookings:writeAccept or decline a booking (POST /bookings/{ref}/accept, /decline).
account:readRead your profile, your key, your recent API calls, the reference data, and the webhook registration and delivery log.
account:writeChange your profile (PATCH /me).
webhooks:writeRegister and change the webhook, rotate its secret, send a test event, retry a failed delivery. Also reads the registration and the delivery log.

When every call answers 401

GET /ping needs no key and returns authorization_header_seen. If it is false, something between your code and our server removed the Authorization header (a proxy, a redirect, a client that drops it on redirect) — from our side that looks exactly like a wrong key. If it is true, read error.code: missing_credentials, invalid_authorization_header (not Bearer <key>), invalid_key, key_revoked, key_expired or partner_not_approved.

3. Convenciones: límites, errores, idempotencia y paginación

Requests and responses

  • Send Content-Type: application/json and a JSON object. Form-encoded bodies are accepted too. A body over 1 MB is 413 body_too_large; a body that is not JSON is 400 invalid_json.
  • Every answer is JSON, UTF-8. The API sets no cookies and needs none.
  • Timestamps are ISO 8601 on the Istanbul clock with the offset: 2026-10-22T10:15:00+03:00. Dates are YYYY-MM-DD.
  • Money is always EUR, as plain numbers (85.0), per person unless a field says otherwise.
  • Image addresses are always absolute URLs.
  • Every answer except /openapi.json carries X-Request-Id (also in error bodies as request_id). Quote it when you write to us; it finds your exact call. Your own calls are listed by GET /requests for 30 days; bodies are never stored.
  • Writes answer with the thing they wrote (POST/PATCH /tours, PATCH /me, PUTs, accept and decline, translations), in the same shape the matching GET gives, plus ignored. Two writes answer with a small receipt instead: DELETE /tours/{id} (deleted, tour_id) and POST /tours/{id}/images (the stored address). ignored is the names of the fields in your request that were not applied because they are read-only or unknown (slug, status, group_id, pricing.mode …). Sending back what a GET returned is therefore always safe.

Pagination and incremental sync

List endpoints take page (from 1) and per_page (default 50, at most 100), and answer {"data": [...], "pagination": {"page", "per_page", "total", "has_more"}}.

To copy changes into your own system, poll GET /tours or GET /bookings with updated_since. Use the pagination.as_of of your previous poll as the next updated_since — not your own clock. as_of is our clock, taken before the read, so a row written while you were reading is caught next time and a difference between your clock and ours cannot make you miss a change. updated_since is inclusive (>=), so the boundary row comes again: de-duplicate on its id. Only those two lists have as_of and updated_since. Encode + as %2B in a URL.

Rate limits

LimitApplies toValue
Generalper key, rolling minute120 requests
Generalper key, rolling 24 hours10,000 requests
Image POSTsper key, rolling minute20 POSTs to /tours/{id}/images (each one may make our server download a file)
Test events and retriesper partner, rolling hour (all your keys and your partner panel together)10 test events and retries combined — /webhook/test, /webhook/deliveries/{id}/retry and the test button on your panel
Failed authenticationper IP address, rolling minute30 answers of 401

Every answer to a call carrying a key — whether the key was accepted or refused — has X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a Unix timestamp). They describe whichever window is tightest for you right now — the one that refused you, otherwise the one with less left — so you are warned before a daily lock-out. A 401 we could not match to a key reports the failed-authentication allowance for your address instead. Answers given before any key is read carry none of them, because no allowance is spent by them: /ping, /openapi.json, an unknown version, and the 503 while the API is switched off. Over a limit you get 429 with Retry-After in seconds. Answers of 429 are not counted against your windows, so a client that obeys Retry-After is never punished for having been limited.

The test/retry allowance is one allowance over three doors: a test event spends one of the 10 whether you ordered it through the API or with the button on your panel, and every POST to /webhook/deliveries/{id}/retry spends one whatever the answer was — a 403 for a missing scope or a 429 still counts. It exists so the API cannot be used to make our server post at an address you choose.

Errors

An error is the HTTP status plus this body:

{
    "error": {
        "type": "validation_error",
        "code": "tour_title_required",
        "message": "A tour needs a title. (2 fields were refused - see error.errors.)",
        "field": "title",
        "errors": [
            {
                "code": "tour_title_required",
                "field": "title",
                "message": "A tour needs a title."
            },
            {
                "code": "invalid_category",
                "field": "taxonomy.category_id",
                "message": "No active category with id 99. GET /reference lists them."
            }
        ]
    },
    "request_id": "req_8f21c0a4e3"
}

type groups the failure, code is stable and safe to branch on, message is for a human, field names the input that was wrong. When a request gets several things wrong, error.errors[] lists all of them at once (the first is also the headline), so a 200-tour import tells you what is broken in every row in one call. A rejected write changes nothing: validation runs to the end before anything is saved.

StatustypeWhen
400validation_errorThe body is not JSON (code invalid_json).
401authentication_errorNo key, a wrong key, a revoked or expired key, a key in the query string, or a partner that is not approved.
403permission_errorThe key lacks the scope, the partner has no API access, or the request was not HTTPS.
404not_foundNo such endpoint, or a resource that does not exist on your account. Another partner's resource answers the same 404, never a 403.
409conflictThe request is valid but the current state forbids it (a booking that is not pending, a tour that has bookings, a reused Idempotency-Key).
413validation_errorThe body, or an uploaded image, is too large.
422validation_errorThe request was understood and refused. Every wrong field is listed in error.errors.
429rate_limit_errorToo many requests. Wait the number of seconds in Retry-After.
500server_errorOur fault. Nothing was changed. Quote the request_id to us.
503service_unavailableThe partner API is switched off (code service_disabled).

The codes

Authentication and access
StatuscodeMeaning
401missing_credentialsNo Authorization header.
401invalid_authorization_headerThe header is not "Bearer <key>".
401invalid_keyThe key is malformed, unknown or its secret is wrong.
401key_revokedThe key was revoked in the panel.
401key_expiredThe key had an expiry date and it has passed. Keys made in the panel have none; one given an expiry by us stops here.
401api_key_in_queryA key was sent in the query string. Refused even when a correct header came with it - treat that key as leaked and rotate it.
401partner_not_approvedThe partner account is pending, suspended or rejected.
403partner_api_disabledAPI access is not switched on for this partner. Write to us.
403insufficient_scopeThe key does not carry the scope the endpoint needs; the message names it.
403ip_not_allowedThe key is restricted to certain addresses and this request came from another.
403https_requiredPlain HTTP. Use https://.
503service_disabledThe whole API is switched off at the moment.
404unsupported_versionOnly /api/partner/v1 exists.
404unknown_endpointNo such verb and path.
429rate_limit_exceededSee "Rate limits".
The request itself
StatuscodeMeaning
400invalid_jsonThe body is not a JSON object.
413body_too_largeThe JSON body is over 1 MB.
422no_updatable_fieldsNothing in the request can be written. Fields we dropped are listed in the message.
422invalid_field_typeAn object where a list belongs, or the reverse.
422invalid_updated_since / invalid_language / invalid_active / invalid_group_id / invalid_status / invalid_tour_id / invalid_date_from / invalid_date_toA bad filter on a list endpoint. All bad filters are reported together.
409idempotency_key_reusedThe Idempotency-Key was used before with a different body.
409idempotency_in_progressThe first request with this Idempotency-Key is still running.
500idempotency_unavailableThe key could not be recorded, so the request was not run. Retry.
Tours
StatuscodeMeaning
404tour_not_foundNo such tour on your account.
422tour_title_requiredA tour needs a title.
422invalid_languageNot one of en, es, pt, ar.
422invalid_difficulty_levelNot one of easy, moderate, challenging, difficult.
422invalid_category / invalid_destination / invalid_experience_type / invalid_guide_languageAn id or code that GET /reference does not list (or that is inactive).
422media_url_not_on_siteAn image address on another host. Bring it in with POST /tours/{id}/images.
422invalid_media_url / invalid_gallery / invalid_url / url_too_longA malformed address.
422too_many_itinerary_steps / too_many_faqs / too_many_supplements / too_many_items / too_many_imagesOver the caps in "Limits".
422invalid_itinerary_step / invalid_faq / invalid_supplementAn entry that is not an object.
422supplement_name_requiredAn active supplement without a name.
422hotel_switch_has_pricingincludes.hotel was set to false while the tour still has matrix cells. A matrix cannot be emptied through the API (every cell must stay above 0), so a tour that must become a day tour needs a message to Via Turquia.
409tour_has_bookingsA tour with bookings cannot be deleted.
409translation_existsThat language already exists in this tour's group.
409matrix_on_day_tourA matrix was sent for a tour without accommodation.
409flat_price_on_hotel_tourA single price was sent for a tour that includes accommodation.
422matrix_cell_missing / invalid_matrix_cell / duplicate_matrix_cell / invalid_matrix_priceThe matrix is not complete, or a cell is wrong. The message names every missing cell.
422invalid_price / pricing_body_requiredNo price figure, or one that is not a number.
500tour_write_failed / pricing_write_failed / image_write_failed / delete_failedNothing was saved; the whole change was rolled back.
500tour_unreadable / pricing_unreadableThe change was saved but could not be read back for the answer. Read it with a GET; quote the request id.
Images
StatuscodeMeaning
422image_requiredNeither a "file" part nor a JSON "url".
422unsafe_urlThe address is not https, or resolves to a private or reserved network.
422fetch_failedWe could not download the address (error, redirect, timeout).
422heic_not_supportedHEIC/HEIF photos are not accepted. Send JPEG, PNG, GIF or WebP.
422not_an_image / invalid_image_type / invalid_imageThe bytes are not a supported, decodable image.
413file_too_largeOver the size limit.
404image_not_in_galleryDELETE /images: that address is not in this tour's gallery.
422url_requiredDELETE /images needs {"url": ...}.
Bookings
StatuscodeMeaning
404booking_not_foundNo booking with that reference on one of your tours.
409booking_not_pendingAccept works only on a pending booking.
409booking_not_cancellableDecline works only on a pending or confirmed booking.
409deposit_already_paidThe customer has paid the deposit, so only Via Turquia can cancel (and refund).
500booking_unreadableThe answer was saved but the booking could not be read back. Read it with a GET; quote the request id.
Webhook
StatuscodeMeaning
404webhook_not_configuredYou have no webhook yet. PUT /webhook first.
404delivery_not_foundNo such delivery on your account.
409webhook_inactiveThe webhook is switched off. PUT /webhook with is_active true.
409already_deliveredRetry: that event was accepted by your endpoint.
409delivery_not_failedRetry: that delivery is still queued and will go on its own.
422url_required / invalid_url / unsafe_urlThe webhook address is missing, too long, or not an allowed https address.
422invalid_events / unknown_eventThe events list is wrong; the message lists the valid names.
500queue_failedThe test event could not be queued.
Profile
StatuscodeMeaning
422invalid_phoneA phone number has to be international, e.g. +905321234567.
422company_name_requiredcompany_name cannot be emptied.

Idempotency

A network timeout leaves you not knowing whether your POST happened. Send an Idempotency-Key header — any 1–80 printable ASCII characters, a fresh UUID for each operation — and retry with the same key and the same body:

  • It already finished (within 24 hours): you get the first answer back, with its original status, plus the header Idempotency-Replayed: true. The action is not run again — no second tour, no second accept.
  • It is still running: 409 idempotency_in_progress. Wait a moment and retry to collect its answer. (A request that never produced an answer is treated as abandoned after 120 seconds, and the key can then be used again.)
  • The body differs from the one first sent with that key (on any endpoint): 409 idempotency_key_reused. A key stands for one operation.
  • Error answers are stored too. A 422 replays as the same 422; fix your body and send it with a new key. Answers refused before the endpoint ran — 401, 403 and 429 — are not stored, so retrying those with the same key is fine.

Keys are scoped to your partner account (all your API keys share them) and are compared without regard to letter case. Only POST endpoints honour the header — PUT, PATCH and DELETE can simply be repeated. They are marked on every endpoint below. Always use it on POST /tours and on accept/decline.

4. Referencia de endpoints

Paths are relative to https://www.viaturquia.com/api/partner/v1. The curl examples assume the key is in $VT_API_KEY. {id} is a tour_id (every language version has its own); {ref} is a booking reference such as VT2026K7M2QX. The openapi.json describes the same endpoints, field by field.

Service and account

GET /ping

Is the service up, and does my Authorization header survive the trip?

No key needed

Request
curl "https://www.viaturquia.com/api/partner/v1/ping"
  • The one endpoint that needs no key. authorization_header_seen is false when something between you and us removed the header - from our side that looks exactly like a wrong key, so check this first when every call answers 401.
  • Answers 503 service_disabled while the API is switched off.
Response 200 - ok
{
    "ok": true,
    "version": "v1",
    "time": "2026-10-22T10:15:00+03:00",
    "authorization_header_seen": true
}

GET /me

Your partner profile, the key you are using, and your rate limit.

Scope: account:read

Request
curl "https://www.viaturquia.com/api/partner/v1/me" \
  -H "Authorization: Bearer $VT_API_KEY"
  • commission_pct is read only. rate_limit.remaining counts what is left after this request in the tighter of the two windows.
Response 200 - ok (51 lines)
{
    "partner": {
        "partner_id": 42,
        "slug": "anatolia-trails",
        "company_name": "Anatolia Trails",
        "contact_name": "Elif Demir",
        "email": "elif@anatolia-trails.example",
        "phone": "+905321234567",
        "country": "Turkey",
        "license_number": "TURSAB-1234",
        "type": "agency",
        "website": "https://anatolia-trails.example",
        "languages_spoken": [
            "English",
            "Spanish"
        ],
        "description": "Day tours and short breaks in Istanbul and Cappadocia.",
        "logo_url": null,
        "status": "approved",
        "api_enabled": true,
        "notify_email": true,
        "commission_pct": 15,
        "bank_account_holder": "Anatolia Trails Ltd",
        "bank_name": "Example Bank",
        "bank_iban": "TR330006100519786457841326",
        "bank_swift": "EXAMPTRIS",
        "tax_id": "1234567890",
        "approved_at": "2026-09-30T12:00:00+03:00",
        "created_at": "2026-09-28T09:41:00+03:00"
    },
    "key": {
        "key_id": "9f2c41d07ab35e18",
        "label": "Production sync",
        "scopes": [
            "tours:read",
            "tours:write",
            "bookings:read",
            "bookings:write",
            "webhooks:write"
        ],
        "expires_at": null,
        "last_used_at": "2026-10-22T10:14:58+03:00",
        "created_at": "2026-10-20T16:02:11+03:00"
    },
    "rate_limit": {
        "per_minute": 120,
        "per_day": 10000,
        "remaining": 118,
        "reset_at": "2026-10-22T10:16:00+03:00"
    }
}

PATCH /me

Change your profile.

Scope: account:write

Request
curl -X PATCH "https://www.viaturquia.com/api/partner/v1/me" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+905321234567","website":"https://anatolia-trails.example","languages_spoken":["English","Spanish"]}'
Request body
{
    "phone": "+905321234567",
    "website": "https://anatolia-trails.example",
    "languages_spoken": [
        "English",
        "Spanish"
    ]
}
  • Editable: company_name, contact_name, phone (international form), country, website (http/https), description (simple formatting survives), languages_spoken (a list, at most 20), bank_account_holder, bank_name, bank_iban, bank_swift, tax_id.
  • Your email, status, commission and everything else are not editable. They are not an error either: they come back in ignored[], so you can send back what GET /me gave you.
  • A request with any invalid field changes nothing at all.
Response 200 - ok - what GET /me answers, plus ignored (52 lines)
{
    "partner": {
        "partner_id": 42,
        "slug": "anatolia-trails",
        "company_name": "Anatolia Trails",
        "contact_name": "Elif Demir",
        "email": "elif@anatolia-trails.example",
        "phone": "+905321234567",
        "country": "Turkey",
        "license_number": "TURSAB-1234",
        "type": "agency",
        "website": "https://anatolia-trails.example",
        "languages_spoken": [
            "English",
            "Spanish"
        ],
        "description": "Day tours and short breaks in Istanbul and Cappadocia.",
        "logo_url": null,
        "status": "approved",
        "api_enabled": true,
        "notify_email": true,
        "commission_pct": 15,
        "bank_account_holder": "Anatolia Trails Ltd",
        "bank_name": "Example Bank",
        "bank_iban": "TR330006100519786457841326",
        "bank_swift": "EXAMPTRIS",
        "tax_id": "1234567890",
        "approved_at": "2026-09-30T12:00:00+03:00",
        "created_at": "2026-09-28T09:41:00+03:00"
    },
    "key": {
        "key_id": "9f2c41d07ab35e18",
        "label": "Production sync",
        "scopes": [
            "tours:read",
            "tours:write",
            "bookings:read",
            "bookings:write",
            "webhooks:write"
        ],
        "expires_at": null,
        "last_used_at": "2026-10-22T10:14:58+03:00",
        "created_at": "2026-10-20T16:02:11+03:00"
    },
    "rate_limit": {
        "per_minute": 120,
        "per_day": 10000,
        "remaining": 118,
        "reset_at": "2026-10-22T10:16:00+03:00"
    },
    "ignored": []
}

422 invalid_phone, invalid_url, company_name_required, no_updatable_fields

GET /reference

Every fixed value you need to build a tour: languages, categories, destinations, seasons, hotel classes, room types.

Scope: any one of tours:read or account:read

Request
curl "https://www.viaturquia.com/api/partner/v1/reference" \
  -H "Authorization: Bearer $VT_API_KEY"
  • The ids you send in taxonomy and the season_id of a price cell come from here. Lists are shortened in the example.
  • seasons[].date_ranges are MM-DD pairs and never cross the new year.
  • Readable with either scope, so a catalogue-only key can resolve ids.
Response 200 - ok (170 lines)
{
    "languages": [
        {
            "code": "en",
            "name": "English"
        },
        {
            "code": "es",
            "name": "Español"
        }
    ],
    "categories": [
        {
            "category_id": 3,
            "parent_id": null,
            "slug": "city-tours",
            "names": {
                "en": "City tours",
                "es": "Tours urbanos",
                "pt": "Passeios urbanos",
                "ar": null
            }
        }
    ],
    "destinations": [
        {
            "destination_id": 1,
            "slug": "istanbul",
            "names": {
                "en": "Istanbul",
                "es": "Estambul",
                "pt": "Istambul",
                "ar": "إسطنبول"
            }
        }
    ],
    "experience_types": [
        {
            "experience_type_id": 2,
            "slug": "cultural",
            "names": {
                "en": "Cultural",
                "es": "Cultural",
                "pt": "Cultural",
                "ar": null
            }
        }
    ],
    "seasons": [
        {
            "season_id": 1,
            "name_key": "highest",
            "names": {
                "en": "Highest season",
                "es": "Temporada más alta",
                "pt": null,
                "ar": null
            },
            "date_ranges": [
                {
                    "start": "08-01",
                    "end": "10-31"
                }
            ]
        }
    ],
    "hotel_categories": [
        {
            "code": "A",
            "names": {
                "en": "5 stars",
                "es": "5 estrellas",
                "pt": null,
                "ar": null
            },
            "descriptions": {
                "en": "Luxury hotels",
                "es": null,
                "pt": null,
                "ar": null
            }
        }
    ],
    "room_types": [
        {
            "code": "double",
            "capacity": 2,
            "names": {
                "en": "Double",
                "es": "Doble",
                "pt": "Duplo",
                "ar": null
            }
        },
        {
            "code": "triple",
            "capacity": 3,
            "names": {
                "en": "Triple",
                "es": "Triple",
                "pt": "Triplo",
                "ar": null
            }
        },
        {
            "code": "individual",
            "capacity": 1,
            "names": {
                "en": "Single",
                "es": "Individual",
                "pt": "Individual",
                "ar": null
            }
        }
    ],
    "difficulty_levels": [
        {
            "code": "easy",
            "labels": {
                "en": "Easy",
                "es": "Fácil",
                "pt": "Fácil",
                "ar": "سهل"
            }
        }
    ],
    "group_types": [
        {
            "code": "small_group",
            "labels": {
                "en": "Small group",
                "es": "Grupo pequeño",
                "pt": "Grupo pequeno",
                "ar": "مجموعة صغيرة"
            }
        }
    ],
    "age_ranges": [
        {
            "code": "all_ages",
            "labels": {
                "en": "All ages",
                "es": "Todas las edades",
                "pt": "Todas as idades",
                "ar": "جميع الأعمار"
            }
        }
    ],
    "document_types": [
        "license",
        "id",
        "contract",
        "insurance",
        "other"
    ],
    "guide_languages": [
        "en",
        "es",
        "pt",
        "ar",
        "fr",
        "de",
        "it",
        "ru",
        "tr",
        "ja",
        "zh",
        "ko"
    ]
}

GET /requests

Your own recent API calls (the audit trail).

Scope: account:read

Query parameters
NameTypeMeaning
pageintegerPage number, from 1.
per_pageintegerRows per page. Default 50, at most 100.
Request
curl "https://www.viaturquia.com/api/partner/v1/requests?page=1&per_page=50" \
  -H "Authorization: Bearer $VT_API_KEY"
  • Newest first. Request bodies are never stored. The request that asks is not in the list: it is logged as it is answered. Entries are kept for 30 days.
Response 200 - ok
{
    "data": [
        {
            "request_id": "req_8f21c0a4e3",
            "key_id": "9f2c41d07ab35e18",
            "method": "GET",
            "path": "/api/partner/v1/tours?page=1",
            "status": 200,
            "duration_ms": 31,
            "ip": "203.0.113.7",
            "idempotency_key": null,
            "created_at": "2026-10-22T10:14:58+03:00"
        }
    ],
    "pagination": {
        "page": 1,
        "per_page": 50,
        "total": 1,
        "has_more": false
    }
}

GET /openapi.json

This API as an OpenAPI 3.1 document.

No key needed

Request
curl "https://www.viaturquia.com/api/partner/v1/openapi.json"
  • Needs no key and is readable even while the API is switched off, so you can load it into Postman or Insomnia before you have an account.

200 ok — the OpenAPI 3.1 document, JSON.

Tours

GET /tours

Your tours, one summary row each.

Scope: tours:read

Query parameters
NameTypeMeaning
pageintegerPage number, from 1.
per_pageintegerRows per page. Default 50, at most 100.
languagestringen, es, pt or ar.
activestring1 (published) or 0 (waiting for review).
group_idintegerOnly the language versions of one tour.
updated_sincestringISO 8601 date or time, e.g. 2026-10-22T10:15:00+03:00. Only rows changed at or after it. Pass the previous list's pagination.as_of here. In a URL encode + as %2B.
qstringMatches the title or slug.
Request
curl "https://www.viaturquia.com/api/partner/v1/tours?language=es&updated_since=2026-10-22T10%3A15%3A00%2B03%3A00" \
  -H "Authorization: Bearer $VT_API_KEY"
  • Ordered by tour_id. Every language version of a tour is its own row with its own tour_id; group_id ties them together.
  • pagination.as_of is our clock, taken before the read. Store it and send it back as updated_since next time instead of your own clock: it cannot drift, and a row written while you read is caught by the next poll. updated_since is inclusive (>=), so the row at the boundary comes again - de-duplicate on tour_id.
Response 200 - ok
{
    "data": [
        {
            "tour_id": 1207,
            "group_id": 1207,
            "language": "es",
            "slug": "estambul-clasico",
            "title": "Estambul Clásico",
            "status": "awaiting_review",
            "is_active": false,
            "price": 85,
            "duration_days": 1,
            "duration_nights": 0,
            "hotel_included": false,
            "pricing_mode": "flat",
            "main_image": "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg",
            "created_at": "2026-10-22T09:58:12+03:00",
            "updated_at": "2026-10-22T10:02:40+03:00"
        }
    ],
    "pagination": {
        "page": 1,
        "per_page": 50,
        "total": 1,
        "has_more": false,
        "as_of": "2026-10-22T10:15:00+03:00"
    }
}

422 one error listing every bad filter

POST /tours

Create a tour (one language).

Scope: tours:write · honours Idempotency-Key

Request
curl -X POST "https://www.viaturquia.com/api/partner/v1/tours" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d @body.json
Request body (71 lines)
{
    "language": "es",
    "title": "Estambul Clásico",
    "short_description": "Un día completo entre Santa Sofía, la Mezquita Azul y el Bósforo.",
    "description": "<p>Descubre el corazón histórico de Estambul con un guía local.</p>",
    "highlights": [
        "Santa Sofía",
        "Mezquita Azul"
    ],
    "starting_time": "09:00",
    "meeting_point": "Vestíbulo de su hotel en Sultanahmet",
    "duration": {
        "days": 1,
        "details": "8 horas"
    },
    "group": {
        "type": "small_group",
        "max_size": 16,
        "min_participants": 2
    },
    "difficulty_level": "easy",
    "includes": {
        "hotel": false,
        "guide": true,
        "meals": true,
        "transfers": true,
        "tickets": true
    },
    "inclusions": [
        "Guía en español",
        "Entradas"
    ],
    "itinerary": [
        {
            "time": "09:00",
            "title": "Recogida en el hotel",
            "description": "<p>Salida desde Sultanahmet.</p>"
        }
    ],
    "supplements": [
        {
            "name": "Cena en el Bósforo",
            "price": 25,
            "per_person": true
        }
    ],
    "pricing": {
        "price": 85,
        "original_price": 110,
        "child_discount_pct": 30,
        "infant_discount_pct": 100
    },
    "cancellation": {
        "policy": "<p>Cancelación gratuita hasta 48 horas antes.</p>",
        "free_hours": 48
    },
    "taxonomy": {
        "category_id": 3,
        "destinations": [
            1
        ],
        "experience_types": [
            2
        ],
        "guide_languages": [
            "es",
            "en"
        ]
    },
    "reference_url": "https://anatolia-trails.example/tour/123"
}
  • Answers 201 with the same object GET /tours/{id} gives, plus ignored[]. The tour is born unpublished: status is awaiting_review until Via Turquia approves it. You cannot publish it yourself.
  • language is en, es, pt or ar and defaults to es when you leave it out. To add the same tour in another language use POST /tours/{id}/translations - group_id cannot be set here.
  • title is the only required field. Everything is described field by field in section 5.
  • Send an Idempotency-Key so a retry after a timeout cannot create the tour twice.
Response 201 - created (123 lines)
{
    "tour_id": 1207,
    "group_id": 1207,
    "language": "es",
    "slug": "estambul-clasico",
    "status": "awaiting_review",
    "is_active": false,
    "title": "Estambul Clásico",
    "short_description": "Un día completo entre Santa Sofía, la Mezquita Azul y el Bósforo.",
    "description": "<p>Descubre el corazón histórico de Estambul con un guía local.</p>",
    "highlights": [
        "Santa Sofía",
        "Mezquita Azul",
        "Crucero por el Bósforo"
    ],
    "important_info": "Lleve calzado cómodo y hombros cubiertos para las mezquitas.",
    "meeting_point": "Vestíbulo de su hotel en Sultanahmet",
    "ending_point": "Gran Bazar",
    "starting_time": "09:00",
    "duration": {
        "days": 1,
        "nights": 0,
        "details": "8 horas"
    },
    "group": {
        "type": "Grupo pequeño",
        "max_size": 16,
        "min_participants": 2,
        "max_participants": 16
    },
    "age": {
        "min_age": 6,
        "range": "Todas las edades",
        "child_label": "6-11",
        "infant_label": "0-5"
    },
    "difficulty_level": "easy",
    "difficulty_label": "Fácil",
    "includes": {
        "hotel": false,
        "guide": true,
        "meals": true,
        "transfers": true,
        "tickets": true
    },
    "inclusions": [
        "Guía en español",
        "Entradas",
        "Almuerzo"
    ],
    "exclusions": [
        "Bebidas",
        "Propinas"
    ],
    "itinerary": [
        {
            "time": "09:00",
            "title": "Recogida en el hotel",
            "description": "<p>Salida desde Sultanahmet.</p>"
        },
        {
            "time": "09:30",
            "title": "Santa Sofía",
            "description": "<p>Visita guiada de una hora.</p>"
        }
    ],
    "faqs": [
        {
            "question": "¿Hay que reservar con antelación?",
            "answer": "Sí, con 24 horas."
        }
    ],
    "supplements": [
        {
            "name": "Cena en el Bósforo",
            "description": null,
            "price": 25,
            "per_person": true,
            "active": true
        }
    ],
    "pricing": {
        "mode": "flat",
        "hotel_included": false,
        "matrix_cells": 0,
        "matrix_complete": false,
        "price": 85,
        "original_price": 110,
        "child_discount_pct": 30,
        "infant_discount_pct": 100
    },
    "cancellation": {
        "policy": "<p>Cancelación gratuita hasta 48 horas antes.</p>",
        "free_hours": 48
    },
    "media": {
        "main_image": "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg",
        "gallery": [
            "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg"
        ],
        "video_url": null
    },
    "taxonomy": {
        "category_id": 3,
        "categories": [
            3
        ],
        "destinations": [
            1
        ],
        "experience_types": [
            2
        ],
        "guide_languages": [
            "es",
            "en"
        ]
    },
    "reference_url": "https://anatolia-trails.example/tour/123",
    "created_at": "2026-10-22T09:58:12+03:00",
    "updated_at": "2026-10-22T10:02:40+03:00",
    "ignored": []
}
Response 422 - every wrong field in error.errors
{
    "error": {
        "type": "validation_error",
        "code": "tour_title_required",
        "message": "A tour needs a title. (2 fields were refused - see error.errors.)",
        "field": "title",
        "errors": [
            {
                "code": "tour_title_required",
                "field": "title",
                "message": "A tour needs a title."
            },
            {
                "code": "invalid_category",
                "field": "taxonomy.category_id",
                "message": "No active category with id 99. GET /reference lists them."
            }
        ]
    },
    "request_id": "req_8f21c0a4e3"
}

GET /tours/{id}

One tour in full.

Scope: tours:read

Request
curl "https://www.viaturquia.com/api/partner/v1/tours/1207" \
  -H "Authorization: Bearer $VT_API_KEY"
  • The object is also the shape POST and PATCH accept: fetch it, change a value, send it back. Fields we compute or that are yours to read only (tour_id, group_id, slug, status, is_active, created_at, updated_at, difficulty_label, pricing.mode and the other derived pricing fields) are dropped on the way in and listed in ignored[].
  • taxonomy.category_id is the tour's primary category, or null when none is set; taxonomy.categories is every category the tour is listed in.
  • Someone else's tour is a 404, exactly like one that does not exist.
Response 200 - ok (122 lines)
{
    "tour_id": 1207,
    "group_id": 1207,
    "language": "es",
    "slug": "estambul-clasico",
    "status": "awaiting_review",
    "is_active": false,
    "title": "Estambul Clásico",
    "short_description": "Un día completo entre Santa Sofía, la Mezquita Azul y el Bósforo.",
    "description": "<p>Descubre el corazón histórico de Estambul con un guía local.</p>",
    "highlights": [
        "Santa Sofía",
        "Mezquita Azul",
        "Crucero por el Bósforo"
    ],
    "important_info": "Lleve calzado cómodo y hombros cubiertos para las mezquitas.",
    "meeting_point": "Vestíbulo de su hotel en Sultanahmet",
    "ending_point": "Gran Bazar",
    "starting_time": "09:00",
    "duration": {
        "days": 1,
        "nights": 0,
        "details": "8 horas"
    },
    "group": {
        "type": "Grupo pequeño",
        "max_size": 16,
        "min_participants": 2,
        "max_participants": 16
    },
    "age": {
        "min_age": 6,
        "range": "Todas las edades",
        "child_label": "6-11",
        "infant_label": "0-5"
    },
    "difficulty_level": "easy",
    "difficulty_label": "Fácil",
    "includes": {
        "hotel": false,
        "guide": true,
        "meals": true,
        "transfers": true,
        "tickets": true
    },
    "inclusions": [
        "Guía en español",
        "Entradas",
        "Almuerzo"
    ],
    "exclusions": [
        "Bebidas",
        "Propinas"
    ],
    "itinerary": [
        {
            "time": "09:00",
            "title": "Recogida en el hotel",
            "description": "<p>Salida desde Sultanahmet.</p>"
        },
        {
            "time": "09:30",
            "title": "Santa Sofía",
            "description": "<p>Visita guiada de una hora.</p>"
        }
    ],
    "faqs": [
        {
            "question": "¿Hay que reservar con antelación?",
            "answer": "Sí, con 24 horas."
        }
    ],
    "supplements": [
        {
            "name": "Cena en el Bósforo",
            "description": null,
            "price": 25,
            "per_person": true,
            "active": true
        }
    ],
    "pricing": {
        "mode": "flat",
        "hotel_included": false,
        "matrix_cells": 0,
        "matrix_complete": false,
        "price": 85,
        "original_price": 110,
        "child_discount_pct": 30,
        "infant_discount_pct": 100
    },
    "cancellation": {
        "policy": "<p>Cancelación gratuita hasta 48 horas antes.</p>",
        "free_hours": 48
    },
    "media": {
        "main_image": "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg",
        "gallery": [
            "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg"
        ],
        "video_url": null
    },
    "taxonomy": {
        "category_id": 3,
        "categories": [
            3
        ],
        "destinations": [
            1
        ],
        "experience_types": [
            2
        ],
        "guide_languages": [
            "es",
            "en"
        ]
    },
    "reference_url": "https://anatolia-trails.example/tour/123",
    "created_at": "2026-10-22T09:58:12+03:00",
    "updated_at": "2026-10-22T10:02:40+03:00"
}

404 tour_not_found

PATCH /tours/{id}

Change part of a tour. A field you leave out is not touched.

Scope: tours:write

Request
curl -X PATCH "https://www.viaturquia.com/api/partner/v1/tours/1207" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Estambul Clásico en un día","pricing":{"price":89}}'
Request body
{
    "title": "Estambul Clásico en un día",
    "pricing": {
        "price": 89
    }
}
  • A tour that is already published stays published: your change goes live at once, without a new review. It is recorded in your audit trail.
  • Some fields change every language version at once - see "Which fields are shared" in section 5.
  • Lists (inclusions, itinerary, faqs, supplements, gallery, taxonomy lists) are replaced as a whole when you send them. supplements is the sharp one: every supplement row is replaced, including the inactive ones, so leaving one out deletes it.
  • Sending only {"pricing":{"price":N}} to a tour priced by a room matrix is 422 no_updatable_fields, because on such a tour the price is derived from the matrix and pricing.price is ignored. Together with other fields it simply appears in ignored[].
  • A request that fails validation changes nothing.
Response 200 - ok (123 lines)
{
    "tour_id": 1207,
    "group_id": 1207,
    "language": "es",
    "slug": "estambul-clasico",
    "status": "awaiting_review",
    "is_active": false,
    "title": "Estambul Clásico en un día",
    "short_description": "Un día completo entre Santa Sofía, la Mezquita Azul y el Bósforo.",
    "description": "<p>Descubre el corazón histórico de Estambul con un guía local.</p>",
    "highlights": [
        "Santa Sofía",
        "Mezquita Azul",
        "Crucero por el Bósforo"
    ],
    "important_info": "Lleve calzado cómodo y hombros cubiertos para las mezquitas.",
    "meeting_point": "Vestíbulo de su hotel en Sultanahmet",
    "ending_point": "Gran Bazar",
    "starting_time": "09:00",
    "duration": {
        "days": 1,
        "nights": 0,
        "details": "8 horas"
    },
    "group": {
        "type": "Grupo pequeño",
        "max_size": 16,
        "min_participants": 2,
        "max_participants": 16
    },
    "age": {
        "min_age": 6,
        "range": "Todas las edades",
        "child_label": "6-11",
        "infant_label": "0-5"
    },
    "difficulty_level": "easy",
    "difficulty_label": "Fácil",
    "includes": {
        "hotel": false,
        "guide": true,
        "meals": true,
        "transfers": true,
        "tickets": true
    },
    "inclusions": [
        "Guía en español",
        "Entradas",
        "Almuerzo"
    ],
    "exclusions": [
        "Bebidas",
        "Propinas"
    ],
    "itinerary": [
        {
            "time": "09:00",
            "title": "Recogida en el hotel",
            "description": "<p>Salida desde Sultanahmet.</p>"
        },
        {
            "time": "09:30",
            "title": "Santa Sofía",
            "description": "<p>Visita guiada de una hora.</p>"
        }
    ],
    "faqs": [
        {
            "question": "¿Hay que reservar con antelación?",
            "answer": "Sí, con 24 horas."
        }
    ],
    "supplements": [
        {
            "name": "Cena en el Bósforo",
            "description": null,
            "price": 25,
            "per_person": true,
            "active": true
        }
    ],
    "pricing": {
        "mode": "flat",
        "hotel_included": false,
        "matrix_cells": 0,
        "matrix_complete": false,
        "price": 89,
        "original_price": 110,
        "child_discount_pct": 30,
        "infant_discount_pct": 100
    },
    "cancellation": {
        "policy": "<p>Cancelación gratuita hasta 48 horas antes.</p>",
        "free_hours": 48
    },
    "media": {
        "main_image": "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg",
        "gallery": [
            "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg"
        ],
        "video_url": null
    },
    "taxonomy": {
        "category_id": 3,
        "categories": [
            3
        ],
        "destinations": [
            1
        ],
        "experience_types": [
            2
        ],
        "guide_languages": [
            "es",
            "en"
        ]
    },
    "reference_url": "https://anatolia-trails.example/tour/123",
    "created_at": "2026-10-22T09:58:12+03:00",
    "updated_at": "2026-10-22T10:30:00+03:00",
    "ignored": []
}

404 tour_not_found

Response 422 - every wrong field in error.errors
{
    "error": {
        "type": "validation_error",
        "code": "tour_title_required",
        "message": "A tour needs a title. (2 fields were refused - see error.errors.)",
        "field": "title",
        "errors": [
            {
                "code": "tour_title_required",
                "field": "title",
                "message": "A tour needs a title."
            },
            {
                "code": "invalid_category",
                "field": "taxonomy.category_id",
                "message": "No active category with id 99. GET /reference lists them."
            }
        ]
    },
    "request_id": "req_8f21c0a4e3"
}

DELETE /tours/{id}

Delete one language version of a tour.

Scope: tours:write

Request
curl -X DELETE "https://www.viaturquia.com/api/partner/v1/tours/1207" \
  -H "Authorization: Bearer $VT_API_KEY"
  • Deletes only the row you address, not its other languages. A tour that has any booking - even a cancelled one - answers 409 tour_has_bookings and stays; ask us to take it off the site instead.
Response 200 - ok
{
    "deleted": true,
    "tour_id": 1207
}

409 tour_has_bookings

POST /tours/{id}/translations

Open a tour in another language.

Scope: tours:write · honours Idempotency-Key

Request
curl -X POST "https://www.viaturquia.com/api/partner/v1/tours/1207/translations" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"language":"en"}'
Request body
{
    "language": "en"
}
  • Creates a new language version in the same tour group, unpublished. It copies the structure - durations, group sizes, cancellation hours, prices and the whole matrix, the include switches, categories, destinations, experience types, guide languages, ages - and leaves every text empty. The translation is yours to write with PATCH.
  • needs[] lists what you still have to fill in. Supplements arrive with their price but inactive and with no name (needs shows the source name as a hint); they are not sold until you PATCH supplements with a name and "active": true.
  • A language the group already has is 409 translation_exists.
Response 201 - created (111 lines)
{
    "tour_id": 1208,
    "group_id": 1207,
    "language": "en",
    "slug": "estambul-clasico-en",
    "status": "awaiting_review",
    "is_active": false,
    "title": "",
    "short_description": null,
    "description": null,
    "highlights": [],
    "important_info": null,
    "meeting_point": null,
    "ending_point": null,
    "starting_time": null,
    "duration": {
        "days": 1,
        "nights": 0,
        "details": null
    },
    "group": {
        "type": null,
        "max_size": 16,
        "min_participants": 2,
        "max_participants": 16
    },
    "age": {
        "min_age": 6,
        "range": null,
        "child_label": "6-11",
        "infant_label": "0-5"
    },
    "difficulty_level": null,
    "difficulty_label": null,
    "includes": {
        "hotel": false,
        "guide": true,
        "meals": true,
        "transfers": true,
        "tickets": true
    },
    "inclusions": [],
    "exclusions": [],
    "itinerary": [],
    "faqs": [],
    "supplements": [
        {
            "name": "",
            "description": null,
            "price": 25,
            "per_person": true,
            "active": false
        }
    ],
    "pricing": {
        "mode": "flat",
        "hotel_included": false,
        "matrix_cells": 0,
        "matrix_complete": false,
        "price": 85,
        "original_price": 110,
        "child_discount_pct": 30,
        "infant_discount_pct": 100
    },
    "cancellation": {
        "policy": null,
        "free_hours": 48
    },
    "media": {
        "main_image": null,
        "gallery": [],
        "video_url": null
    },
    "taxonomy": {
        "category_id": 3,
        "categories": [
            3
        ],
        "destinations": [
            1
        ],
        "experience_types": [
            2
        ],
        "guide_languages": [
            "es",
            "en"
        ]
    },
    "reference_url": "https://anatolia-trails.example/tour/123",
    "created_at": "2026-10-22T09:58:12+03:00",
    "updated_at": "2026-10-22T10:02:40+03:00",
    "ignored": [],
    "needs": [
        "title",
        "short_description",
        "description",
        "highlights",
        "important_info",
        "itinerary",
        "inclusions",
        "exclusions",
        "meeting_point",
        "ending_point",
        "cancellation.policy",
        "faqs",
        "media.main_image",
        "media.gallery",
        "supplements[0].name (source: Cena en el Bósforo)"
    ]
}

409 translation_exists

422 invalid_language

Pricing

GET /tours/{id}/pricing

The 27-cell room matrix of an accommodation tour, or the single price of a day tour.

Scope: tours:read

Request
curl "https://www.viaturquia.com/api/partner/v1/tours/1207/pricing" \
  -H "Authorization: Bearer $VT_API_KEY"
  • group_tour_ids lists the language versions that share these prices - the rows a write reaches.
  • mode is matrix as soon as one cell exists. matrix_complete tells an accommodation tour whose matrix you have not finished from a day tour.
  • The example shows three of the 27 cells.
Response 200 - a day tour
{
    "tour_id": 1207,
    "group_id": 1207,
    "group_tour_ids": [
        1207
    ],
    "mode": "flat",
    "hotel_included": false,
    "matrix_cells": 0,
    "matrix_complete": false,
    "price": 85,
    "original_price": 110,
    "child_discount_pct": 30,
    "infant_discount_pct": 100
}
Response 200 - an accommodation tour (38 lines)
{
    "tour_id": 1301,
    "group_id": 1301,
    "group_tour_ids": [
        1301,
        1302,
        1303,
        1304
    ],
    "mode": "matrix",
    "hotel_included": true,
    "matrix_cells": 27,
    "matrix_complete": true,
    "price": 410,
    "original_price": null,
    "child_discount_pct": 30,
    "infant_discount_pct": 100,
    "matrix": [
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "double",
            "price": 640
        },
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "triple",
            "price": 610
        },
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "individual",
            "price": 840
        }
    ]
}

PUT /tours/{id}/pricing

Write the prices: the whole matrix for an accommodation tour, or one price for a day tour.

Scope: tours:write

Request
curl -X PUT "https://www.viaturquia.com/api/partner/v1/tours/1207/pricing" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"matrix":[{"season_id":1,"hotel_category":"A","room_type":"double","price":640},{"season_id":1,"hotel_category":"A","room_type":"triple","price":610},{"season_id":1,"hotel_category":"A","room_type":"individual","price":840}]}'
Request body
{
    "matrix": [
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "double",
            "price": 640
        },
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "triple",
            "price": 610
        },
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "individual",
            "price": 840
        }
    ]
}
Request body - A day tour sends one price
{
    "price": 89
}
  • matrix: all 27 cells are required (3 seasons x hotel classes A/B/C x room types double/triple/individual), each above 0. A missing cell is 422 and the message names every one that is missing. The figures are per person, in EUR.
  • The prices are written to every language version of the tour (that is also an accommodation tour). tours.price - the "from" price on the site - is then set to the cheapest cell; you cannot set it by hand on such a tour.
  • A matrix for a tour whose includes.hotel is false is 409 matrix_on_day_tour; a single price for a tour that includes accommodation is 409 flat_price_on_hotel_tour.
  • Anything else in the body is dropped and listed in ignored[] - so you can send back what GET returned.
Response 200 - ok - what GET answers, plus ignored (39 lines)
{
    "tour_id": 1301,
    "group_id": 1301,
    "group_tour_ids": [
        1301,
        1302,
        1303,
        1304
    ],
    "mode": "matrix",
    "hotel_included": true,
    "matrix_cells": 27,
    "matrix_complete": true,
    "price": 410,
    "original_price": null,
    "child_discount_pct": 30,
    "infant_discount_pct": 100,
    "matrix": [
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "double",
            "price": 640
        },
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "triple",
            "price": 610
        },
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "individual",
            "price": 840
        }
    ],
    "ignored": []
}

409 matrix_on_day_tour, flat_price_on_hotel_tour

422 matrix_cell_missing, invalid_matrix_cell, duplicate_matrix_cell, invalid_matrix_price, invalid_price

Images

POST /tours/{id}/images

Add an image to a tour's gallery - upload a file, or give us an address to fetch.

Scope: tours:write · honours Idempotency-Key

Request
curl -X POST "https://www.viaturquia.com/api/partner/v1/tours/1207/images" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://anatolia-trails.example/photos/santa-sofia.jpg"}'
Or upload the file
curl -X POST "https://www.viaturquia.com/api/partner/v1/tours/1207/images" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "file=@santa-sofia.jpg"
Request body
{
    "url": "https://anatolia-trails.example/photos/santa-sofia.jpg"
}
  • Either multipart/form-data with the file in a part named "file" (or "image"), or JSON {"url": "https://..."} and our server downloads it.
  • Accepted: JPEG, PNG, GIF, WebP. HEIC/HEIF photos (iPhones) are refused with heic_not_supported. Uploads up to 12 MB; downloads up to 5 MB; at most 50 megapixels. We apply the EXIF rotation and scale the longest edge down to 1920 px.
  • A fetched address must be https, must resolve to a public address, is not allowed to redirect, and has 10 seconds. At most 20 image POSTs a minute per key.
  • The image is appended to the gallery of that one language version (a gallery belongs to its row). The answer is the stored address; to make it the main image, send it as media.main_image with PATCH. A tour holds at most 40 gallery images.
  • Media addresses in a PATCH/POST body must already be on Via Turquia - this endpoint is how an outside image gets there.
Response 201 - created
{
    "tour_id": 1207,
    "url": "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg",
    "source": "url",
    "gallery_count": 2
}

422 image_required, unsafe_url, fetch_failed, heic_not_supported, not_an_image, too_many_images

413 file_too_large

DELETE /tours/{id}/images

Take an image out of a tour's gallery.

Scope: tours:write

Request
curl -X DELETE "https://www.viaturquia.com/api/partner/v1/tours/1207/images" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg"}'
Request body
{
    "url": "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg"
}
  • The image leaves the gallery; the file itself is not deleted from our storage.
Response 200 - ok
{
    "deleted": true,
    "tour_id": 1207,
    "url": "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg",
    "gallery_count": 1
}

404 image_not_in_gallery, tour_not_found

Bookings

GET /bookings

Bookings made on your tours, newest first.

Scope: bookings:read

Query parameters
NameTypeMeaning
pageintegerPage number, from 1.
per_pageintegerRows per page. Default 50, at most 100.
statusstringpending, confirmed, cancelled or completed.
tour_idintegerOne tour row.
date_fromstringTravel date from, YYYY-MM-DD.
date_tostringTravel date to, YYYY-MM-DD.
updated_sincestringISO 8601 date or time, e.g. 2026-10-22T10:15:00+03:00. Only rows changed at or after it. Pass the previous list's pagination.as_of here. In a URL encode + as %2B.
Request
curl "https://www.viaturquia.com/api/partner/v1/bookings?status=pending&date_from=2026-11-01" \
  -H "Authorization: Bearer $VT_API_KEY"
  • Customers book on the Via Turquia site (or through its chat assistant); you cannot create a booking through the API.
  • pagination.as_of works as it does for tours.
  • The customer's name, email and phone are in the booking: the same details your new-booking email already carries, so you can run the tour.
Response 200 - ok (102 lines)
{
    "data": [
        {
            "booking_ref": "VT2026K7M2QX",
            "booking_id": 8841,
            "status": "pending",
            "source": "web",
            "tour": {
                "tour_id": 1301,
                "group_id": 1301,
                "language": "en",
                "title": "Cappadocia 3 Days",
                "slug": "cappadocia-3-days"
            },
            "booking_date": "2026-11-14",
            "travellers": {
                "adults": 2,
                "children": 0,
                "infants": 0,
                "total": 2
            },
            "rooms": [
                {
                    "room_type_id": 1,
                    "room_type": "double",
                    "room_type_name": "Double",
                    "capacity": 2,
                    "hotel_category_id": 2,
                    "hotel_category": "B",
                    "hotel_category_name": "4 stars",
                    "season_id": 1,
                    "season": "highest",
                    "season_name": "Highest season",
                    "occupancy": 2,
                    "travellers": {
                        "adults": 2,
                        "children": 0,
                        "infants": 0
                    },
                    "price_per_person": 520,
                    "room_subtotal": 1040
                }
            ],
            "room_summary": {
                "season_id": 1,
                "season_name": "Highest season",
                "hotel_category_id": 2,
                "hotel_category_name": "4 stars",
                "room_type_id": 1,
                "room_type_name": "Double"
            },
            "supplements": {
                "info": "Hot-air balloon x2",
                "total": 300
            },
            "special_requests": "Vegetarian meals, please.",
            "hotel": {
                "name": "Cave Hotel Goreme",
                "address": "Goreme, Nevsehir"
            },
            "pickup_time": "08:30:00",
            "customer": {
                "name": "Ana García",
                "email": "ana@example.com",
                "phone": "+34600111222",
                "country": "Spain"
            },
            "amounts": {
                "currency": "EUR",
                "price_per_person": 520,
                "discount": 0,
                "promo_code": null,
                "total": 1340,
                "commission_pct": 15,
                "commission": 201,
                "net": 1139,
                "currency_shown": "EUR"
            },
            "payment": {
                "deposit_status": "pending",
                "deposit_amount": 268,
                "deposit_paid_at": null,
                "deposit_method": null,
                "payment_status": "pending"
            },
            "partner_answer": {
                "action": "none",
                "at": null,
                "note": null
            },
            "created_at": "2026-10-22T09:12:30+03:00",
            "updated_at": "2026-10-22T09:12:30+03:00"
        }
    ],
    "pagination": {
        "page": 1,
        "per_page": 50,
        "total": 1,
        "has_more": false,
        "as_of": "2026-10-22T10:15:00+03:00"
    }
}

GET /bookings/{ref}

One booking, by its reference (VT2026...).

Scope: bookings:read

Request
curl "https://www.viaturquia.com/api/partner/v1/bookings/VT2026K7M2QX" \
  -H "Authorization: Bearer $VT_API_KEY"
  • rooms[] is one entry per room, with the room type, hotel class, season and who sleeps in it; room_summary is the single-room summary kept on the booking row.
  • amounts are EUR. commission and net are zero for a cancelled booking. currency_shown is only what the customer saw on the page.
  • payment tells you whether the deposit has arrived. partner_answer is what you answered (none until you do) and stays even if Via Turquia later changes the status.
Response 200 - ok (91 lines)
{
    "booking_ref": "VT2026K7M2QX",
    "booking_id": 8841,
    "status": "pending",
    "source": "web",
    "tour": {
        "tour_id": 1301,
        "group_id": 1301,
        "language": "en",
        "title": "Cappadocia 3 Days",
        "slug": "cappadocia-3-days"
    },
    "booking_date": "2026-11-14",
    "travellers": {
        "adults": 2,
        "children": 0,
        "infants": 0,
        "total": 2
    },
    "rooms": [
        {
            "room_type_id": 1,
            "room_type": "double",
            "room_type_name": "Double",
            "capacity": 2,
            "hotel_category_id": 2,
            "hotel_category": "B",
            "hotel_category_name": "4 stars",
            "season_id": 1,
            "season": "highest",
            "season_name": "Highest season",
            "occupancy": 2,
            "travellers": {
                "adults": 2,
                "children": 0,
                "infants": 0
            },
            "price_per_person": 520,
            "room_subtotal": 1040
        }
    ],
    "room_summary": {
        "season_id": 1,
        "season_name": "Highest season",
        "hotel_category_id": 2,
        "hotel_category_name": "4 stars",
        "room_type_id": 1,
        "room_type_name": "Double"
    },
    "supplements": {
        "info": "Hot-air balloon x2",
        "total": 300
    },
    "special_requests": "Vegetarian meals, please.",
    "hotel": {
        "name": "Cave Hotel Goreme",
        "address": "Goreme, Nevsehir"
    },
    "pickup_time": "08:30:00",
    "customer": {
        "name": "Ana García",
        "email": "ana@example.com",
        "phone": "+34600111222",
        "country": "Spain"
    },
    "amounts": {
        "currency": "EUR",
        "price_per_person": 520,
        "discount": 0,
        "promo_code": null,
        "total": 1340,
        "commission_pct": 15,
        "commission": 201,
        "net": 1139,
        "currency_shown": "EUR"
    },
    "payment": {
        "deposit_status": "pending",
        "deposit_amount": 268,
        "deposit_paid_at": null,
        "deposit_method": null,
        "payment_status": "pending"
    },
    "partner_answer": {
        "action": "none",
        "at": null,
        "note": null
    },
    "created_at": "2026-10-22T09:12:30+03:00",
    "updated_at": "2026-10-22T09:12:30+03:00"
}

404 booking_not_found

POST /bookings/{ref}/accept

Accept a pending booking.

Scope: bookings:write · honours Idempotency-Key

Request
curl -X POST "https://www.viaturquia.com/api/partner/v1/bookings/VT2026K7M2QX/accept" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"note":"Rooms held."}'
Request body
{
    "note": "Rooms held."
}
  • The booking becomes confirmed and Via Turquia may now ask the customer for the deposit. The customer is not mailed by this call.
  • Only a pending booking can be accepted: anything else is 409 booking_not_pending. Two simultaneous accepts are safe - one wins, the other gets the 409.
  • note is optional, plain text, 500 characters.
Response 200 - ok - the booking as it now stands, plus action, message and ignored (94 lines)
{
    "booking_ref": "VT2026K7M2QX",
    "booking_id": 8841,
    "status": "confirmed",
    "source": "web",
    "tour": {
        "tour_id": 1301,
        "group_id": 1301,
        "language": "en",
        "title": "Cappadocia 3 Days",
        "slug": "cappadocia-3-days"
    },
    "booking_date": "2026-11-14",
    "travellers": {
        "adults": 2,
        "children": 0,
        "infants": 0,
        "total": 2
    },
    "rooms": [
        {
            "room_type_id": 1,
            "room_type": "double",
            "room_type_name": "Double",
            "capacity": 2,
            "hotel_category_id": 2,
            "hotel_category": "B",
            "hotel_category_name": "4 stars",
            "season_id": 1,
            "season": "highest",
            "season_name": "Highest season",
            "occupancy": 2,
            "travellers": {
                "adults": 2,
                "children": 0,
                "infants": 0
            },
            "price_per_person": 520,
            "room_subtotal": 1040
        }
    ],
    "room_summary": {
        "season_id": 1,
        "season_name": "Highest season",
        "hotel_category_id": 2,
        "hotel_category_name": "4 stars",
        "room_type_id": 1,
        "room_type_name": "Double"
    },
    "supplements": {
        "info": "Hot-air balloon x2",
        "total": 300
    },
    "special_requests": "Vegetarian meals, please.",
    "hotel": {
        "name": "Cave Hotel Goreme",
        "address": "Goreme, Nevsehir"
    },
    "pickup_time": "08:30:00",
    "customer": {
        "name": "Ana García",
        "email": "ana@example.com",
        "phone": "+34600111222",
        "country": "Spain"
    },
    "amounts": {
        "currency": "EUR",
        "price_per_person": 520,
        "discount": 0,
        "promo_code": null,
        "total": 1340,
        "commission_pct": 15,
        "commission": 201,
        "net": 1139,
        "currency_shown": "EUR"
    },
    "payment": {
        "deposit_status": "pending",
        "deposit_amount": 268,
        "deposit_paid_at": null,
        "deposit_method": null,
        "payment_status": "pending"
    },
    "partner_answer": {
        "action": "accepted",
        "at": "2026-10-22T10:20:05+03:00",
        "note": "Rooms held."
    },
    "created_at": "2026-10-22T09:12:30+03:00",
    "updated_at": "2026-10-22T10:20:05+03:00",
    "action": "accepted",
    "message": "The booking is confirmed. Via Turquia will ask the customer for the deposit.",
    "ignored": []
}
Response 409 - booking_not_pending
{
    "error": {
        "type": "conflict",
        "code": "booking_not_pending",
        "message": "Only a booking that is still pending can be accepted. This one is confirmed."
    },
    "request_id": "req_a41b7c90d2"
}

POST /bookings/{ref}/decline

Decline a pending or confirmed booking.

Scope: bookings:write · honours Idempotency-Key

Request
curl -X POST "https://www.viaturquia.com/api/partner/v1/bookings/VT2026K7M2QX/decline" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"reason":"No rooms on that date."}'
Request body
{
    "reason": "No rooms on that date."
}
  • The booking is cancelled and the customer is emailed the cancellation automatically, in their own language. customer_notified is false when that email could not be delivered (a blocked address, a failed send) - then reach the customer another way.
  • Declining works only while the deposit has not been paid. Once it has, the answer is 409 deposit_already_paid and Via Turquia is told: money has changed hands, so only we can cancel and refund.
  • reason is optional, plain text, 500 characters; it is stored with the booking.
Response 200 - ok - the booking as it now stands, plus action, message, customer_notified and ignored (95 lines)
{
    "booking_ref": "VT2026K7M2QX",
    "booking_id": 8841,
    "status": "cancelled",
    "source": "web",
    "tour": {
        "tour_id": 1301,
        "group_id": 1301,
        "language": "en",
        "title": "Cappadocia 3 Days",
        "slug": "cappadocia-3-days"
    },
    "booking_date": "2026-11-14",
    "travellers": {
        "adults": 2,
        "children": 0,
        "infants": 0,
        "total": 2
    },
    "rooms": [
        {
            "room_type_id": 1,
            "room_type": "double",
            "room_type_name": "Double",
            "capacity": 2,
            "hotel_category_id": 2,
            "hotel_category": "B",
            "hotel_category_name": "4 stars",
            "season_id": 1,
            "season": "highest",
            "season_name": "Highest season",
            "occupancy": 2,
            "travellers": {
                "adults": 2,
                "children": 0,
                "infants": 0
            },
            "price_per_person": 520,
            "room_subtotal": 1040
        }
    ],
    "room_summary": {
        "season_id": 1,
        "season_name": "Highest season",
        "hotel_category_id": 2,
        "hotel_category_name": "4 stars",
        "room_type_id": 1,
        "room_type_name": "Double"
    },
    "supplements": {
        "info": "Hot-air balloon x2",
        "total": 300
    },
    "special_requests": "Vegetarian meals, please.",
    "hotel": {
        "name": "Cave Hotel Goreme",
        "address": "Goreme, Nevsehir"
    },
    "pickup_time": "08:30:00",
    "customer": {
        "name": "Ana García",
        "email": "ana@example.com",
        "phone": "+34600111222",
        "country": "Spain"
    },
    "amounts": {
        "currency": "EUR",
        "price_per_person": 520,
        "discount": 0,
        "promo_code": null,
        "total": 1340,
        "commission_pct": 15,
        "commission": 0,
        "net": 0,
        "currency_shown": "EUR"
    },
    "payment": {
        "deposit_status": "pending",
        "deposit_amount": 268,
        "deposit_paid_at": null,
        "deposit_method": null,
        "payment_status": "pending"
    },
    "partner_answer": {
        "action": "declined",
        "at": "2026-10-22T10:20:05+03:00",
        "note": "No rooms on that date."
    },
    "created_at": "2026-10-22T09:12:30+03:00",
    "updated_at": "2026-10-22T10:20:05+03:00",
    "action": "declined",
    "message": "The booking is cancelled and the customer has been told by email.",
    "customer_notified": true,
    "ignored": []
}

409 booking_not_cancellable, deposit_already_paid

Webhook

GET /webhook

Your webhook registration and how delivery is going.

Scope: any one of webhooks:write or account:read

Request
curl "https://www.viaturquia.com/api/partner/v1/webhook" \
  -H "Authorization: Bearer $VT_API_KEY"
  • webhook is null until you register one. The signing secret is never shown here - only secret_set. If you lost it, rotate it.
  • deliveries counts every delivery you have by state. fail_streak is the number of failed attempts in a row; at 20 the webhook is switched off.
  • A key with account:read can read the registration and the delivery log without being able to change it.
Response 200 - ok (47 lines)
{
    "webhook": {
        "url": "https://anatolia-trails.example/hooks/viaturquia",
        "events": [
            "booking.created",
            "booking.confirmed",
            "booking.cancelled",
            "booking.deposit_paid",
            "booking.updated",
            "tour.approved",
            "tour.unpublished"
        ],
        "is_active": true,
        "fail_streak": 0,
        "secret_set": true,
        "created_at": "2026-10-21T11:00:00+03:00",
        "updated_at": "2026-10-21T11:00:00+03:00"
    },
    "available_events": [
        "booking.created",
        "booking.confirmed",
        "booking.cancelled",
        "booking.deposit_paid",
        "booking.updated",
        "tour.approved",
        "tour.unpublished",
        "ping"
    ],
    "last_delivery": {
        "delivery_id": 311,
        "event_id": "evt_4be1f09a77c3d2a05c18",
        "event_type": "booking.created",
        "status": "delivered",
        "attempts": 1,
        "max_attempts": 6,
        "next_attempt_at": null,
        "last_status": 200,
        "last_error": null,
        "created_at": "2026-10-22T09:12:31+03:00",
        "delivered_at": "2026-10-22T09:13:02+03:00"
    },
    "deliveries": {
        "pending": 0,
        "delivered": 14,
        "failed": 1
    }
}

PUT /webhook

Register or change your webhook: the address, the events, on or off.

Scope: webhooks:write

Request
curl -X PUT "https://www.viaturquia.com/api/partner/v1/webhook" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://anatolia-trails.example/hooks/viaturquia","events":["booking.created","booking.confirmed","booking.cancelled"],"is_active":true}'
Request body
{
    "url": "https://anatolia-trails.example/hooks/viaturquia",
    "events": [
        "booking.created",
        "booking.confirmed",
        "booking.cancelled"
    ],
    "is_active": true
}
  • url is required, https only, no user name or password in it, and the host must resolve to a public address (never a private or local network). The address is checked again at every send.
  • events is a list of names from available_events. Left out on the first registration it subscribes you to every event except ping; left out later, it keeps what you have. ping is only ever sent when you ask for it with POST /webhook/test.
  • The first PUT creates the signing secret and is the only answer, besides rotate_secret, that carries it. Copy it now: it is not shown again.
  • A PUT that leaves the webhook active clears the failure streak. After the automatic shut-off, this is how you say "I have fixed my endpoint".
Response 200 - ok - what GET /webhook answers, plus secret on the first registration and ignored (49 lines)
{
    "webhook": {
        "url": "https://anatolia-trails.example/hooks/viaturquia",
        "events": [
            "booking.created",
            "booking.confirmed",
            "booking.cancelled",
            "booking.deposit_paid",
            "booking.updated",
            "tour.approved",
            "tour.unpublished"
        ],
        "is_active": true,
        "fail_streak": 0,
        "secret_set": true,
        "created_at": "2026-10-21T11:00:00+03:00",
        "updated_at": "2026-10-21T11:00:00+03:00"
    },
    "available_events": [
        "booking.created",
        "booking.confirmed",
        "booking.cancelled",
        "booking.deposit_paid",
        "booking.updated",
        "tour.approved",
        "tour.unpublished",
        "ping"
    ],
    "last_delivery": {
        "delivery_id": 311,
        "event_id": "evt_4be1f09a77c3d2a05c18",
        "event_type": "booking.created",
        "status": "delivered",
        "attempts": 1,
        "max_attempts": 6,
        "next_attempt_at": null,
        "last_status": 200,
        "last_error": null,
        "created_at": "2026-10-22T09:12:31+03:00",
        "delivered_at": "2026-10-22T09:13:02+03:00"
    },
    "deliveries": {
        "pending": 0,
        "delivered": 14,
        "failed": 1
    },
    "secret": "whsec_Q7mX2dT9pLk4vN8cR1sZ6bYh3WaE5uJf0GoIxA9tKeD",
    "ignored": []
}

422 url_required, invalid_url, unsafe_url, unknown_event, invalid_events

POST /webhook/rotate_secret

Create a new signing secret.

Scope: webhooks:write · honours Idempotency-Key

Request
curl -X POST "https://www.viaturquia.com/api/partner/v1/webhook/rotate_secret" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
  • The old secret stops working the moment this answers. Deploy the new one to your receiver first if you can, because events signed after this call use it.
  • Shown once. 404 webhook_not_configured if you have no webhook yet.
Response 200 - ok (49 lines)
{
    "webhook": {
        "url": "https://anatolia-trails.example/hooks/viaturquia",
        "events": [
            "booking.created",
            "booking.confirmed",
            "booking.cancelled",
            "booking.deposit_paid",
            "booking.updated",
            "tour.approved",
            "tour.unpublished"
        ],
        "is_active": true,
        "fail_streak": 0,
        "secret_set": true,
        "created_at": "2026-10-21T11:00:00+03:00",
        "updated_at": "2026-10-21T11:00:00+03:00"
    },
    "available_events": [
        "booking.created",
        "booking.confirmed",
        "booking.cancelled",
        "booking.deposit_paid",
        "booking.updated",
        "tour.approved",
        "tour.unpublished",
        "ping"
    ],
    "last_delivery": {
        "delivery_id": 311,
        "event_id": "evt_4be1f09a77c3d2a05c18",
        "event_type": "booking.created",
        "status": "delivered",
        "attempts": 1,
        "max_attempts": 6,
        "next_attempt_at": null,
        "last_status": 200,
        "last_error": null,
        "created_at": "2026-10-22T09:12:31+03:00",
        "delivered_at": "2026-10-22T09:13:02+03:00"
    },
    "deliveries": {
        "pending": 0,
        "delivered": 14,
        "failed": 1
    },
    "secret": "whsec_n4B8kV2zQxT7pLd1sWm9RhYc6AeU3uJf5GoIxC0tKeH",
    "ignored": []
}

404 webhook_not_configured

POST /webhook/test

Queue a ping event to your webhook.

Scope: webhooks:write · honours Idempotency-Key

Request
curl -X POST "https://www.viaturquia.com/api/partner/v1/webhook/test" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
  • Answers 202 Accepted, not 200: the event is queued, not sent. It goes out within a minute; read its outcome from GET /webhook/deliveries.
  • This, the test button on your partner panel and /webhook/deliveries/{id}/retry share one allowance of 10 an hour per partner (not per key). A test event counts whichever of the two doors queued it; every POST to the retry path counts whatever the answer, including a 403 for a missing scope and a 429. Over the allowance: 429 with Retry-After.
  • 409 webhook_inactive if the webhook is switched off; 404 webhook_not_configured if you have none.
Response 202 - queued
{
    "data": [
        {
            "delivery_id": 312,
            "event_id": "evt_c07d6e25b19f8a3340e7",
            "event_type": "ping",
            "status": "pending",
            "attempts": 0,
            "max_attempts": 6,
            "next_attempt_at": "2026-10-22T10:21:00+03:00",
            "last_status": null,
            "last_error": null,
            "created_at": "2026-10-22T10:21:00+03:00",
            "delivered_at": null
        }
    ],
    "pagination": {
        "page": 1,
        "per_page": 1,
        "total": 1,
        "has_more": false
    },
    "ignored": []
}

409 webhook_inactive

429 rate_limit_exceeded

GET /webhook/deliveries

Recent deliveries, newest first, with their state and what your endpoint answered. Deliveries are kept for 90 days.

Scope: any one of webhooks:write or account:read

Query parameters
NameTypeMeaning
pageintegerPage number, from 1.
per_pageintegerRows per page. Default 50, at most 100.
statusstringpending, delivered or failed.
Request
curl "https://www.viaturquia.com/api/partner/v1/webhook/deliveries?page=1&per_page=50" \
  -H "Authorization: Bearer $VT_API_KEY"
  • last_error holds the first 500 characters of what your endpoint answered, or the reason nothing did. It is empty on a delivered row.
  • attempts counts tries so far out of max_attempts. A pending row carries next_attempt_at.
  • Readable with webhooks:write or account:read.
Response 200 - ok (49 lines)
{
    "data": [
        {
            "delivery_id": 312,
            "event_id": "evt_c07d6e25b19f8a3340e7",
            "event_type": "ping",
            "status": "pending",
            "attempts": 0,
            "max_attempts": 6,
            "next_attempt_at": "2026-10-22T10:21:00+03:00",
            "last_status": null,
            "last_error": null,
            "created_at": "2026-10-22T10:21:00+03:00",
            "delivered_at": null
        },
        {
            "delivery_id": 311,
            "event_id": "evt_4be1f09a77c3d2a05c18",
            "event_type": "booking.created",
            "status": "delivered",
            "attempts": 1,
            "max_attempts": 6,
            "next_attempt_at": null,
            "last_status": 200,
            "last_error": null,
            "created_at": "2026-10-22T09:12:31+03:00",
            "delivered_at": "2026-10-22T09:13:02+03:00"
        },
        {
            "delivery_id": 305,
            "event_id": "evt_91d0a3c6e84b7f21a9d3",
            "event_type": "booking.confirmed",
            "status": "failed",
            "attempts": 6,
            "max_attempts": 6,
            "next_attempt_at": null,
            "last_status": 503,
            "last_error": "The endpoint answered 503.",
            "created_at": "2026-10-21T17:40:10+03:00",
            "delivered_at": null
        }
    ],
    "pagination": {
        "page": 1,
        "per_page": 50,
        "total": 3,
        "has_more": false
    }
}

POST /webhook/deliveries/{id}/retry

Send a failed delivery again.

Scope: webhooks:write · honours Idempotency-Key

Request
curl -X POST "https://www.viaturquia.com/api/partner/v1/webhook/deliveries/305/retry" \
  -H "Authorization: Bearer $VT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
  • Acts only on a delivery whose status is failed - one that has given up. A delivered row is 409 already_delivered and a still-queued one 409 delivery_not_failed (it will be tried on its own).
  • The schedule starts over (a fresh run of 6 attempts), the event id does not change. Answers 202: it is queued, not sent.
  • Shares the 10-an-hour allowance with /webhook/test (see there). Needs an active webhook: 409 webhook_inactive otherwise.
Response 202 - queued
{
    "data": [
        {
            "delivery_id": 305,
            "event_id": "evt_91d0a3c6e84b7f21a9d3",
            "event_type": "booking.confirmed",
            "status": "pending",
            "attempts": 0,
            "max_attempts": 6,
            "next_attempt_at": "2026-10-22T10:25:00+03:00",
            "last_status": 503,
            "last_error": "The endpoint answered 503.",
            "created_at": "2026-10-21T17:40:10+03:00",
            "delivered_at": null
        }
    ],
    "pagination": {
        "page": 1,
        "per_page": 1,
        "total": 1,
        "has_more": false
    },
    "ignored": []
}

409 delivery_not_failed, already_delivered, webhook_inactive

404 delivery_not_found

5. Tours, precios y la matriz de habitaciones

One tour, up to four rows

A tour is sold in up to four languages (en, es, pt, ar). Each language version is its own tour with its own tour_id, its own text and photos; the versions of one product share a group_id. Create the first with POST /tours (language defaults to es if you leave it out), then open the others with POST /tours/{id}/translations, which copies the structure and leaves the texts empty for you to write. A tour's language never changes after it is created, and the slug (its address) is made by us, always in plain ASCII and unique.

Which fields are shared by all languages

Some things belong to the product, not to a translation. Writing one of them to any language version writes it to every version of the tour, so one language can never quote a price another has moved on from. In particular: editing the price of the Spanish tour changes it for the English, Portuguese and Arabic tours too.

Written to every language version of the tourWritten to the one tour you address
  • pricing.price
  • pricing.original_price
  • pricing.child_discount_pct
  • pricing.infant_discount_pct
  • includes.hotel
  • duration.days
  • duration.nights
  • group.max_size
  • group.min_participants
  • group.max_participants
  • cancellation.free_hours
  • reference_url
  • the 27-cell price matrix (PUT /tours/{id}/pricing)
  • everything textual: title, short_description, description, highlights, important_info, meeting_point, ending_point, starting_time, duration.details, cancellation.policy, inclusions, exclusions, itinerary, faqs
  • supplements (names, prices, active flags)
  • includes.guide, meals, transfers, tickets
  • group.type, age.*, difficulty_level
  • media (main image, gallery, video) and the images you add
  • taxonomy (category, destinations, experience types, guide languages)

A write only reaches versions that are yours.

Review: why a new tour is not on the site yet

A tour you create is born unpublished: status is awaiting_review and is_active is false. Via Turquia reviews it and publishes it (you cannot, and is_active in a request is ignored). When we do, you receive a tour.approved webhook and status becomes published. After that, your later changes go live immediately — there is no second review — and each is recorded in your audit trail. If we take a tour off the site you receive tour.unpublished.

The tour body, field by field

The same object comes back from GET /tours/{id}; POST and PATCH accept it, field for field. PATCH leaves alone every field you do not send; an object you send (say duration) is merged key by key, and a list you send is replaced as a whole.

FieldTypeNotes
languagestringen, es, pt or ar. Create only; defaults to es. Ignored afterwards.
titlestring, 255Required on create. Plain text.
short_description, important_info, meeting_point, ending_pointstring, 5000Plain text: HTML tags are removed.
starting_timestring, 100Free text, e.g. 09:00.
descriptionHTMLSimple formatting survives: p br strong em b i ul ol li h3 h4, with all attributes removed. Links, images, scripts and every other tag are stripped.
highlightslist of stringsUp to 60, 300 characters each. Stored as bullet lines.
inclusions, exclusionslist of stringsUp to 60, 500 characters each.
duration.days / nights / detailsint, int, stringDays at least 1. Nights are forced to 0 while includes.hotel is false; a non-zero value you send is dropped and reported as duration.nights in ignored. Days and nights are shared.
group.typestring, 100A code from GET /reference (private, small_group, group), stored in the tour's own language; any other text is kept as you wrote it.
group.max_size / min_participants / max_participantsint or nullShared by all languages.
age.min_age / range / child_label / infant_labelint, string, string, stringrange takes a code from /reference (all_ages, family_friendly, adults_only) or your own text. Labels are at most 20 characters, e.g. 6-11.
difficulty_levelstringeasy, moderate, challenging or difficult; stored in the tour's own language. null leaves the stored value alone. difficulty_label (the stored text) is read-only.
includes.hotelbooleanThe switch that decides how the tour is priced — see below. Shared by all languages.
includes.guide / meals / transfers / ticketsbooleanWhat the price includes.
itinerarylist of objectsUp to 60 steps of {time, title, description}; the description keeps simple formatting.
faqslist of objectsUp to 40 of {question, answer}. Per language version.
supplementslist of objectsUp to 40 optional extras: {name, description, price, per_person, active}. per_person and active default to true. Sending the list replaces every supplement row, inactive ones included, so leaving one out deletes it — always send back the whole list you read. name is required for an active supplement; an inactive one may wait for its name. A translation's supplements arrive inactive and unnamed.
pricing.pricenumber, EURPer person. For a tour priced by the matrix it is derived from the cheapest cell and ignored if you send it (alone, that is 422 no_updatable_fields). Clamped to 0–99,999,999.99.
pricing.original_pricenumber or nullA "was" price shown struck through.
pricing.child_discount_pct / infant_discount_pctnumber 0-100A child pays price × (1 − child_discount_pct/100); 100 for infants means free.
pricing.mode, hotel_included, matrix_cells, matrix_complete, matrixread onlyDerived. Ignored in a request; see PUT /tours/{id}/pricing.
cancellation.policy / free_hoursHTML, int or nullPolicy keeps simple formatting. free_hours (0–65535) is shared.
media.main_imagestring or nullAn address on Via Turquia. An address on another host is refused with media_url_not_on_site: bring the image in with POST /tours/{id}/images and use the address it returns. Empty or null clears it.
media.gallerylist of stringsUp to 40 addresses on Via Turquia. POST/DELETE /tours/{id}/images add and remove one at a time.
media.video_urlstring or nullAn http(s) address of a video page.
taxonomy.category_idint or nullThe primary category, from GET /reference. It is null when the tour has none — it is not guessed from the list below.
taxonomy.categorieslist of intEvery category the tour is listed in. Sending it replaces the list; sending only category_id sets the primary and leaves the list alone. On create, either one fills the other.
taxonomy.destinations / experience_typeslist of intIds from GET /reference. An id the tour already carries is always accepted, even if it has since been switched off.
taxonomy.guide_languageslist of stringLanguage codes from /reference (en es pt ar fr de it ru tr ja zh ko).
reference_urlstring or nullYour own page for this tour, where its meeting point, times and route can be checked. Shared; never shown to customers. A value that is not an http(s) address is refused with the reason.
is_active, is_featured, sort_order, badge_type, partner_id, tour_id, slug, created_at, updated_at, difficulty_label, status, group_idread onlyNever writable. Sent in a request they are dropped and listed in ignored.

Text is cut to its limit rather than refused; numbers are clamped into range. What is wrong rather than merely too long (a missing title, an unknown category id, a malformed address) is a 422 that lists every such field.

Complete tour object (122 lines)
{
    "tour_id": 1207,
    "group_id": 1207,
    "language": "es",
    "slug": "estambul-clasico",
    "status": "awaiting_review",
    "is_active": false,
    "title": "Estambul Clásico",
    "short_description": "Un día completo entre Santa Sofía, la Mezquita Azul y el Bósforo.",
    "description": "<p>Descubre el corazón histórico de Estambul con un guía local.</p>",
    "highlights": [
        "Santa Sofía",
        "Mezquita Azul",
        "Crucero por el Bósforo"
    ],
    "important_info": "Lleve calzado cómodo y hombros cubiertos para las mezquitas.",
    "meeting_point": "Vestíbulo de su hotel en Sultanahmet",
    "ending_point": "Gran Bazar",
    "starting_time": "09:00",
    "duration": {
        "days": 1,
        "nights": 0,
        "details": "8 horas"
    },
    "group": {
        "type": "Grupo pequeño",
        "max_size": 16,
        "min_participants": 2,
        "max_participants": 16
    },
    "age": {
        "min_age": 6,
        "range": "Todas las edades",
        "child_label": "6-11",
        "infant_label": "0-5"
    },
    "difficulty_level": "easy",
    "difficulty_label": "Fácil",
    "includes": {
        "hotel": false,
        "guide": true,
        "meals": true,
        "transfers": true,
        "tickets": true
    },
    "inclusions": [
        "Guía en español",
        "Entradas",
        "Almuerzo"
    ],
    "exclusions": [
        "Bebidas",
        "Propinas"
    ],
    "itinerary": [
        {
            "time": "09:00",
            "title": "Recogida en el hotel",
            "description": "<p>Salida desde Sultanahmet.</p>"
        },
        {
            "time": "09:30",
            "title": "Santa Sofía",
            "description": "<p>Visita guiada de una hora.</p>"
        }
    ],
    "faqs": [
        {
            "question": "¿Hay que reservar con antelación?",
            "answer": "Sí, con 24 horas."
        }
    ],
    "supplements": [
        {
            "name": "Cena en el Bósforo",
            "description": null,
            "price": 25,
            "per_person": true,
            "active": true
        }
    ],
    "pricing": {
        "mode": "flat",
        "hotel_included": false,
        "matrix_cells": 0,
        "matrix_complete": false,
        "price": 85,
        "original_price": 110,
        "child_discount_pct": 30,
        "infant_discount_pct": 100
    },
    "cancellation": {
        "policy": "<p>Cancelación gratuita hasta 48 horas antes.</p>",
        "free_hours": 48
    },
    "media": {
        "main_image": "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg",
        "gallery": [
            "https://www.viaturquia.com/uploads/tours/3f9c1e0a7b52d4c8.jpg"
        ],
        "video_url": null
    },
    "taxonomy": {
        "category_id": 3,
        "categories": [
            3
        ],
        "destinations": [
            1
        ],
        "experience_types": [
            2
        ],
        "guide_languages": [
            "es",
            "en"
        ]
    },
    "reference_url": "https://anatolia-trails.example/tour/123",
    "created_at": "2026-10-22T09:58:12+03:00",
    "updated_at": "2026-10-22T10:02:40+03:00"
}

Two kinds of tour: priced by traveller, or by room

includes.hotel is the single switch. It is shared by all the languages of a tour, and it decides everything about pricing:

<code>includes.hotel</code> = false (day tour)<code>includes.hotel</code> = true (hotel nights)
Priced byOne price per person: pricing.price, written with PUT /tours/{id}/pricing {"price": N} or with PATCH.A matrix of 27 per-person prices: 3 seasons × hotel class A/B/C × room type double/triple/individual.
The customer choosesHow many adults, children and infants.Hotel class, and the rooms.
NightsAlways 0.As many as the tour has.
Wrong callPUT with a matrix is 409 matrix_on_day_tour.PUT with a price is 409 flat_price_on_hotel_tour.

The usual path for a tour with hotel nights: POST /tours with "includes": {"hotel": true} and the nights, then PUT /tours/{id}/pricing with all 27 cells. Until the matrix is complete pricing.matrix_complete is false — finish it before the tour is published, because a room combination without a price cannot be booked.

Turning includes.hotel off on a tour that still has matrix cells is 422 hotel_switch_has_pricing, because a day-tour page asking only for travellers would then meet a price list that demands rooms. There is no call that empties a matrix; if a tour with a matrix must become a day tour, write to us.

The 27-cell matrix

GET /reference lists the seasons (season_id, their names and date ranges), the hotel classes and the room types. The seasons are Via Turquia's calendar — at the moment three, together covering every day of the year — and the season that applies to a booking is decided by its travel date (a stay that crosses a season boundary is priced pro rata by the days in each). You price every combination:

Three of the 27 cells
{
    "matrix": [
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "double",
            "price": 640
        },
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "triple",
            "price": 610
        },
        {
            "season_id": 1,
            "hotel_category": "A",
            "room_type": "individual",
            "price": 840
        }
    ]
}
  • All 27 cells are required and each must be above 0. A missing cell is 422 matrix_cell_missing, and the message lists every one that is missing; a repeated one is duplicate_matrix_cell.
  • Prices are per person, in EUR. A room's subtotal is the sum over its travellers, with children and infants discounted as above.
  • They are written to every language version of the tour (that includes hotel nights).
  • pricing.price — the "from" price shown on the site — is set by us to the cheapest cell whenever you write the matrix. You do not set it, and a request that only tries (PATCH {"pricing": {"price": 90}}) is 422 no_updatable_fields; next to other fields it is simply listed in ignored.

The room-fill rule

On a tour priced by the matrix, rooms are sold whole, and this is the single most common reason a customer's booking is refused at checkout, so design your prices around it:

  • The paying travellers in a room (adults + children) must equal the room's capacity: a double takes exactly 2, a triple exactly 3, and a traveller on their own takes an individual room.
  • Infants do not take a bed and do not count toward the capacity; there can be at most one infant per adult.
  • So two travellers book one double; three book a triple, or a double plus an individual; one traveller cannot book a double at the double's per-person price.

That is why every cell must be priced — any combination a customer may legally choose has to have a price. The rule applies only to tours that have matrix cells; a day tour has no rooms at all. GET /reference gives each room type's capacity.

Photos

Upload with multipart/form-data (part file), or send {"url": "https://..."} and our server downloads it. JPEG, PNG, GIF and WebP are accepted; HEIC/HEIF (the default iPhone format) is refused with heic_not_supported — export as JPEG. Uploads may be up to 12 MB, downloads up to 5 MB, at most 50 megapixels; the EXIF rotation is applied and the longest edge is scaled down to 1920 px. A fetched address must be https, must resolve to a public address, may not redirect, and has 10 seconds. The answer carries the stored address: put it in media.main_image with a PATCH to make it the main picture. The image joins the gallery of the one language version you addressed.

Example: a complete tour

POST /tours (71 lines)
{
    "language": "es",
    "title": "Estambul Clásico",
    "short_description": "Un día completo entre Santa Sofía, la Mezquita Azul y el Bósforo.",
    "description": "<p>Descubre el corazón histórico de Estambul con un guía local.</p>",
    "highlights": [
        "Santa Sofía",
        "Mezquita Azul"
    ],
    "starting_time": "09:00",
    "meeting_point": "Vestíbulo de su hotel en Sultanahmet",
    "duration": {
        "days": 1,
        "details": "8 horas"
    },
    "group": {
        "type": "small_group",
        "max_size": 16,
        "min_participants": 2
    },
    "difficulty_level": "easy",
    "includes": {
        "hotel": false,
        "guide": true,
        "meals": true,
        "transfers": true,
        "tickets": true
    },
    "inclusions": [
        "Guía en español",
        "Entradas"
    ],
    "itinerary": [
        {
            "time": "09:00",
            "title": "Recogida en el hotel",
            "description": "<p>Salida desde Sultanahmet.</p>"
        }
    ],
    "supplements": [
        {
            "name": "Cena en el Bósforo",
            "price": 25,
            "per_person": true
        }
    ],
    "pricing": {
        "price": 85,
        "original_price": 110,
        "child_discount_pct": 30,
        "infant_discount_pct": 100
    },
    "cancellation": {
        "policy": "<p>Cancelación gratuita hasta 48 horas antes.</p>",
        "free_hours": 48
    },
    "taxonomy": {
        "category_id": 3,
        "destinations": [
            1
        ],
        "experience_types": [
            2
        ],
        "guide_languages": [
            "es",
            "en"
        ]
    },
    "reference_url": "https://anatolia-trails.example/tour/123"
}

Then, to sell it in English:

curl -X POST "https://www.viaturquia.com/api/partner/v1/tours/1207/translations" -H "Authorization: Bearer $VT_API_KEY" -H "Content-Type: application/json" -d '{"language": "en"}'

The answer is the new, unpublished English tour, with needs listing what you must still write — including a name for each supplement, which stays inactive and unsold until you PATCH supplements with the name and "active": true.

6. Ciclo de vida de una reserva

customer books on viaturquia.com (or through the chat assistant)
   |  status = pending, deposit_status = pending
   |-->  email to you (as before)  +  webhook booking.created
   v
you answer: API (POST /bookings/{ref}/accept|decline) or the partner panel
   |
   |-- accept  -> status = confirmed, partner_answer.action = accepted
   |             webhook booking.confirmed
   |             Via Turquia now asks the customer for the deposit
   |-- decline -> status = cancelled, partner_answer.action = declined
   |             the customer is emailed the cancellation automatically
   |             webhook booking.cancelled
   v
deposit paid -> payment.deposit_status = paid, webhook booking.deposit_paid

Rules

  • Nobody is asked for a deposit until you accept. A booking arrives pending. Accepting it is what starts the money. Accepting does not email the customer; Via Turquia sends the deposit request on its own schedule.
  • Accept works only on a pending booking: otherwise 409 booking_not_pending. Two simultaneous accepts are safe — one wins, the other receives the 409.
  • Decline works on a pending or confirmed booking whose deposit has not been paid. It cancels the booking and emails the customer the cancellation, in the language they booked in. customer_notified in the answer is false if that email could not be delivered (a blocked address, a failed send) — then contact the customer yourself.
  • Once the customer has paid the deposit, a decline from you is 409 deposit_already_paid: money has changed hands, so only Via Turquia can cancel and refund. We are told that you tried, so you are not silently ignored; write to us and we will arrange it.
  • Your answer stays visible. partner_answer (action, at, note) keeps what you answered even if Via Turquia later changes the status.
  • The team can also confirm or cancel a booking from our own admin. You are told through the same events — the actor field says who did it.
  • You cannot change a booking's dates, travellers or price. booking.updated tells you when we do.

What a booking contains

The customer's name, email and phone are included, together with the travel date, travellers, rooms, extras, hotel and pick-up time. They are the same details your new-booking email has always carried, handed to your software instead of your inbox. amounts gives the total, commission_pct, commission and net in EUR (zero commission and net once cancelled); payment tells you whether the deposit has arrived.

A booking (91 lines)
{
    "booking_ref": "VT2026K7M2QX",
    "booking_id": 8841,
    "status": "pending",
    "source": "web",
    "tour": {
        "tour_id": 1301,
        "group_id": 1301,
        "language": "en",
        "title": "Cappadocia 3 Days",
        "slug": "cappadocia-3-days"
    },
    "booking_date": "2026-11-14",
    "travellers": {
        "adults": 2,
        "children": 0,
        "infants": 0,
        "total": 2
    },
    "rooms": [
        {
            "room_type_id": 1,
            "room_type": "double",
            "room_type_name": "Double",
            "capacity": 2,
            "hotel_category_id": 2,
            "hotel_category": "B",
            "hotel_category_name": "4 stars",
            "season_id": 1,
            "season": "highest",
            "season_name": "Highest season",
            "occupancy": 2,
            "travellers": {
                "adults": 2,
                "children": 0,
                "infants": 0
            },
            "price_per_person": 520,
            "room_subtotal": 1040
        }
    ],
    "room_summary": {
        "season_id": 1,
        "season_name": "Highest season",
        "hotel_category_id": 2,
        "hotel_category_name": "4 stars",
        "room_type_id": 1,
        "room_type_name": "Double"
    },
    "supplements": {
        "info": "Hot-air balloon x2",
        "total": 300
    },
    "special_requests": "Vegetarian meals, please.",
    "hotel": {
        "name": "Cave Hotel Goreme",
        "address": "Goreme, Nevsehir"
    },
    "pickup_time": "08:30:00",
    "customer": {
        "name": "Ana García",
        "email": "ana@example.com",
        "phone": "+34600111222",
        "country": "Spain"
    },
    "amounts": {
        "currency": "EUR",
        "price_per_person": 520,
        "discount": 0,
        "promo_code": null,
        "total": 1340,
        "commission_pct": 15,
        "commission": 201,
        "net": 1139,
        "currency_shown": "EUR"
    },
    "payment": {
        "deposit_status": "pending",
        "deposit_amount": 268,
        "deposit_paid_at": null,
        "deposit_method": null,
        "payment_status": "pending"
    },
    "partner_answer": {
        "action": "none",
        "at": null,
        "note": null
    },
    "created_at": "2026-10-22T09:12:30+03:00",
    "updated_at": "2026-10-22T09:12:30+03:00"
}

7. Webhooks

Register one https address and choose the events you want. We POST each event to it as it happens. Events are queued and sent by a background job within about a minute, so a slow server on your side never delays a customer.

Registering

curl -X PUT "https://www.viaturquia.com/api/partner/v1/webhook" \
  -H "Authorization: Bearer $VT_API_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/viaturquia", "events": ["booking.created", "booking.confirmed"]}'

The first registration returns the signing secret (whsec_…) — once. Store it; if you lose it, POST /webhook/rotate_secret issues a new one and the old one stops working at once. Then send POST /webhook/test: a ping event is queued (answer 202) and the delivery log shows what your server answered. The address must be https, carry no user name or password, and resolve to a public address; it is checked again at every send, and redirects are not followed.

Events

EventWhenactordata
booking.createdA customer's booking is saved on one of your tours (website or chat assistant).customera booking
booking.confirmedThe booking becomes confirmed - you accepted it, or Via Turquia confirmed it.partner or admina booking
booking.cancelledThe booking becomes cancelled - you declined it, or Via Turquia cancelled it.partner or admina booking
booking.deposit_paidThe deposit is recorded as paid: the answer to "has the money come?".admin or systema booking
booking.updatedThe travel date, traveller counts, total price, special requests, hotel name or pick-up time changed. Nothing else raises it (not internal notes, not payment fields), so you get three events, not thirty.admin or systema booking
tour.approvedVia Turquia published a tour of yours.admina tour
tour.unpublishedVia Turquia took a published tour off the site.admina tour
pingOnly from POST /webhook/test. Sent even if you did not subscribe to it.partner{message, partner_id, sent_at}

Leave events out on your first registration to receive everything except ping. The new-booking email to you keeps being sent alongside the webhook — a webhook never silences it; ask us if you want it off.

The request we send

Body of a booking.created event (97 lines)
{
    "id": "evt_4be1f09a77c3d2a05c18",
    "type": "booking.created",
    "created_at": "2026-10-22T09:12:31+03:00",
    "data": {
        "booking_ref": "VT2026K7M2QX",
        "booking_id": 8841,
        "status": "pending",
        "source": "web",
        "tour": {
            "tour_id": 1301,
            "group_id": 1301,
            "language": "en",
            "title": "Cappadocia 3 Days",
            "slug": "cappadocia-3-days"
        },
        "booking_date": "2026-11-14",
        "travellers": {
            "adults": 2,
            "children": 0,
            "infants": 0,
            "total": 2
        },
        "rooms": [
            {
                "room_type_id": 1,
                "room_type": "double",
                "room_type_name": "Double",
                "capacity": 2,
                "hotel_category_id": 2,
                "hotel_category": "B",
                "hotel_category_name": "4 stars",
                "season_id": 1,
                "season": "highest",
                "season_name": "Highest season",
                "occupancy": 2,
                "travellers": {
                    "adults": 2,
                    "children": 0,
                    "infants": 0
                },
                "price_per_person": 520,
                "room_subtotal": 1040
            }
        ],
        "room_summary": {
            "season_id": 1,
            "season_name": "Highest season",
            "hotel_category_id": 2,
            "hotel_category_name": "4 stars",
            "room_type_id": 1,
            "room_type_name": "Double"
        },
        "supplements": {
            "info": "Hot-air balloon x2",
            "total": 300
        },
        "special_requests": "Vegetarian meals, please.",
        "hotel": {
            "name": "Cave Hotel Goreme",
            "address": "Goreme, Nevsehir"
        },
        "pickup_time": "08:30:00",
        "customer": {
            "name": "Ana García",
            "email": "ana@example.com",
            "phone": "+34600111222",
            "country": "Spain"
        },
        "amounts": {
            "currency": "EUR",
            "price_per_person": 520,
            "discount": 0,
            "promo_code": null,
            "total": 1340,
            "commission_pct": 15,
            "commission": 201,
            "net": 1139,
            "currency_shown": "EUR"
        },
        "payment": {
            "deposit_status": "pending",
            "deposit_amount": 268,
            "deposit_paid_at": null,
            "deposit_method": null,
            "payment_status": "pending"
        },
        "partner_answer": {
            "action": "none",
            "at": null,
            "note": null
        },
        "created_at": "2026-10-22T09:12:30+03:00",
        "updated_at": "2026-10-22T09:12:30+03:00"
    },
    "actor": "customer"
}
  • id is the event id — the same on every retry and in X-VT-Event-Id. Use it to de-duplicate: delivery is at least once.
  • data is the booking exactly as GET /bookings/{ref} returns it (a tour for tour.*) as it stood when the event was raised.
  • actor is partner, admin, customer or system. Your own accept comes back as booking.confirmed with "actor": "partner" — use it to ignore the echo of what you did yourself.
  • Events can arrive out of order (a retried one is late). Order by data.updated_at, not by arrival.
HeaderValue
Content-Typeapplication/json
User-AgentViaTurquia-Webhook/1.0
X-VT-Event-IdThe event id, e.g. evt_4be1f09a77c3d2a05c18.
X-VT-Event-TypeThe event name.
X-VT-Delivery-Attempt1 for the first try, up to 6.
X-VT-Signaturet=<unix time>,v1=<hex hmac> — see below.

Verifying the signature

Anyone can POST to your address; the signature proves it was us. It is an HMAC-SHA256, keyed with your webhook secret, of the string t + "." + raw request body, where t is the number in the header (the Unix time of this attempt). Rules:

  • Sign over the raw bytes you received. Do not parse and re-serialise the JSON first.
  • Compare in constant time.
  • Reject a t more than 5 minutes from your clock (replay protection). t is re-stamped on every attempt, so a retry hours later still passes; only a recorded request replayed later fails.
PHP
<?php
$secret = getenv('VT_WEBHOOK_SECRET');                    // whsec_...
$body   = file_get_contents('php://input');               // raw bytes, untouched
$header = $_SERVER['HTTP_X_VT_SIGNATURE'] ?? '';

if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) { http_response_code(400); exit; }
[, $t, $signature] = $m;

if (abs(time() - (int) $t) > 300) { http_response_code(400); exit; }          // too old or too new

$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
if (!hash_equals($expected, $signature)) { http_response_code(401); exit; }

$event = json_decode($body, true);
// 1. If $event['id'] was already processed, answer 200 and stop.  2. Otherwise store it and handle it.
http_response_code(200);
Node.js
const crypto  = require('crypto');
const express = require('express');
const app = express();

// express.raw() keeps the body as the exact bytes we signed.
app.post('/hooks/viaturquia', express.raw({ type: 'application/json' }), (req, res) => {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(req.get('X-VT-Signature') || '');
  if (!m) return res.sendStatus(400);
  const [, t, signature] = m;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);

  const expected = crypto.createHmac('sha256', process.env.VT_WEBHOOK_SECRET)
    .update(t + '.').update(req.body).digest('hex');
  const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // De-duplicate on event.id, then handle it.
  res.sendStatus(200);
});
Python (Flask)
import hashlib, hmac, os, re, time
from flask import Flask, abort, request

app = Flask(__name__)

@app.post("/hooks/viaturquia")
def viaturquia():
    body = request.get_data()                      # raw bytes, untouched
    m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", request.headers.get("X-VT-Signature", ""))
    if not m:
        abort(400)
    t, signature = m.groups()
    if abs(time.time() - int(t)) > 300:
        abort(400)

    expected = hmac.new(os.environ["VT_WEBHOOK_SECRET"].encode(),
                        t.encode() + b"." + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature):
        abort(401)

    event = request.get_json(force=True)
    # De-duplicate on event["id"], then handle it.
    return "", 200

Delivery, retries and shut-off

  • Answer with any 2xx within 10 seconds (5 to connect). Anything else — a 4xx, a 5xx, a timeout, a refused connection, a redirect (redirects are not followed, register the final address) — is a failure and is retried.
  • Schedule: one delivery and five retries, waiting 1 m, 5 m, 30 m, 2 h, 6 h after successive failures — 6 attempts in all, spread over 8 h 36 m from the first try to the last. The job runs once a minute, so each attempt can be up to a minute later than its slot. After the last attempt the delivery is failed.
  • For every failure we keep the first 500 characters of what your endpoint answered (or why nothing did) — last_error in GET /webhook/deliveries. Put something useful in your error responses.
  • Twenty failed attempts in a row (a single success resets the count) switch the webhook off and we email you. The events still waiting in the queue at that moment are closed as failed, not held. Fix your endpoint, then PUT /webhook with "is_active": true — that also clears the failure count — and send the closed ones again one at a time.
  • POST /webhook/deliveries/{id}/retry acts only on a delivery whose status is failed. A delivered one is 409 already_delivered; one still queued is 409 delivery_not_failed. It restarts the full schedule, keeps the same event id, and answers 202.
  • /webhook/test and /retry answer 202 Accepted, not 200: the event is queued, not yet sent. Both draw on one allowance of 10 an hour per partner (see rate limits).
  • A key with account:read can read GET /webhook and GET /webhook/deliveries, so a monitoring tool needs no write access.
See what failed
curl "https://www.viaturquia.com/api/partner/v1/webhook/deliveries?status=failed" \
  -H "Authorization: Bearer $VT_API_KEY"

8. Preguntas frecuentes

Why is my tour not on the site?

Every tour you create starts as awaiting_review. Via Turquia checks it and publishes it; you cannot publish it yourself and is_active in a request is ignored. When it goes live status becomes published and a tour.approved event is sent. From then on, your changes appear immediately without another review. Our review screen flags a tour that has no itinerary, no images or no price (a complete matrix, if it includes hotel nights), so complete those first.

I lost my API key.

It cannot be recovered — we store only a hash. Sign in to the partner panel, revoke the old key (so a leaked one stops working at once) and create a new one. The new key is shown once.

Why did I get a 409?

A 409 means the request was valid but the current state forbids it. The code tells which: booking_not_pending (accept needs a pending booking), booking_not_cancellable, deposit_already_paid (only we can cancel a paid booking), tour_has_bookings (a booked tour cannot be deleted), translation_exists, matrix_on_day_tour / flat_price_on_hotel_tour (wrong kind of price for the tour), already_delivered / delivery_not_failed / webhook_inactive on webhook calls, or idempotency_key_reused / idempotency_in_progress (see Idempotency).

Every call is a 401 but my key is right.

Call GET /ping and read authorization_header_seen; false means a proxy or your HTTP client dropped the header. Also check that you send Authorization: Bearer <key> (not Basic, not in the URL), that the key is not revoked, and that your partner account is still approved.

I changed the Spanish price and the English one changed too.

Prices, durations, group sizes, cancellation hours, the accommodation switch and the reference address belong to the product, not to a translation, so a write reaches every language version of the tour. The list is in section 5, “Which fields are shared by all languages”.

My PATCH to pricing.price returned 422 or had no effect.

On a tour priced by the room matrix the price shown on the site is the cheapest matrix cell and is set by us each time you write the matrix; pricing.price is ignored there. Change the cell with PUT /tours/{id}/pricing instead.

Why is a customer unable to book my hotel tour?

Usually the room-fill rule (section 5): rooms are sold whole, so a double needs exactly two paying travellers. The other cause is an incomplete matrix — check pricing.matrix_complete. Every one of the 27 cells needs a price above 0.

My image was rejected.

The usual causes: an iPhone HEIC photo (heic_not_supported; export JPEG), a file over 12 MB, an address that is not https or that redirects (fetch_failed, unsafe_url), or putting a foreign address straight into media.main_image (media_url_not_on_site; upload through POST /tours/{id}/images first).

Why was my webhook switched off?

Twenty failed delivery attempts in a row. We email you when it happens. Fix your endpoint, PUT /webhook with "is_active": true, and use POST /webhook/deliveries/{id}/retry for the events that were closed meanwhile (up to 10 an hour).

How do I stay in sync without missing anything?

Poll with updated_since set to the pagination.as_of of your last poll, or listen to webhooks and poll occasionally as a safety net. Webhooks are delivered at least once and may arrive out of order; de-duplicate on the event id.

Is there a sandbox?

No. Tours you create stay unpublished until we publish them, so you can build and test freely, and POST /webhook/test sends a harmless ping. Bookings are real customers' bookings, so there is nothing to simulate on that side; answer them as you would in the panel.

Can I see other partners' tours or bookings?

No. Everything is limited to your own account, and a resource that belongs to someone else answers 404, the same as one that does not exist.

9. Cambios y versiones

Versioning

The version is in the path (/api/partner/v1). Inside v1 we only add: new endpoints, new optional request fields, new fields in answers, new event types and new values in lists such as available_events. We do not remove or rename a field, change its type, or tighten what a request may contain. Write your client to ignore fields and events it does not know.

A change that would break a working client ships as a new version path (/v2), never inside v1. Before that happens we email every partner who holds a live key, v1 keeps answering during the transition, and every change is recorded in the changelog below. Two things may still change inside v1 at any time: a security fix, and the correction of a figure we were reporting wrongly (an amount, a status, a date).

Changelog

DateChange
October 2026v1 released. Profile and reference data; tours in four languages with the shared-field rules; the 27-cell price matrix; images; bookings with accept and decline; signed webhooks with retries; Idempotency-Key; rate limits; the OpenAPI 3.1 document.

This page and openapi.json are produced from the same description of the API, so they always list the same endpoints. Questions, or something that behaves differently from what is written here: write to info@viaturquia.com and quote the request_id.

Comparar 0