For developers · API v1

Partner API guide

Send us your listings with a small JSON API. Travellers find them here, and we send them back to your site to book. It's free, and we never take bookings or payments.

QuickstartAPI referenceopenapi.yamlApply for a key

Overview

Campervan Atlas is a free search site for campervan, motorhome and caravan hire and for campsites, starting with the UK, Ireland, France, Spain and Portugal. It works like a travel search site. Here is what happens to your data:

  1. You send listings. One API call per vehicle or campsite, or 100 at a time. You keep your own ids (we call them external_id).
  2. We show them. We place each listing in the right country, region and town, put it on our map, and give it a page at /listing/…. We don't change your text or prices.
  3. Travellers come to you. A traveller either clicks through to your own page (a click-out), or sends an enquiry form that we forward to you straight away (an enquiry).
  4. You take the booking on your own site, with your own terms and payments.

What is free: listing, click-outs, enquiries and the API. There are no fees and no commission. We don't sell your data or your customers' details, and we never scrape anyone's website: everything on the site comes from open data or from partners like you.

Base URL: https://campervanatlas.com/api/v1. Every request and response is JSON in UTF-8. Times are UTC in ISO 8601 (2027-05-14T09:20:00Z). Money is always an integer in minor units: 8900 with GBP means £89.00.

Getting access

  1. Apply on the partners page. Tell us who you are, what you rent and roughly how many listings you have.
  2. We check that you are a real business or platform, and that you own or manage what you want to list. This usually takes up to 3 working days.
  3. When we approve you, we send your API key once. It starts with cc_live_. We only keep a fingerprint of it, so we can't show it again. If you lose it, we issue a new one.

Listings you send with status active go live straight away. To try things out first, send "status": "paused": paused listings are saved but not shown or linked. Then switch them to active.

Quickstart: your first listing in 3 commands

You need curl and your API key. Copy each block into a terminal.

1. Check your key

export CC_KEY="cc_live_your_key_here"

curl https://campervanatlas.com/api/v1/me \
  -H "Authorization: Bearer $CC_KEY"

You should see your account as JSON. If you get 401, the key is wrong or has a typo.

2. Send a listing

curl -X PUT https://campervanatlas.com/api/v1/listings/van-001 \
  -H "Authorization: Bearer $CC_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "category": "campervan",
    "title": "Heather, 4-berth pop-top campervan",
    "url": "https://www.example.com/vans/heather",
    "location": { "lat": 55.9756, "lng": -3.1703 },
    "price": { "from": 8900, "currency": "GBP" },
    "vehicle": { "berths": 4 }
  }'

van-001 is your own id for the van. Sending to the same id again updates it, so it's safe to repeat. The first time you get 201 Created, after that 200 OK. The response shows what we worked out:

{
  "id": 1042,
  "external_id": "van-001",
  "category": "campervan",
  "status": "active",
  "title": "Heather, 4-berth pop-top campervan",
  "url": "https://www.example.com/vans/heather",
  "public_url": "https://campervanatlas.com/listing/heather-4-berth-pop-top-campervan-edinburgh-1042",
  "location": {
    "lat": 55.9756, "lng": -3.1703, "precision": "exact", "name": null,
    "country_code": "GB", "region_id": "GB.SCT.U8",
    "town": { "id": 2650225, "name": "Edinburgh" }
  },
  "price": { "from": 8900, "currency": "GBP", "unit": "night", "min_nights": null, "deposit": null },
  "vehicle": { "berths": 4 },
  "sleeps": 4,
  "result": "created",
  …
}

3. See your listings

curl "https://campervanatlas.com/api/v1/listings?limit=10" \
  -H "Authorization: Bearer $CC_KEY"

Open public_url in a browser to see the listing on our site. That's it. Next, add photos, more vehicle details and amenities from the field reference, and set up lead delivery.

Authentication

Send your key in the Authorization header of every request, except GET /taxonomy, which is public:

Authorization: Bearer cc_live_4hT9…
  • Keep the key on your server. Never put it in a web page, app or public code repository.
  • The first 12 characters (for example cc_live_4hT9) are shown in GET /me as api_key_prefix, so you can tell keys apart.
  • If a key leaks, ask us to rotate it. The old key stops working at once.
  • Always call the API over https://, so the key is never sent in clear text.

401 unauthorized means a missing or wrong key. 403 forbidden means the key is right but your account is not approved, or has been suspended.

Listing fields

This is the body of PUT /listings/{external_id}, and of each item in a batch (plus external_id). Always send the whole listing: fields you leave out are cleared. Unknown fields are rejected with a clear error, so typos don't go unnoticed.

FieldTypeRequiredRules and notesExample
categorystring (enum)yesone of "campervan", "motorhome", "caravan", "campsite"Decides which pages the listing appears on."campervan"
statusstring (enum)noone of "active", "paused" · default "active"paused hides the listing from the site and from click-outs without deleting it."active"
titlestringyesmin 5 characters · max 120 charactersYour own words. Best without prices, phone numbers or ALL CAPS."'Heather' 4-berth pop-top campervan"
descriptionstringnomax 5000 charactersPlain text. Blank lines start new paragraphs. No HTML."A Volkswagen T6.1 conversion with a pop-top roof, two do…
urlstringyesmax 2000 characters · https:// URLhttps only. Click-outs send travellers here, with our UTM parameters added."https://www.example.com/vans/heather"
imagesarray of objectnomax 30 itemsThe first image is the cover photo. Only images you own or are licensed to share.
images[].urlstringyes, in every imagemax 2000 characters · https:// URLhttps only. At least 1200 px wide looks best."https://www.example.com/img/heather-front.jpg"
images[].altstringnomax 200 charactersDescribe the photo for screen-reader users."Heather parked beside a loch"
images[].widthintegernogreater than 01600
images[].heightintegernogreater than 01067
locationobjectyesWhere the traveller collects the vehicle, or where the campsite is.
location.latnumberyesmin -90 · max 90WGS84 decimal degrees.55.9756
location.lngnumberyesmin -180 · max 180WGS84 decimal degrees.-3.1703
location.precisionstring (enum)noone of "exact", "approximate" · default "exact"Use approximate for private owners; we will not show a precise pin."exact"
location.namestringnomax 120 charactersPick-up point or campsite address shown to users"Leith depot, Edinburgh"
location.country_codestringnoexactly 2 charactersOptional. We check it exists; otherwise we infer it from the coordinates."GB"
priceobjectnoOptional, but listings with a price are easier for travellers to compare.
price.fromintegerif price is sentmin 0 · integer, minor unitsYour lowest price per unit, in minor units: 8900 = £89.00.8900
price.currencystringif price is sentexactly 3 charactersISO 4217, e.g. GBP or EUR. We never convert currencies."GBP"
price.unitstring (enum)noone of "night", "week" · default "night""night"
price.min_nightsintegernomin 1 · max 3653
price.depositintegernomin 0 · integer, minor unitsAmount in minor units, e.g. 8900 = £89.0050000
vehicleobjectnoCampervans, motorhomes and caravans only.
vehicle.vehicle_classstring (enum)noone of 11 values (see list below)"campervan"
vehicle.makestringnomax 60 characters"Volkswagen"
vehicle.modelstringnomax 80 characters"Transporter T6.1"
vehicle.yearintegernomin 1950 · max 21002022
vehicle.berthsintegernomin 1 · max 12We copy this to sleeps for filtering.4
vehicle.seatsintegernomin 1 · max 12Travel seats with seatbelts4
vehicle.transmissionstring (enum)noone of "manual", "automatic""manual"
vehicle.fuelstring (enum)noone of "diesel", "petrol", "electric", "hybrid", "lpg""diesel"
vehicle.length_mnumbernomin 2 · max 205.3
vehicle.height_mnumbernomin 1 · max 52
vehicle.width_mnumbernomin 1 · max 3.51.9
vehicle.weight_kgintegernomin 300 · max 20000Maximum authorised mass3200
vehicle.licencestringnomax 10 charactersDriving licence category needed, e.g. B or C1"B"
campsiteobjectnoCampsites only.
campsite.pitch_typesarray of string (enum)noone of "tent", "caravan", "motorhome", "campervan", "glamping", "cabin", "static_caravan" · max 7 items["tent","campervan","motorhome","caravan"]
campsite.pitchesintegernomin 1 · max 1000060
campsite.open_fromstringnoformat MM-DD, e.g. "03-15"Season start, MM-DD"03-15"
campsite.open_tostringnoformat MM-DD, e.g. "03-15"Season end, MM-DD"10-31"
campsite.check_instringnoformat HH:MM (24-hour)"14:00"
campsite.check_outstringnoformat HH:MM (24-hour)"11:00"
amenitiesarray of string (enum)noone of 38 values (see list below) · max 60 itemsKeys from the amenity list below. Unknown keys are rejected so listings stay comparable.["fridge","kitchen","heating","solar","awning","bedding",…
policiesobjectno
policies.pets_allowedbooleannotrue
policies.smoking_allowedbooleanno
policies.festivals_allowedbooleannofalse
policies.travel_abroad_allowedbooleannotrue
policies.one_way_availablebooleannotrue
policies.instant_bookbooleanno
policies.min_driver_ageintegernomin 16 · max 9925
policies.mileage_included_per_day_kminteger or nullnomin 0 · max 5000null means unlimited mileage.null
policies.insurance_includedbooleannotrue
policies.insurance_excessintegernomin 0 · integer, minor unitsAmount in minor units, e.g. 8900 = £89.00125000
policies.cancellationstring (enum)noone of "flexible", "moderate", "strict", "non_refundable""moderate"
policies.cancellation_textstringnomax 1000 characters"Full refund up to 30 days before pick-up."
ratingobjectnoOnly ratings you collected yourself and may republish.
rating.valuenumberif rating is sentmin 0 · max 54.8
rating.countintegerif rating is sentmin 037
osm_refstringnonode/…, way/… or relation/… followed by the OSM idCampsites: the OpenStreetMap object for your site, e.g. way/123456. We link the listing to our open-data page when it matches."way/123456789"

Fields we add in responses

  • id: our id for the listing (used in /go/{id} and the page address).
  • public_url: the listing's page on our site.
  • location.country_code, location.region_id, location.town: where we placed it, from the coordinates and our GeoNames towns.
  • osm_ref and place_id: the OpenStreetMap place we linked, if your osm_ref matched one.
  • sleeps: copied from vehicle.berths.
  • created_at, updated_at: updated_at only changes when the content changes.
  • result (on PUT): created, updated or unchanged.

Allowed values

The same lists are available as JSON from GET /api/v1/taxonomy (no key needed).

Amenities (38)
  • toilet Toilet
  • shower Shower
  • hot_water Hot water
  • kitchen Kitchen
  • fridge Fridge
  • freezer Freezer
  • oven Oven
  • microwave Microwave
  • heating Heating
  • air_conditioning Air conditioning
  • solar Solar panel
  • inverter Inverter / 230V
  • awning Awning
  • bike_rack Bike rack
  • tv TV
  • gps Sat nav
  • reversing_camera Reversing camera
  • cruise_control Cruise control
  • isofix ISOFIX child seats
  • bedding Bedding included
  • kitchen_kit Kitchen kit included
  • outdoor_furniture Outdoor furniture
  • electric_hookup Electric hook-up
  • hardstanding Hardstanding pitches
  • waste_disposal Waste disposal
  • drinking_water Drinking water
  • toilets Toilets
  • showers Showers
  • laundry Laundry
  • wifi Wi-Fi
  • swimming_pool Swimming pool
  • playground Playground
  • shop Shop
  • restaurant Restaurant
  • bar Bar
  • dogs_allowed Dogs allowed
  • wheelchair_accessible Wheelchair accessible
  • ev_charging EV charging
Vehicle classes (11)
  • compact_campervan Compact campervan
  • campervan Campervan
  • large_campervan Large campervan
  • low_profile Low-profile motorhome
  • coachbuilt Coachbuilt motorhome
  • a_class A-class motorhome
  • overland_4x4 4x4 / overland
  • touring_caravan Touring caravan
  • static_caravan Static caravan
  • folding_camper Folding camper
  • trailer_tent Trailer tent
Campsite pitch types (7)
  • tent
  • caravan
  • motorhome
  • campervan
  • glamping
  • cabin
  • static_caravan

Categories and examples

Every listing has one category. Each example below is a complete, valid body you can send as is (we check them against the live schema every time this page is built).

campervan: Campervan

Vans up to about 6 m: VW-style conversions, panel vans, pop-tops and 4x4s.

  • Send vehicle.berths: we copy it to sleeps for the "sleeps N" filter.
  • Use vehicle_class compact_campervan, campervan, large_campervan or overland_4x4.
Full campervan example
{
  "category": "campervan",
  "status": "active",
  "title": "'Heather' 4-berth pop-top campervan",
  "description": "A Volkswagen T6.1 conversion with a pop-top roof, two double beds and a full kitchen.\n\nCollect from our Leith depot. Bedding and kitchen kit included.",
  "url": "https://www.example.com/vans/heather",
  "images": [
    {
      "url": "https://www.example.com/img/heather-front.jpg",
      "alt": "Heather parked beside a loch",
      "width": 1600,
      "height": 1067
    },
    {
      "url": "https://www.example.com/img/heather-kitchen.jpg",
      "alt": "Kitchen with two-ring hob and sink",
      "width": 1600,
      "height": 1067
    }
  ],
  "location": {
    "lat": 55.9756,
    "lng": -3.1703,
    "precision": "exact",
    "name": "Leith depot, Edinburgh",
    "country_code": "GB"
  },
  "price": {
    "from": 8900,
    "currency": "GBP",
    "unit": "night",
    "min_nights": 3,
    "deposit": 50000
  },
  "vehicle": {
    "vehicle_class": "campervan",
    "make": "Volkswagen",
    "model": "Transporter T6.1",
    "year": 2022,
    "berths": 4,
    "seats": 4,
    "transmission": "manual",
    "fuel": "diesel",
    "length_m": 5.3,
    "height_m": 2,
    "width_m": 1.9,
    "weight_kg": 3200,
    "licence": "B"
  },
  "amenities": [
    "fridge",
    "kitchen",
    "heating",
    "solar",
    "awning",
    "bedding",
    "kitchen_kit"
  ],
  "policies": {
    "pets_allowed": true,
    "festivals_allowed": false,
    "travel_abroad_allowed": true,
    "min_driver_age": 25,
    "mileage_included_per_day_km": null,
    "insurance_included": true,
    "insurance_excess": 125000,
    "cancellation": "moderate",
    "cancellation_text": "Full refund up to 30 days before pick-up."
  },
  "rating": {
    "value": 4.8,
    "count": 37
  }
}

motorhome: Motorhome

Coachbuilt, low-profile and A-class motorhomes.

  • height_m and length_m help travellers with ferries, car parks and low bridges.
  • Set licence to C1 if the vehicle is over 3,500 kg.
Full motorhome example
{
  "category": "motorhome",
  "title": "'Ben Nevis' 6-berth coachbuilt motorhome",
  "description": "Family motorhome with a fixed rear bunk, an over-cab double and a dinette bed. Shower room with toilet.",
  "url": "https://www.example.com/motorhomes/ben-nevis",
  "images": [
    {
      "url": "https://www.example.com/img/ben-nevis.jpg",
      "alt": "Ben Nevis motorhome on a Highland road"
    }
  ],
  "location": {
    "lat": 57.4778,
    "lng": -4.2247,
    "name": "Inverness, IV1"
  },
  "price": {
    "from": 14500,
    "currency": "GBP",
    "unit": "night",
    "min_nights": 4,
    "deposit": 75000
  },
  "vehicle": {
    "vehicle_class": "coachbuilt",
    "make": "Fiat",
    "model": "Ducato",
    "year": 2021,
    "berths": 6,
    "seats": 6,
    "transmission": "automatic",
    "fuel": "diesel",
    "length_m": 7.4,
    "height_m": 3.1,
    "weight_kg": 3500
  },
  "amenities": [
    "toilet",
    "shower",
    "hot_water",
    "kitchen",
    "fridge",
    "oven",
    "heating",
    "bike_rack",
    "reversing_camera"
  ],
  "policies": {
    "pets_allowed": false,
    "one_way_available": true,
    "min_driver_age": 25,
    "mileage_included_per_day_km": 250,
    "cancellation": "strict"
  }
}

caravan: Caravan

Touring caravans, static caravans, folding campers and trailer tents.

  • Static caravans you rent on a park can be caravan listings with vehicle_class static_caravan.
  • If you deliver to the traveller, set location.precision to approximate.
Full caravan example
{
  "category": "caravan",
  "title": "Two-berth touring caravan, delivered to your pitch",
  "description": "We deliver, level and set up this caravan on any campsite within 40 miles of Bristol.",
  "url": "https://www.example.com/caravans/sprite-2",
  "images": [
    {
      "url": "https://www.example.com/img/sprite-2.jpg",
      "alt": "Small white touring caravan on grass"
    }
  ],
  "location": {
    "lat": 51.4545,
    "lng": -2.5879,
    "precision": "approximate",
    "name": "Bristol area (delivered)"
  },
  "price": {
    "from": 39900,
    "currency": "GBP",
    "unit": "week"
  },
  "vehicle": {
    "vehicle_class": "touring_caravan",
    "make": "Swift",
    "model": "Sprite Compact",
    "year": 2019,
    "berths": 2,
    "length_m": 5.6,
    "weight_kg": 1050
  },
  "amenities": [
    "fridge",
    "kitchen",
    "heating",
    "toilet",
    "awning"
  ],
  "policies": {
    "pets_allowed": true,
    "cancellation": "flexible"
  }
}

campsite: Campsite

Campsites, caravan parks and glamping sites. One listing per site.

  • Never send a vehicle object for a campsite; use campsite instead.
  • Send osm_ref if your site is on OpenStreetMap, so we merge your listing with our open-data page instead of showing it twice.
Full campsite example
{
  "category": "campsite",
  "title": "Lantern Meadow Camping",
  "description": "A quiet, adults-only site on a working farm with views of Skiddaw. Ten minutes on foot to Keswick.",
  "url": "https://www.example.org/stay",
  "images": [
    {
      "url": "https://www.example.org/img/meadow.jpg",
      "alt": "Tents and campervans in a meadow at sunset"
    }
  ],
  "location": {
    "lat": 54.6072,
    "lng": -3.1264,
    "precision": "exact",
    "name": "Lantern Meadow, Keswick CA12"
  },
  "price": {
    "from": 2800,
    "currency": "GBP",
    "unit": "night",
    "min_nights": 2
  },
  "campsite": {
    "pitch_types": [
      "tent",
      "campervan",
      "motorhome",
      "caravan"
    ],
    "pitches": 60,
    "open_from": "03-15",
    "open_to": "10-31",
    "check_in": "14:00",
    "check_out": "11:00"
  },
  "amenities": [
    "electric_hookup",
    "hardstanding",
    "waste_disposal",
    "drinking_water",
    "toilets",
    "showers",
    "wifi",
    "dogs_allowed"
  ],
  "policies": {
    "pets_allowed": true,
    "cancellation": "flexible"
  },
  "osm_ref": "way/123456789"
}

Keeping listings in sync

Pick whichever is easier for your system. Many partners do both: incremental updates during the day and a full sync at night as a safety net.

Nightly full sync

Send everything with POST /listings/batch (up to 100 per call), then delete anything we have that you don't. Simple and self-healing. Identical listings come back as unchanged and are not touched.

Incremental

When something changes in your system, PUT that one listing. When it's gone, DELETE it. Fast and cheap, but if you miss an event, nothing fixes it until the next change.

Full sync script

// nightly-sync.mjs: run once a night (Node 18 or newer, no packages needed).
// Sends every listing you have, then deletes the ones you no longer have.
const API = 'https://campervanatlas.com/api/v1';
const headers = { Authorization: `Bearer ${process.env.CC_KEY}`, 'Content-Type': 'application/json' };

async function call(method, path, body) {
  for (;;) {
    const res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
    if (res.status === 429) { // too fast: wait as long as we ask, then retry
      await new Promise((r) => setTimeout(r, 1000 * Number(res.headers.get('Retry-After') ?? 5)));
      continue;
    }
    if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
    return res.status === 204 ? null : res.json();
  }
}

const mine = await loadListingsFromMyDatabase(); // your code: [{ external_id, category, title, ... }]
if (mine.length === 0) throw new Error('Refusing to sync zero listings'); // protects against wiping everything

// 1. Upsert everything, 100 at a time. Identical listings come back as "unchanged".
for (let i = 0; i < mine.length; i += 100) {
  const { summary, results } = await call('POST', '/listings/batch', { items: mine.slice(i, i + 100) });
  console.log(summary);
  for (const r of results) if (r.result === 'error') console.warn(r.external_id, JSON.stringify(r.error));
}

// 2. Delete what we have that you don't.
const keep = new Set(mine.map((l) => l.external_id));
let cursor = null;
do {
  const page = await call('GET', '/listings?limit=200' + (cursor ? `&cursor=${cursor}` : ''));
  for (const l of page.data) {
    if (!keep.has(l.external_id)) await call('DELETE', `/listings/${encodeURIComponent(l.external_id)}`);
  }
  cursor = page.next_cursor;
} while (cursor);

Incremental updates

# A van changed: send the whole listing again
curl -X PUT https://campervanatlas.com/api/v1/listings/van-001 -H "Authorization: Bearer $CC_KEY" \
  -H "Content-Type: application/json" -d @van-001.json

# Sold or off the fleet for good
curl -X DELETE https://campervanatlas.com/api/v1/listings/van-001 -H "Authorization: Bearer $CC_KEY"

# In the workshop for a month: hide it, keep it
#   PUT the same listing with "status": "paused", then "active" when it's back

How batch results work

Each item is checked and saved on its own, so one bad listing never blocks the others. The call returns 200 with a summary and one entry per item, in the order you sent them:

{
  "summary": {
    "created": 1,
    "updated": 0,
    "unchanged": 0,
    "failed": 1
  },
  "results": [
    {
      "index": 0,
      "external_id": "van-001",
      "status": 201,
      "result": "created",
      "id": 1042,
      "public_url": "https://campervanatlas.com/listing/heather-4-berth-pop-top-campervan-edinburgh-1042"
    },
    {
      "index": 1,
      "external_id": "van-002",
      "status": 422,
      "result": "error",
      "error": {
        "code": "validation_error",
        "message": "The request body is not valid. See details.",
        "details": [
          {
            "path": "price.currency",
            "message": "Required"
          }
        ]
      }
    }
  ]
}

To see what changed on our side since your last run, use GET /listings?updated_since=2027-05-01T00:00:00Z. Lists are paged: follow next_cursor until it is null.

Send us a product feed instead of using the API

If you already publish a product feed, for example for Awin, CJ or Tradedoubler, or your booking system can export a file, we can read that instead. You don't write any code. We fetch the feed every hour or every day and keep your listings in step with it: new rows are added, changed rows are updated, and rows that disappear are paused, never deleted. When they come back, they go live again.

Formats

  • CSV or TSV in UTF-8 with a header row. Commas, semicolons, pipes or tabs all work, and so do quoted fields with line breaks.
  • JSON: an array of objects, or an object with an items array ({"items": [ … ]}). Nested objects are fine.
  • XML: one <product> (or <item>) element per listing, with one child element per field.
  • Gzip-compressed files are fine. The feed must be reachable by URL; a URL with a token in it is fine, as is a header such as Authorization.

Columns

Use these column names, or the standard columns of your affiliate network's product feed (Awin, CJ and Tradedoubler names are recognised). We ignore columns we don't know.

ColumnNeededWhat it isExample
idRequiredYour stable id for the listing (we use it like external_id)van-001
titleRequired5 to 120 charactersHeather, 4-berth pop-top campervan
urlRequiredThe https:// page travellers should land on (an affiliate deep link is fine)https://www.example.com/vans/heather
categoryRequired*campervan, motorhome, caravan or campsite (plain words like "Campervan hire" work)campervan
priceOptionalFrom-price in normal units, not pence or cents89.00
currencyWith priceISO 4217 codeGBP
price_unitOptionalnight (default) or weeknight
latitude, longitudeRequired*Pick-up point or campsite position55.9756, -3.1703
city, region, countryInstead of coordinatesWe place the listing at the town (shown as approximate)Oban, Argyll and Bute, GB
imageOptionalhttps:// photo URLs; several separated by |https://www.example.com/p/1.jpg
descriptionOptionalPlain text or simple HTML, up to 5000 characters-
sleeps, seats, transmissionOptionalVehicles only; transmission is manual or automatic4, 4, manual
make, model, year, fuelOptionalVehicles onlyVolkswagen, California, 2022, diesel

* If every row has the same category or currency, we can set it once for the whole feed instead. Each listing needs either coordinates or a town we can find.

Every row goes through the same checks as the API. A row that fails is skipped, not published, and we tell you which rows failed and why. If a whole feed comes back empty or broken, we keep your current listings live rather than pausing everything.

Getting set up

  1. Apply on the partners page and say you'd like to send a feed rather than use the API. Tell us the format and roughly how many listings it has.
  2. When we approve you, send us the feed URL, and the header value if it needs one, by replying to your approval email. We store it encrypted and never show it on the site.
  3. We do a test read of the first rows and send you what we understood, including any rows that need fixing.
  4. Then we switch on the hourly or daily schedule. Leads work exactly as described under Leads.

Availability

Optional. If you send it, travellers can see which dates are free before they click through. If you don't, we just send them to your page to check.

curl -X PUT https://campervanatlas.com/api/v1/listings/van-001/availability \
  -H "Authorization: Bearer $CC_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ranges": [
      { "start": "2027-06-01", "end": "2027-06-08", "status": "available", "price": 11900 },
      { "start": "2027-06-08", "end": "2027-06-15", "status": "unavailable" },
      { "start": "2027-06-15", "end": "2027-09-01", "status": "available", "price": 12900 }
    ]
  }'
  • Each call replaces all ranges for that listing. Send {"ranges": []} to clear them.
  • start is inclusive and end is exclusive, like hotel nights: 1 to 8 June is 7 nights, and the van is free again on the 8th.
  • Ranges must not overlap. Gaps are fine: we treat them as unknown.
  • price (optional) overrides the listing's price.from for that range, in minor units per price unit.
  • Up to 730 ranges (two years of single days) per listing. Send real dates only.

Leads

A lead is a traveller who showed interest in one of your listings. There are two kinds, and you can always list both with GET /leads.

Click-outs

When a traveller presses "View on partner site", they go through /go/{id} and we redirect them to your listing url with three parameters added:

https://www.example.com/vans/heather?utm_source=campervanatlas&utm_medium=referral&cc_lead=lead_4fQ2c9TzX0bL7mWk1yR8sD
  • utm_source=campervanatlas and utm_medium=referral show up in Google Analytics or any other analytics tool. Your own parameters in url are kept.
  • cc_lead is a unique id for that click. If you save it with the booking, you can match bookings to our click leads. We don't use tracking pixels or cookies on your site.
  • Click leads contain no personal data. We skip obvious bots and link-preview robots.

Enquiries by webhook

Enquiries come from the form on our listing page. The traveller agrees to share their details with you; we then send them to your server as a signed HTTPS POST, usually within a few seconds. Set it up once:

curl -X PATCH https://campervanatlas.com/api/v1/me \
  -H "Authorization: Bearer $CC_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "webhook_url": "https://www.example.com/hooks/campervan-atlas" }'

# The response includes "webhook_secret": "whsec_…" ONCE. Store it.
# Then check your endpoint end to end:
curl -X POST https://campervanatlas.com/api/v1/webhooks/test -H "Authorization: Bearer $CC_KEY"

# Lost the secret, or it leaked? Rotate it (the old one stops working at once):
curl -X PATCH https://campervanatlas.com/api/v1/me -H "Authorization: Bearer $CC_KEY" \
  -H "Content-Type: application/json" -d '{ "rotate_webhook_secret": true }'

Each delivery looks like this:

POST /hooks/campervan-atlas HTTP/1.1
Content-Type: application/json
User-Agent: CampervanAtlas-Webhooks/1.0
CC-Event-Id: evt_4fQ2c9TzX0bL7mWk1yR8sD
CC-Event-Type: lead.created
CC-Signature: t=1791370800,v1=6d1f0c…(64 hex characters)
{
  "id": "evt_4fQ2c9TzX0bL7mWk1yR8sD",
  "type": "lead.created",
  "created_at": "2027-05-14T09:20:00Z",
  "data": {
    "id": "lead_4fQ2c9TzX0bL7mWk1yR8sD",
    "kind": "enquiry",
    "created_at": "2027-05-14T09:20:00Z",
    "name": "Ada Lovelace",
    "email": "<traveller's email address>",
    "phone": "+44 7700 900123",
    "start_date": "2027-06-01",
    "end_date": "2027-06-08",
    "guests": 2,
    "message": "Hi, is Heather free that week? We have a small dog.",
    "source_page": "https://campervanatlas.com/listing/heather-4-berth-pop-top-campervan-edinburgh-1042",
    "consent_at": "2027-05-14T09:20:00Z",
    "listing": {
      "id": 1042,
      "external_id": "van-001",
      "title": "Heather, 4-berth pop-top campervan",
      "url": "https://www.example.com/vans/heather",
      "public_url": "https://campervanatlas.com/listing/heather-4-berth-pop-top-campervan-edinburgh-1042"
    }
  }
}

phone, start_date, end_date, guests and message may be null. The id is the same on every retry, so use it to ignore repeats.

Enquiries by email

No booking system or server? Set lead_email and we email each enquiry to that address, usually within a few seconds. You can use email on its own or together with a webhook:

curl -X PATCH https://campervanatlas.com/api/v1/me \
  -H "Authorization: Bearer $CC_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "lead_email": "<your bookings inbox>" }'

# Stop enquiry emails:
curl -X PATCH https://campervanatlas.com/api/v1/me -H "Authorization: Bearer $CC_KEY" \
  -H "Content-Type: application/json" -d '{ "lead_email": null }'
  • Emails come from our leads address on campervanatlas.com, with the sender name "Campervan Atlas enquiries" and the subject New enquiry: {listing title}. Allow-list the campervanatlas.com domain so enquiries don't land in spam.
  • Just press Reply. The email's Reply-To is the traveller's address, so your answer goes straight to them, not to us.
  • Each email has the traveller's name, email, phone (if given), dates, guests and message, the listing title, and links to the listing on our site and on yours. The lead id is at the bottom.
  • We only ever email the lead_email you set. We never email the traveller.
  • Webhook and email are separate channels. The lead counts as delivered as soon as either one gets through; a channel that failed is retried on the same schedule without repeating the one that worked. webhook_delivered_at and email_sent_at on each lead show which channel reached you.

Verifying the signature

Check every webhook before you trust it, so nobody else can send you fake enquiries. The CC-Signature header looks like t=1791370800,v1=6d1f…:

  1. Take t (the time we signed, in Unix seconds) and the raw request body, exactly as received.
  2. Compute HMAC-SHA256 of t + "." + body with your webhook_secret as the key, as lowercase hex.
  3. Compare it with v1 using a constant-time comparison, and reject t more than 5 minutes old.
// webhook.mjs: Node 18 or newer, no packages needed.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.CC_WEBHOOK_SECRET; // whsec_…

// Returns true when the CC-Signature header matches the raw body.
export function verifyCcSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(header ?? '').split(',').map((kv) => kv.split('=')));
  const t = Number(parts.t);
  if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // stale or malformed
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

createServer((req, res) => {
  let raw = '';
  req.setEncoding('utf8');
  req.on('data', (chunk) => (raw += chunk));
  req.on('end', () => {
    // Verify the raw text exactly as received, before parsing it.
    if (!verifyCcSignature(raw, req.headers['cc-signature'], SECRET)) {
      res.writeHead(400).end('bad signature');
      return;
    }
    const event = JSON.parse(raw);
    if (event.type === 'lead.created') {
      // Save event.data, using event.id to ignore repeats. Send emails etc. later, not here.
      console.log('New enquiry for', event.data.listing?.external_id);
    }
    res.writeHead(200).end('ok'); // answer within 5 seconds
  });
}).listen(3000);

// Using Express? Read the raw body: app.post('/hooks/cc', express.text({ type: 'application/json' }), …)

Retries

Reply with any 2xx status within 5 seconds. Do slow work (emails, CRM updates) after replying. Anything else, including a timeout or a redirect, counts as a failure and we try again:

AttemptSent afterWait since previous
1Immediately-
21 min1 min
35 min4 min
421 min16 min
51 h 25 min64 min
65 h 41 min256 min

After 6 failed attempts the lead is marked failed, but it is never lost: you can still pull it. If you also use email, an enquiry that reached you by email is already delivered, and we stop retrying the webhook when you acknowledge it.

Pulling leads

No webhook, or your server was down? Collect enquiries with the API, for example from a cron job every 15 minutes, and acknowledge each one:

# Enquiries that have not reached you yet (no webhook or email, or both failed)
curl "https://campervanatlas.com/api/v1/leads?kind=enquiry&delivery_status=pending" \
  -H "Authorization: Bearer $CC_KEY"

# Tell us you have it, so we stop retrying the webhook and email
curl -X POST https://campervanatlas.com/api/v1/leads/lead_4fQ2c9TzX0bL7mWk1yR8sD/ack \
  -H "Authorization: Bearer $CC_KEY"

# Everything since a date, clicks included (follow next_cursor for more pages)
curl "https://campervanatlas.com/api/v1/leads?since=2027-05-01T00:00:00Z" -H "Authorization: Bearer $CC_KEY"

delivery_status is pending until your webhook answers 2xx, the enquiry email is sent, or you acknowledge the lead; then it's delivered. Click leads are not_applicable. We delete the personal details of every lead (name, email, phone and message) after 90 days, so copy what you need into your own system.

Errors

Every error is JSON with the same shape. code is stable and safe to use in your code; message is written for people. For validation errors, details lists every problem with a dotted path to the field (images.0.url is the url of the first image):

{
  "error": {
    "code": "validation_error",
    "message": "The request body is not valid. See details.",
    "details": [
      {
        "path": "location.lat",
        "message": "Too big: expected number to be <=90"
      },
      {
        "path": "images.0.url",
        "message": "Must be an https:// URL"
      },
      {
        "path": "vehicle.colour",
        "message": "Unknown field. Check the spelling against the field reference."
      }
    ]
  }
}
StatuscodeMeaningWhat to do
400invalid_jsonThe body is not valid JSON.Check quoting and commas. Most JSON libraries never produce this.
400bad_requestA query parameter is wrong (unknown name, bad cursor, bad date).Read details. Use next_cursor exactly as returned.
400invalid_external_idThe external_id in the URL has characters we do not allow.Use A-Z a-z 0-9 . _ : ~ - (up to 100).
401unauthorizedNo API key, or a wrong one.Send Authorization: Bearer cc_live_…. Ask us to rotate a lost key.
403forbiddenYour account is pending, suspended or rejected.Contact us. Do not retry.
404not_foundNo such listing or lead on your account, or no such endpoint.Check the external_id. Lists only show your own data.
405method_not_allowedWrong HTTP method for this path.See the Allow header.
409webhook_not_configuredYou asked for a test webhook but have no webhook_url.PATCH /me with webhook_url first.
413payload_too_largeBody over 1 MB (10 MB for batch).Send fewer items per batch.
415unsupported_media_typeBody was not sent as JSON.Add Content-Type: application/json.
422validation_errorValid JSON that breaks the rules.Fix each details[].path and send again. Do not retry unchanged.
429rate_limitedMore than 120 requests in a minute.Wait Retry-After seconds, then continue. Use batch.
500internal_errorOur fault.Retry with backoff. Tell us if it persists.

Safe to retry: 429 and 5xx, with a growing delay. Everything else needs a change to the request first.

Rate limits

Each key can make about 120 requests per minute. One batch call counts as one request, so you can send up to 12,000 listings a minute. The limit is counted per Cloudflare data centre, so treat it as approximate: stay a little under it rather than aiming for exactly 120.

Responses state the policy (we can't give a live remaining count):

RateLimit-Limit: 120
RateLimit-Policy: 120;w=60     # 120 requests per 60 seconds

If you go over, you get 429 with "code": "rate_limited" and a Retry-After header in seconds. Wait that long, then carry on. The sync script above shows how.

Images and copyright

Only send content you own or have the right to share. That covers photos, descriptions and ratings.

  • Photos: your own photos, or ones you have a licence to use for advertising. No photos copied from other websites, booking platforms or image searches. No images of people who haven't agreed to it.
  • Image format: https URLs to JPEG, PNG or WebP files, ideally at least 1200 px wide. The first image is the cover. Keep URLs stable; if an image disappears, we stop showing it. Add alt text describing the photo.
  • Text: write your own descriptions. Don't paste text from other listing sites. No contact details or booking links in the description: travellers reach you through click-outs and enquiries.
  • Ratings: only ratings you collected yourself and may republish. Don't send scores from third-party review platforms unless their terms allow it.
  • Accuracy: prices must be prices a traveller can actually book. Keep listings up to date, and pause or delete what you can't rent.
  • Private owners: if you list for private owners (peer-to-peer), send "precision": "approximate" so we don't show their home on a map.

If someone tells us content infringes their rights, we may hide the listing while we sort it out with you.

Data terms in plain English

These are the key points of the data terms you agree to when you apply. If anything here and the full terms disagree, the full terms win.

What you let us do
Show your listings, photos and prices on Campervan Atlas, in search results, on maps, and in links and previews of our pages, for as long as you keep them listed. This permission is free, non-exclusive, and ends when you delete the listing.
What stays yours
Your content and your customers. We don't claim ownership, don't sell your data, and don't share your listings with other companies as a data feed.
What we promise
Listing is free, with no commission. We link to your site and send you every lead. We never scrape your website. When you delete a listing, it disappears from our site straight away.
What you promise
You run the business or platform you applied for, you have the right to send the content (see Images and copyright), and your listings are accurate and bookable.
Travellers' personal data
Enquiries are sent to you with the traveller's agreement, so you can reply to them. Use them only to answer that enquiry and for the booking that follows, and handle them under data protection law (UK GDPR / GDPR); you are responsible for them once they reach you. We delete our copy of the personal details after 90 days.
Ending it
You can stop at any time by deleting your listings. We may pause or remove listings, or suspend a key, if content is misleading, infringing, or the API is misused, and we will tell you why.

Changelog

We add fields and endpoints without warning, so ignore fields you don't recognise. We never remove or rename anything in v1; breaking changes would come as v2, with at least six months' notice.

· v1.0.0
First release: listings (single and batch), availability, click-out and enquiry leads with signed webhooks and retries, lead pulling and acknowledgement, taxonomy.
Product feeds: send a CSV, TSV, JSON or XML feed instead of using the API. See Product feeds.
Enquiries by email to your lead_email, with Reply-To set to the traveller. Leads gain webhook_delivered_at, email_sent_at and acked_at. See Enquiries by email.

Questions or problems? Reply to your approval email, or contact us.