openapi: 3.1.0
info:
  title: Campervan Atlas Partner API
  version: 1.0.0
  summary: Feed campervan, motorhome, caravan and campsite listings into Campervan Atlas and receive leads back.
  description: |
    Approved partners push listings with this API. Travellers find them on Campervan Atlas and are sent
    back to **your** site (a click-out) or send you an enquiry (forwarded to your webhook and/or by email
    to your `lead_email`, and always available from `GET /leads`). We never take bookings or payments.

    * Authentication: `Authorization: Bearer cc_live_…`
    * Rate limit: about 120 requests per minute per key (counted per Cloudflare location, so approximate). Responses carry `RateLimit-Limit` and `RateLimit-Policy`; there is no live remaining count.
    * Money: integers in minor units (`8900` with `GBP` is £89.00).
    * Errors: `{"error":{"code","message","details":[{"path","message"}]}}`.

    The human guide, with examples for each category and webhook verification code, is at
    [/developers](/developers).
  contact:
    name: Campervan Atlas partner support
    url: /contact?topic=partnerships
servers:
  - url: /api/v1
    description: This site
security:
  - bearerAuth: []
tags:
  - name: Account
    description: Your partner account, webhook and lead email settings.
  - name: Listings
    description: Create, update and remove listings. `external_id` is your own id for the listing.
  - name: Availability
    description: Optional calendar of available and unavailable dates, with optional per-range prices.
  - name: Leads
    description: Click-outs and enquiries for your listings.
  - name: Reference
    description: Allowed values for listing fields. Public, no key needed.

paths:
  /me:
    get:
      tags: [Account]
      operationId: getMe
      summary: Get your partner account
      responses:
        '200':
          description: Your account.
          headers: &rateLimitHeaders
            RateLimit-Limit: { $ref: '#/components/headers/RateLimit-Limit' }
            RateLimit-Policy: { $ref: '#/components/headers/RateLimit-Policy' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Partner' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Account]
      operationId: updateMe
      summary: Set webhook URL and lead email, or rotate the webhook secret
      description: |
        The first time you set `webhook_url` we create a signing secret and return it **once** as
        `webhook_secret`. Send `rotate_webhook_secret: true` to replace it (the old secret stops working at once).
        Webhook URLs must be `https://` and publicly reachable. Set `webhook_url` to `null` to stop webhooks;
        leads then wait for you at `GET /leads`.

        Set `lead_email` to also receive each enquiry by email, from our leads address on
        campervanatlas.com ("Campervan Atlas enquiries"). The email's `Reply-To` is the traveller, so replying answers them
        directly. We only ever email `lead_email`, never the traveller. Webhook and email are independent:
        an enquiry is `delivered` once either succeeds, and only the channel that failed is retried.
        Set `lead_email` to `null` to stop enquiry emails.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PartnerUpdate' }
            example:
              webhook_url: https://www.example-vans.co.uk/hooks/campervan-atlas
              lead_email: '<your bookings inbox>'
      responses:
        '200':
          description: Updated account. Includes `webhook_secret` only when a new secret was generated.
          headers: *rateLimitHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerWithSecret' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/test:
    post:
      tags: [Account]
      operationId: testWebhook
      summary: Send a signed test event to your webhook
      description: Sends a `webhook.test` event, signed exactly like real events, and reports what your endpoint answered.
      responses:
        '200':
          description: Outcome of the delivery attempt (check `delivered`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookTestResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: No webhook configured yet (`webhook_not_configured`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /listings:
    get:
      tags: [Listings]
      operationId: listListings
      summary: List your listings
      description: Ordered by our listing `id`, oldest first. Follow `next_cursor` until it is `null`.
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [active, paused] }
        - name: category
          in: query
          schema: { $ref: '#/components/schemas/Category' }
        - name: updated_since
          in: query
          description: Only listings whose content changed at or after this time (ISO 8601). Re-sending identical data does not count as a change.
          schema: { type: string, format: date-time }
          example: '2026-10-01T00:00:00Z'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of listings.
          headers: *rateLimitHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListingPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /listings/{external_id}:
    parameters:
      - $ref: '#/components/parameters/ExternalId'
    get:
      tags: [Listings]
      operationId: getListing
      summary: Get one listing
      responses:
        '200':
          description: The listing.
          headers: *rateLimitHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Listing' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    put:
      tags: [Listings]
      operationId: upsertListing
      summary: Create or replace a listing
      description: |
        Idempotent: send the full listing every time. `201` when it was created, `200` when it already
        existed (`result` is `updated` or `unchanged`). Fields you leave out are cleared.
        We work out country, region and town from `location`, link `osm_ref` to our open-data place
        when it matches, and give the listing a public page at `public_url`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ListingInput' }
            example:
              category: campervan
              title: "'Heather' 4-berth campervan"
              description: Pop-top VW conversion with a full kitchen.
              url: https://www.example-vans.co.uk/vans/heather
              images:
                - { url: 'https://www.example-vans.co.uk/img/heather-1.jpg', alt: Heather parked by a loch, width: 1600, height: 1067 }
              location: { lat: 55.9521, lng: -3.1965, precision: exact, name: Leith depot, 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 }
              amenities: [fridge, kitchen, heating, bedding]
              policies: { pets_allowed: true, min_driver_age: 25, mileage_included_per_day_km: null, cancellation: moderate }
      responses:
        '200':
          description: Listing existed and was updated (or was already identical).
          headers: *rateLimitHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListingUpsertResult' }
        '201':
          description: Listing created.
          headers:
            Location:
              description: Path of the listing in this API.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListingUpsertResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Listings]
      operationId: deleteListing
      summary: Delete a listing
      description: Removes the listing and its availability. Leads you already received stay available, without the listing link. To hide a listing temporarily, PUT it with `status` `paused` instead.
      responses:
        '204': { description: Deleted. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /listings/batch:
    post:
      tags: [Listings]
      operationId: batchUpsertListings
      summary: Create or replace up to 100 listings
      description: |
        Each item is a full listing plus its `external_id`. Items are validated and saved one by one, so a
        bad item does not stop the others: check `results[].status`. The response is `200` whenever the
        envelope itself is valid.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BatchRequest' }
      responses:
        '200':
          description: Per-item results.
          headers: *rateLimitHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BatchResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /listings/{external_id}/availability:
    parameters:
      - $ref: '#/components/parameters/ExternalId'
    get:
      tags: [Availability]
      operationId: getAvailability
      summary: Get availability ranges
      responses:
        '200':
          description: Ranges sorted by start date.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AvailabilityResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    put:
      tags: [Availability]
      operationId: replaceAvailability
      summary: Replace all availability ranges
      description: |
        Replaces every range for the listing. `start` is inclusive and `end` exclusive (like hotel nights),
        so `2027-06-01` to `2027-06-08` is 7 nights. Ranges must not overlap. Send `{"ranges": []}` to clear.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Availability' }
            example:
              ranges:
                - { start: '2027-06-01', end: '2027-06-08', status: available, price: 11900 }
                - { start: '2027-06-08', end: '2027-06-15', status: unavailable }
      responses:
        '200':
          description: Saved ranges.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AvailabilityResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /leads:
    get:
      tags: [Leads]
      operationId: listLeads
      summary: List leads for your listings
      description: |
        Oldest first. Poll with `delivery_status=pending&kind=enquiry` to collect enquiries that have not
        reached you by webhook or email, then acknowledge each with `POST /leads/{id}/ack`. Personal data (name,
        email, phone, message) is removed 90 days after a lead is created.
      parameters:
        - name: since
          in: query
          description: Only leads created at or after this time (ISO 8601).
          schema: { type: string, format: date-time }
        - name: kind
          in: query
          schema: { type: string, enum: [click, enquiry] }
        - name: delivery_status
          in: query
          schema: { $ref: '#/components/schemas/DeliveryStatus' }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of leads.
          headers: *rateLimitHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeadPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /leads/{id}/ack:
    post:
      tags: [Leads]
      operationId: ackLead
      summary: Acknowledge a lead
      description: Marks an enquiry as delivered, which stops webhook and email retries for it. Safe to call more than once. Click leads are unaffected.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^lead_[0-9A-Za-z]+$' }
          example: lead_4fQ2c9TzX0bL7mWk1yR8sD
      responses:
        '200':
          description: The lead.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Lead' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /taxonomy:
    get:
      tags: [Reference]
      operationId: getTaxonomy
      summary: Allowed values for listing fields
      security: []
      responses:
        '200':
          description: Categories, vehicle classes, amenities, pitch types and other enums.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Taxonomy' }

webhooks:
  lead.created:
    post:
      summary: A traveller sent an enquiry about one of your listings
      description: |
        We POST this to your `webhook_url`, usually within a few seconds of the enquiry. Reply with any `2xx` within 5 seconds. Anything else (or a
        timeout or redirect) is retried after 1, 4, 16, 64 and 256 minutes: 6 attempts in total. The body is
        identical on every retry, so de-duplicate on `id` (or `data.id`).

        Verify the `CC-Signature` header before trusting the body: compute
        `hex(HMAC-SHA256(webhook_secret, t + "." + raw_body))` and compare it with `v1`.
      parameters:
        - name: CC-Signature
          in: header
          required: true
          schema: { type: string }
          example: t=1791370800,v1=5f2b1c0d9e8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c
        - name: CC-Event-Id
          in: header
          required: true
          schema: { type: string }
        - name: CC-Event-Type
          in: header
          required: true
          schema: { type: string, enum: [lead.created, webhook.test] }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LeadCreatedEvent' }
      responses:
        '200': { description: Received. Any 2xx works. }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: cc_live_<43 base62 characters>
      description: 'Your API key, sent as `Authorization: Bearer cc_live_…`. We show the key once when you are approved.'

  parameters:
    ExternalId:
      name: external_id
      in: path
      required: true
      description: 'Your own id for the listing. 1-100 characters from `A-Z a-z 0-9 . _ : ~ -`. (`batch` is reserved.)'
      schema: { type: string, pattern: '^[A-Za-z0-9._:~-]{1,100}$' }
      example: van-042
    Cursor:
      name: cursor
      in: query
      description: Opaque `next_cursor` value from the previous page.
      schema: { type: string, maxLength: 200 }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }

  headers:
    RateLimit-Limit:
      description: Requests allowed per window (120).
      schema: { type: integer }
    RateLimit-Policy:
      description: '`120;w=60`: 120 requests per 60 seconds.'
      schema: { type: string }

  responses:
    BadRequest:
      description: Malformed JSON (`invalid_json`), bad query parameters (`bad_request`) or an invalid `external_id` (`invalid_external_id`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid API key (`unauthorized`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: unauthorized, message: 'Missing API key. Send the header Authorization: Bearer cc_live_…' } }
    Forbidden:
      description: Your account is not approved, or was suspended (`forbidden`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: No such listing or lead on your account (`not_found`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PayloadTooLarge:
      description: Body over 1 MB (10 MB for batch) (`payload_too_large`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnsupportedMediaType:
      description: Body not sent as `application/json` (`unsupported_media_type`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationError:
      description: The body is JSON but breaks the rules (`validation_error`). `details` lists each problem with a dotted path.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            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: vehicle.colour, message: Unknown field. Check the spelling against the field reference. }
    RateLimited:
      description: Over about 120 requests per minute (`rate_limited`). Wait `Retry-After` seconds (60).
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [unauthorized, forbidden, not_found, bad_request, invalid_json, invalid_external_id, unsupported_media_type,
                payload_too_large, validation_error, rate_limited, method_not_allowed, webhook_not_configured, internal_error]
            message: { type: string }
            details:
              type: array
              items:
                type: object
                required: [path, message]
                properties:
                  path: { type: string, example: images.0.url }
                  message: { type: string }

    Category:
      type: string
      enum: [campervan, motorhome, caravan, campsite]
    MinorUnits:
      type: integer
      minimum: 0
      description: Amount in minor units, e.g. 8900 = £89.00
    HttpsUrl:
      type: string
      format: uri
      maxLength: 2000
      pattern: '^https://'
      description: Must be an https:// URL
    Amenity:
      type: string
      enum: [toilet, shower, hot_water, kitchen, fridge, freezer, oven, microwave, heating, air_conditioning, solar, inverter,
        awning, bike_rack, tv, gps, reversing_camera, cruise_control, isofix, bedding, kitchen_kit, outdoor_furniture,
        electric_hookup, hardstanding, waste_disposal, drinking_water, toilets, showers, laundry, wifi, swimming_pool,
        playground, shop, restaurant, bar, dogs_allowed, wheelchair_accessible, ev_charging]
    VehicleClass:
      type: string
      enum: [compact_campervan, campervan, large_campervan, low_profile, coachbuilt, a_class, overland_4x4,
        touring_caravan, static_caravan, folding_camper, trailer_tent]
    PitchType:
      type: string
      enum: [tent, caravan, motorhome, campervan, glamping, cabin, static_caravan]

    Image:
      type: object
      additionalProperties: false
      required: [url]
      properties:
        url: { $ref: '#/components/schemas/HttpsUrl' }
        alt: { type: string, maxLength: 200 }
        width: { type: integer, minimum: 1 }
        height: { type: integer, minimum: 1 }

    LocationInput:
      type: object
      additionalProperties: false
      required: [lat, lng]
      properties:
        lat: { type: number, minimum: -90, maximum: 90 }
        lng: { type: number, minimum: -180, maximum: 180 }
        precision:
          type: string
          enum: [exact, approximate]
          default: exact
          description: Use approximate for private owners; we will not show a precise pin.
        name: { type: string, maxLength: 120, description: Pick-up point or campsite address shown to users }
        country_code:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2 (case-insensitive). Inferred if omitted.

    PriceInput:
      type: object
      additionalProperties: false
      required: [from, currency]
      properties:
        from: { $ref: '#/components/schemas/MinorUnits' }
        currency: { type: string, minLength: 3, maxLength: 3, description: 'ISO 4217 code (case-insensitive), e.g. GBP or EUR', example: GBP }
        unit: { type: string, enum: [night, week], default: night }
        min_nights: { type: integer, minimum: 1, maximum: 365 }
        deposit: { $ref: '#/components/schemas/MinorUnits' }

    Vehicle:
      type: object
      additionalProperties: false
      description: Campervans, motorhomes and caravans only.
      properties:
        vehicle_class: { $ref: '#/components/schemas/VehicleClass' }
        make: { type: string, maxLength: 60 }
        model: { type: string, maxLength: 80 }
        year: { type: integer, minimum: 1950, maximum: 2100 }
        berths: { type: integer, minimum: 1, maximum: 12, description: Number of people it sleeps }
        seats: { type: integer, minimum: 1, maximum: 12, description: Travel seats with seatbelts }
        transmission: { type: string, enum: [manual, automatic] }
        fuel: { type: string, enum: [diesel, petrol, electric, hybrid, lpg] }
        length_m: { type: number, minimum: 2, maximum: 20 }
        height_m: { type: number, minimum: 1, maximum: 5 }
        width_m: { type: number, minimum: 1, maximum: 3.5 }
        weight_kg: { type: integer, minimum: 300, maximum: 20000, description: Maximum authorised mass }
        licence: { type: string, maxLength: 10, description: 'Driving licence category needed, e.g. B or C1' }

    Campsite:
      type: object
      additionalProperties: false
      description: Campsite listings only.
      properties:
        pitch_types: { type: array, maxItems: 7, items: { $ref: '#/components/schemas/PitchType' } }
        pitches: { type: integer, minimum: 1, maximum: 10000 }
        open_from: { type: string, pattern: '^\d{2}-\d{2}$', description: 'Season start, MM-DD', example: '03-15' }
        open_to: { type: string, pattern: '^\d{2}-\d{2}$', description: 'Season end, MM-DD', example: '10-31' }
        check_in: { type: string, pattern: '^\d{2}:\d{2}$', example: '14:00' }
        check_out: { type: string, pattern: '^\d{2}:\d{2}$', example: '11:00' }

    Policies:
      type: object
      additionalProperties: false
      properties:
        pets_allowed: { type: boolean }
        smoking_allowed: { type: boolean }
        festivals_allowed: { type: boolean }
        travel_abroad_allowed: { type: boolean }
        one_way_available: { type: boolean }
        instant_book: { type: boolean }
        min_driver_age: { type: integer, minimum: 16, maximum: 99 }
        mileage_included_per_day_km:
          type: [integer, 'null']
          minimum: 0
          maximum: 5000
          description: null = unlimited
        insurance_included: { type: boolean }
        insurance_excess: { $ref: '#/components/schemas/MinorUnits' }
        cancellation: { type: string, enum: [flexible, moderate, strict, non_refundable] }
        cancellation_text: { type: string, maxLength: 1000 }

    Rating:
      type: object
      additionalProperties: false
      required: [value, count]
      description: Only send ratings you are entitled to republish.
      properties:
        value: { type: number, minimum: 0, maximum: 5 }
        count: { type: integer, minimum: 0 }

    ListingFields:
      description: The fields of a listing. Used by ListingInput and BatchItem.
      type: object
      required: [category, title, url, location]
      properties:
        category: { $ref: '#/components/schemas/Category' }
        status: { type: string, enum: [active, paused], default: active, description: Paused listings are hidden from the site and from click-outs. }
        title: { type: string, minLength: 5, maxLength: 120 }
        description: { type: string, maxLength: 5000, description: Plain text. Blank lines separate paragraphs. }
        url:
          allOf: [{ $ref: '#/components/schemas/HttpsUrl' }]
          description: The listing page on your site. Leads are sent here.
        images:
          type: array
          maxItems: 30
          default: []
          items: { $ref: '#/components/schemas/Image' }
        location: { $ref: '#/components/schemas/LocationInput' }
        price: { $ref: '#/components/schemas/PriceInput' }
        vehicle: { $ref: '#/components/schemas/Vehicle' }
        campsite: { $ref: '#/components/schemas/Campsite' }
        amenities:
          type: array
          maxItems: 60
          default: []
          items: { $ref: '#/components/schemas/Amenity' }
        policies: { $ref: '#/components/schemas/Policies' }
        rating: { $ref: '#/components/schemas/Rating' }
        osm_ref:
          type: string
          pattern: '^(node|way|relation)/\d+$'
          description: 'For campsites: the OpenStreetMap object this listing is, e.g. way/123456'

    ListingInput:
      description: |
        A full listing. Unknown fields are rejected. A `campsite` listing may not have `vehicle`;
        other categories may not have `campsite`.
      allOf: [{ $ref: '#/components/schemas/ListingFields' }]
      unevaluatedProperties: false

    BatchItem:
      allOf:
        - $ref: '#/components/schemas/ListingFields'
        - type: object
          required: [external_id]
          properties:
            external_id: { type: string, pattern: '^[A-Za-z0-9._:~-]{1,100}$' }
      unevaluatedProperties: false

    BatchRequest:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: '#/components/schemas/BatchItem' }

    BatchResponse:
      type: object
      required: [summary, results]
      properties:
        summary:
          type: object
          properties:
            created: { type: integer }
            updated: { type: integer }
            unchanged: { type: integer }
            failed: { type: integer }
        results:
          type: array
          description: One entry per item, in the order sent.
          items:
            type: object
            required: [index, external_id, status, result]
            properties:
              index: { type: integer }
              external_id: { type: [string, 'null'] }
              status: { type: integer, description: 'HTTP-style status for this item: 201, 200, 422 …' }
              result: { type: string, enum: [created, updated, unchanged, error] }
              id: { type: integer, description: Our listing id (on success). }
              public_url: { type: string, format: uri, description: Our public page (on success). }
              error:
                description: On failure. Same shape as the `error` object of an Error response; paths are relative to the item.
                type: object
                properties:
                  code: { type: string }
                  message: { type: string }
                  details:
                    type: array
                    items:
                      type: object
                      properties:
                        path: { type: string }
                        message: { type: string }

    Listing:
      description: A listing as stored. Mirrors ListingInput, plus ids and what we resolved.
      type: object
      required: [id, external_id, category, status, title, url, public_url, images, location, amenities, created_at, updated_at]
      properties:
        id: { type: integer, description: 'Our listing id. Used in /go/{id} and the public URL.' }
        external_id: { type: string }
        category: { $ref: '#/components/schemas/Category' }
        status: { type: string, enum: [active, paused] }
        title: { type: string }
        description: { type: [string, 'null'] }
        url: { type: string, format: uri, description: Your page, where leads go. }
        public_url: { type: string, format: uri, description: 'Our page for the listing: /listing/{slug}-{id}' }
        images: { type: array, items: { $ref: '#/components/schemas/Image' } }
        location:
          type: object
          properties:
            lat: { type: number }
            lng: { type: number }
            precision: { type: string, enum: [exact, approximate] }
            name: { type: [string, 'null'] }
            country_code: { type: string, description: Given by you or inferred from the coordinates. }
            region_id: { type: [string, 'null'], example: GB.SCT.U8 }
            town:
              description: Nearest town (GeoNames).
              oneOf:
                - type: 'null'
                - type: object
                  properties:
                    id: { type: integer }
                    name: { type: string }
        price:
          oneOf:
            - type: 'null'
            - type: object
              properties:
                from: { type: integer }
                currency: { type: string }
                unit: { type: string, enum: [night, week] }
                min_nights: { type: [integer, 'null'] }
                deposit: { type: [integer, 'null'] }
        vehicle:
          oneOf: [{ type: 'null' }, { $ref: '#/components/schemas/Vehicle' }]
        campsite:
          oneOf: [{ type: 'null' }, { $ref: '#/components/schemas/Campsite' }]
        amenities: { type: array, items: { $ref: '#/components/schemas/Amenity' } }
        policies:
          oneOf: [{ type: 'null' }, { $ref: '#/components/schemas/Policies' }]
        rating:
          oneOf: [{ type: 'null' }, { $ref: '#/components/schemas/Rating' }]
        osm_ref: { type: [string, 'null'], description: The OpenStreetMap object we linked. null if osm_ref was not sent or did not match a known place. }
        place_id: { type: [integer, 'null'], description: Our open-data place id when osm_ref matched. }
        sleeps: { type: [integer, 'null'], description: Copied from vehicle.berths. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time, description: Changes only when the content changes. }

    ListingUpsertResult:
      allOf:
        - $ref: '#/components/schemas/Listing'
        - type: object
          required: [result]
          properties:
            result: { type: string, enum: [created, updated, unchanged] }

    ListingPage:
      type: object
      required: [data, has_more, next_cursor]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Listing' } }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'] }

    Availability:
      type: object
      additionalProperties: false
      required: [ranges]
      properties:
        ranges:
          type: array
          maxItems: 730
          items: { $ref: '#/components/schemas/AvailabilityRange' }
    AvailabilityRange:
      type: object
      additionalProperties: false
      required: [start, end, status]
      properties:
        start: { type: string, format: date, pattern: '^\d{4}-\d{2}-\d{2}$', description: Inclusive }
        end: { type: string, format: date, pattern: '^\d{4}-\d{2}-\d{2}$', description: Exclusive }
        status: { type: string, enum: [available, unavailable] }
        price:
          allOf: [{ $ref: '#/components/schemas/MinorUnits' }]
          description: Price per listing price unit for this range
    AvailabilityResponse:
      type: object
      properties:
        listing_id: { type: integer }
        external_id: { type: string }
        ranges:
          type: array
          items:
            type: object
            properties:
              start: { type: string, format: date }
              end: { type: string, format: date }
              status: { type: string, enum: [available, unavailable] }
              price: { type: [integer, 'null'] }

    Partner:
      type: object
      properties:
        id: { type: integer }
        slug: { type: string }
        name: { type: string }
        website: { type: string, format: uri }
        status: { type: string, enum: [pending, approved, suspended, rejected] }
        is_demo: { type: boolean }
        api_key_prefix: { type: string, description: 'First 12 characters of your key, to tell keys apart.' }
        webhook_url: { type: [string, 'null'] }
        webhook_secret_set: { type: boolean }
        lead_email: { type: [string, 'null'] }
        categories: { type: array, items: { $ref: '#/components/schemas/Category' } }
        countries: { type: array, items: { type: string } }
        listings:
          type: object
          properties:
            active: { type: integer }
            paused: { type: integer }
        rate_limit:
          type: object
          properties:
            limit: { type: integer, example: 120 }
            window_seconds: { type: integer, example: 60 }
        created_at: { type: string, format: date-time }
        approved_at: { type: [string, 'null'], format: date-time }
    PartnerWithSecret:
      allOf:
        - $ref: '#/components/schemas/Partner'
        - type: object
          properties:
            webhook_secret: { type: string, description: Only present when a new secret was generated. Store it; it is not shown again., example: whsec_6tL0… }
            webhook_secret_notice: { type: string }
    PartnerUpdate:
      type: object
      additionalProperties: false
      properties:
        webhook_url: { type: [string, 'null'], format: uri, maxLength: 2000, description: 'https:// endpoint that receives lead.created events. null turns webhooks off.' }
        lead_email: { type: [string, 'null'], format: email, maxLength: 254, description: 'Where we email enquiries, from our campervanatlas.com leads address with Reply-To set to the traveller. null stops enquiry emails.' }
        rotate_webhook_secret: { type: boolean, description: true generates a new webhook secret and returns it once. }
    WebhookTestResult:
      type: object
      properties:
        delivered: { type: boolean }
        event_id: { type: string }
        response_status: { type: [integer, 'null'] }
        error: { type: [string, 'null'] }
        duration_ms: { type: integer }

    DeliveryStatus:
      type: string
      enum: [pending, delivered, failed, not_applicable]
      description: |
        pending = not yet received by you (webhook or email retrying, or neither is set);
        delivered = your webhook answered 2xx, the enquiry email was sent, or you acknowledged it;
        failed = every delivery attempt failed (still pullable); not_applicable = click leads.
    LeadData:
      type: object
      description: The lead. Contact fields are null for click leads and after the 90-day purge.
      properties:
        id: { type: string, example: lead_4fQ2c9TzX0bL7mWk1yR8sD }
        kind: { type: string, enum: [click, enquiry] }
        created_at: { type: string, format: date-time }
        name: { type: [string, 'null'] }
        email: { type: [string, 'null'], format: email }
        phone: { type: [string, 'null'] }
        start_date: { type: [string, 'null'], format: date }
        end_date: { type: [string, 'null'], format: date }
        guests: { type: [integer, 'null'] }
        message: { type: [string, 'null'] }
        source_page: { type: [string, 'null'], format: uri, description: Our page the lead came from. }
        consent_at: { type: [string, 'null'], format: date-time, description: When the traveller agreed to share their details with you. }
        listing:
          oneOf:
            - type: 'null'
            - type: object
              properties:
                id: { type: integer }
                external_id: { type: string }
                title: { type: string }
                url: { type: string, format: uri, description: Your listing page. }
                public_url: { type: string, format: uri, description: Our listing page. }
    Lead:
      allOf:
        - $ref: '#/components/schemas/LeadData'
        - type: object
          properties:
            delivery_status: { $ref: '#/components/schemas/DeliveryStatus' }
            delivery_attempts: { type: integer }
            delivered_at: { type: [string, 'null'], format: date-time }
            webhook_delivered_at: { type: [string, 'null'], format: date-time, description: When your webhook answered 2xx. }
            email_sent_at: { type: [string, 'null'], format: date-time, description: When the enquiry email to your lead_email was sent. }
            acked_at: { type: [string, 'null'], format: date-time, description: When you acknowledged the lead. }
    LeadPage:
      type: object
      required: [data, has_more, next_cursor]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Lead' } }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'] }
    LeadCreatedEvent:
      type: object
      required: [id, type, created_at, data]
      properties:
        id: { type: string, description: 'evt_ + the lead id without its prefix. Same on every retry.', example: evt_4fQ2c9TzX0bL7mWk1yR8sD }
        type: { type: string, const: lead.created }
        created_at: { type: string, format: date-time }
        data: { $ref: '#/components/schemas/LeadData' }

    Taxonomy:
      type: object
      properties:
        version: { type: string }
        categories:
          type: array
          items:
            type: object
            properties:
              key: { $ref: '#/components/schemas/Category' }
              label: { type: string }
              path: { type: string }
        vehicle_classes:
          type: array
          items: { type: object, properties: { key: { $ref: '#/components/schemas/VehicleClass' }, label: { type: string } } }
        amenities:
          type: array
          items: { type: object, properties: { key: { $ref: '#/components/schemas/Amenity' }, label: { type: string } } }
        pitch_types: { type: array, items: { $ref: '#/components/schemas/PitchType' } }
        transmissions: { type: array, items: { type: string } }
        fuels: { type: array, items: { type: string } }
        price_units: { type: array, items: { type: string } }
        cancellation_policies: { type: array, items: { type: string } }
        location_precisions: { type: array, items: { type: string } }
        listing_statuses: { type: array, items: { type: string } }
        currencies:
          type: object
          properties:
            note: { type: string }
            common: { type: array, items: { type: string } }
