supercars.tours · Operator API v1

Your facts, your dates,
maintained by you

Once your business is claimed and verified you get an API key. It lets you keep the structured facts on your operator page correct, and tell us about your next tour, without emailing anyone. This page is the whole contract: every endpoint, every field, and every field that will be refused.

Independent. No bookings. No money from tour operators.

Getting started

Three steps, in this order. The middle one involves a person, so it is not instant.

  1. Claim the business

    Send your proof of identity to POST /api/v1/claim-my-business. This is the one endpoint that needs no key, because you do not have one yet.

  2. We check it, by hand

    Someone reads every claim. If it holds up, your business is marked as claimed and we email you a key. If it does not, we email you about that instead. Nothing on your page changes in the meantime.

  3. Use the key

    Send it as Authorization: Bearer <key> on every request after that. You can then correct your facts and post your next tour whenever you like.

Your key

A key looks like this — the prefix sct_ followed by 43 characters:

Authorization: Bearer sct_7hQ2xVn4Ld8sKpR1yTgW0cAeJm6BuZfHo3iN5vXqDwE

It is 32 bytes from a cryptographic random source, so it is not guessable and there is nothing in it to work out. Treat it exactly as you would a password.

We cannot tell you your key. We store only a SHA-256 hash of it, so after the email that delivers it, no copy exists on our side. If you lose it, email hello@supercars.tours and we will revoke the old one and issue a new one. Revoking is instant, and a revoked key stops working on its very next request.

Keeping it safe

  • Server-side only. Never put the key in a web page, a mobile app, or anything a browser downloads. Anyone who can read it can edit your listing.
  • Not in your git repository. Use an environment variable or your host's secret store.
  • One key per system. If you want your website and your booking system to both post dates, ask for two keys. Then a leak in one can be revoked without breaking the other.
  • Tell us if it leaks. Straight away, at hello@supercars.tours. There is no penalty, and a revoke-and-reissue takes a minute.

Every request is logged against your key, and every change you make is recorded against your page as operator-supplied, with the date. That log is how we can tell your corrections apart from our own.

Rate limits

Counted per key, per hour, per endpoint — not per address, so you are never throttled by another operator sharing your hosting. Going over returns 429; wait and retry.

EndpointPer hourCounted by
POST /claim-my-business5your IP address (no key exists yet)
PATCH /update-my-details120your key
GET /my-next-tour600your key
PUT /my-next-tour60your key
POST /tour-dates60your key

These are generous for the intended use, which is a handful of writes when something changes. If you need more, tell us what for.

Errors

Every failure has the same shape — an error string, plus an errors array when individual fields are at fault:

{
  "error": "Nothing was saved.",
  "errors": [
    { "field": "price_band", "message": "One of: lt4k, 4-8k, 8-15k, 15k+. Or null to clear it." }
  ]
}
CodeMeaningWhat to do
400The body is not valid JSON, or not the multipart form the claim endpoint expects.Fix the request. Retrying will not help.
401No key, or a key we do not recognise. A revoked key and a made-up key get the same answer.Check the header. If it was working yesterday, ask us whether it was revoked.
403The key is valid, but the business is not verified as claimed yet.Wait for the claim to be approved.
404No operator with that slug.Check the slug in your page's URL.
405Right URL, wrong method.The message names the method to use.
422We understood the request and refused it. Nothing was saved — not even the valid fields.Read errors, fix every entry, send again.
429Over the rate limit.Wait, then retry.
503Our end failed. Your request was fine.Retry in a few minutes. Safe to repeat — see idempotency below.

A 422 is all-or-nothing. If you send eight fields and one is wrong, none of the eight is saved. This is deliberate: a half-applied update is harder to reason about than a rejected one.

Claim your business

POST /api/v1/claim-my-business no key needed

Send multipart/form-data with a document that shows you are who you say you are — a company registration, a letterhead, a domain-ownership screenshot, anything a person can check.

FieldRequiredRules
slugyesThe last part of your page's URL, e.g. alpine-drives.
nameyesYour name. Up to 120 characters.
emailyesWhere the key goes if the claim is approved. Up to 200 characters.
rolenoYour role at the company. Up to 120 characters.
messagenoAnything you want the reviewer to know. Up to 4000 characters.
proofyesOne file, under 8 MB. PDF, or a photo as PNG, JPEG, WebP, AVIF or GIF.
# One request. The slug is the last part of your page's URL.
curl -X POST https://supercars.tours/api/v1/claim-my-business \
  -F "slug=alpine-drives" \
  -F "name=A. Director" \
  -F "email=ops@example.com" \
  -F "role=Operations" \
  -F "message=Company registration attached." \
  -F "proof=@registration.pdf"

202 Accepted means it is queued for a human, not that it is approved:

{
  "claim": 41,
  "operator": "alpine-drives",
  "status": "new",
  "note": "A person reviews every claim. If it checks out you will receive an API key by email; nothing on your page changes until then."
}

Notes worth reading before you send

  • Your document is not public. It is stored under a private prefix that our public media route refuses to serve. It cannot be reached by guessing a URL.
  • We identify the file by its contents, not its name or the type your client declares. Renaming a .exe to .pdf is rejected. SVG is refused outright — it can carry script.
  • Both halves of the validation come back together. If the form and the file are both wrong, one 422 tells you about both, so you are not fixing one problem per round trip.
  • Five claims an hour per address. Enough for a typo or two, not for scanning.

What you can change, and what you can't

This is the most important section on the page, so it comes before the endpoint that uses it.

You are the best possible source for the structured facts of your own tours — whose car, how long, what it costs, how many cars run together. You are the worst possible source for whether our author enjoyed driving with you. So the API accepts the first kind and refuses the second, by name, with a reason.

Yours to change

Send any of these to PATCH /update-my-details.

FieldType
websitehttp(s) URL, max 300 chars
car_policyown · hire · both
length_min_dayswhole number, 1–60
length_max_dayswhole number, 1–60
price_bandsee accepted values
convoy_sizetext, max 120 chars
group_vibesee accepted values
open_tosee accepted values
hotel_levelwhole number of stars, 1–5

Ours to keep

Send any of these and the whole request is refused, with this reason.

FieldWhy not
is_publishedPublishing is decided here, not by the API.
touredWhether we have toured with you is our record, and cannot be set.
verified_onThat date records our own check of your website.
style_scoreThe luxury-to-driving position is our editorial judgement.
styleDerived from the style score.
teaserEditorial text is written here.
website_facts"What they say" is our dated transcription of your site.
who_forEditorial text is written here.
who_not_forEditorial text is written here.
field_notesField notes are the author's own and cannot be set.
slugChanging it would break every link to your page.
nameA rename is an editorial change. Email us.
countryYour base country is part of how you are indexed.
logo_urlSend us a logo; it is not set through the API.
claimed_atA claim is granted after verification, never self-set.

This is not just a rule in our code. The database account that serves the API has permission to write those nine columns and no others. If we shipped a bug that tried to let you publish your own page, the database would still refuse it. We mention this because you are trusting us with your listing, and a promise backed by a permission is worth more than a promise.

If something in the editorial text is factually wrong, that is worth telling us about — email hello@supercars.tours and we will check it and re-date it. We correct facts. We don't take copy.

Update your details

PATCH /api/v1/update-my-details key verified claim

Send JSON containing only the fields you want to change. It is a PATCH rather than a PUT on purpose:

  • Leave a field out and it is untouched.
  • Send null and it is cleared.

Those two are different, so there is no way to wipe a value by forgetting to mention it.

curl -X PATCH https://supercars.tours/api/v1/update-my-details \
  -H "Authorization: Bearer $SCT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "car_policy": "both",
        "length_min_days": 3,
        "length_max_days": 7,
        "price_band": "8-15k",
        "hotel_level": 5,
        "convoy_size": null
      }'
{
  "operator": "alpine-drives",
  "updated": ["car_policy", "length_min_days", "length_max_days", "price_band", "hotel_level", "convoy_size"],
  "note": "Recorded, and logged against your page as operator-supplied. Editorial text and the toured label are unchanged."
}

The updated array is what actually changed. Compare it with what you sent if you want to be certain.

Things that will be refused

  • An empty body. {} is a 422, not a success. A client sending nothing has a bug, and "200, nothing changed" would hide it.
  • An unknown field. Named in the error rather than silently dropped, so a typo does not look like it worked.
  • A backwards range. length_min_days above length_max_days is two individually valid numbers that are jointly wrong.
  • A non-http URL. javascript: and data: are rejected, because website is rendered as a link.

Check your next tour

GET /api/v1/my-next-tour key

Returns the next tour we hold for you, including one still waiting for moderation. A read that only showed published rows would answer "nothing" to an operator whose submission is sitting in the queue, which is the least useful true answer available.

curl -H "Authorization: Bearer $SCT_KEY" \
  https://supercars.tours/api/v1/my-next-tour
{
  "operator": "alpine-drives",
  "next_tour": {
    "title": "Stelvio & Gavia, four days",
    "start_date": "2026-09-14",
    "end_date": "2026-09-17",
    "destination": "alps-dolomites",
    "url": "https://example.com/tours/stelvio-2026",
    "price_from": 6400,
    "currency": "EUR",
    "external_ref": "stelvio-2026-09",
    "status": "pending"
  },
  "note": "Held as \"pending\" — a person reviews every date before it appears."
}

With nothing on file, next_tour is null and the note says so. Finished and archived tours are never returned — "next" means next.

status is pending while it waits and published once it is live on your page. Poll this if you want to know when it went up; a few times a day is plenty.

Post your next tour

PUT /api/v1/my-next-tour key verified claim

One tour per request. Send the whole thing; this replaces rather than patches.

FieldRequiredRules
titleyesUp to 160 characters.
start_dateyesYYYY-MM-DD.
end_dateyesYYYY-MM-DD, not before start_date.
destinationnoOne of the nine codes.
urlnoMust start http:// or https://. Max 300 chars.
price_fromnoPositive, up to 99,999,999, two decimal places.
currencywith priceThree uppercase letters, e.g. EUR. Required whenever you send price_from — a number without a currency is not a price.
external_refno, but doYour own identifier, up to 80 characters. See below.
curl -X PUT https://supercars.tours/api/v1/my-next-tour \
  -H "Authorization: Bearer $SCT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "title": "Stelvio & Gavia, four days",
        "start_date": "2026-09-14",
        "end_date": "2026-09-17",
        "destination": "alps-dolomites",
        "url": "https://example.com/tours/stelvio-2026",
        "price_from": 6400,
        "currency": "EUR",
        "external_ref": "stelvio-2026-09"
      }'
{
  "operator": "alpine-drives",
  "inserted": 1,
  "updated": 0,
  "status": "pending",
  "note": "Received. A person reviews every date before it appears on your page."
}

Always send an external_ref. It is your own identifier for the tour, and it makes the request idempotent: send the same ref twice and the second request updates the first row instead of creating a duplicate. That is what makes a 503 safe to retry, and what lets you re-post the same tour after a price change without ending up with two of them. Without a ref, every request creates a new row.

Everything lands as pending. Nothing you send can publish itself. A person reads every date before it appears, usually within a day or two. If a date is wrong after it is published, just send it again with the same external_ref.

Sending an unrecognised destination returns 422 with the offending value named, rather than a server error. Leave it out if none of the nine fits.

Sync a whole calendar

POST /api/v1/tour-dates key

The same rules as PUT /my-next-tour, but up to 200 tours in one request. Use this if your season is already in a system that can post to us; use PUT if you are updating one date by hand.

curl -X POST https://supercars.tours/api/v1/tour-dates \
  -H "Authorization: Bearer $SCT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tours": [
        { "title": "Spring Alps", "start_date": "2026-04-10", "end_date": "2026-04-14",
          "destination": "alps-dolomites", "external_ref": "alps-2026-04" },
        { "title": "Dolomites Summer", "start_date": "2026-07-02", "end_date": "2026-07-06",
          "destination": "alps-dolomites", "external_ref": "dolo-2026-07" }
      ] }'

It is one transaction and one validation pass: if any tour in the batch is invalid, none of them is saved and the errors are indexed so you know which. An external_ref must be unique within a single request as well as across requests.

{
  "error": "Nothing was saved.",
  "errors": [
    { "index": 1, "field": "end_date", "message": "The end date cannot be before the start date." }
  ]
}

Because every ref is an upsert, re-posting your full calendar is safe and is the simplest way to keep us in step. Nightly is fine; the limit is 60 an hour.

Accepted values

These are keys, not labels. We translate them into English and French ourselves, so send the key exactly as written — never a translated string. Any of them accepts null to clear the value.

FieldValuesWhat it means
car_policy own
hire
both
Whether guests bring their own car, drive one of yours, or either.
price_band lt4k
4-8k
8-15k
15k+
Typical per-car cost. lt4k is "less than 4,000".
group_vibe couples-friendly
mixed
young-fast
Who the group tends to be, so readers can self-select.
open_to all-qualifying
single-brand
members
Who may join: anyone with a suitable car, one marque only, or members of a club.
hotel_level 15 Stars, as a whole number.

The luxury-to-driving position on your page is derived from a score we set, not from any of these. That axis is our editorial judgement and is not settable — see what stays ours.

Destination codes

Nine codes, and only these nine. Anything else is a 422 naming the value you sent. If none of them fits your tour, leave destination out entirely and tell us at hello@supercars.tours — a genuine gap is worth a new code.

CodeCovers
alps-dolomitesThe Alpine passes and the Dolomites, whichever country the road is in.
franceFrance, outside the Alps.
italyItaly, outside the Dolomites.
ukGreat Britain and Northern Ireland.
spain-portugalIberia.
scandinavia-iceNordic tours, including ice driving.
other-europeAnywhere else in Europe.
usaNorth America.
eventBuilt around a fixture — a race weekend, a concours, a rally.

Reading the public data

GET /api/v1/tours returns the published directory and needs no key at all. It is the same data any reader sees, so you can check how your listing appears without authenticating. Everything in it is public by definition; nothing private is exposed there.