components:
  parameters: {}
  schemas:
    CreateLeadRequest:
      additionalProperties: false
      properties:
        consent:
          default: {}
          description: Marketing consent the Claimant gave. Each flag defaults to false.
          example:
            email: true
            post: false
            sms: true
            whatsapp: false
          properties:
            email:
              default: false
              type: boolean
            post:
              default: false
              type: boolean
            sms:
              default: false
              type: boolean
            whatsapp:
              default: false
              type: boolean
          type: object
        dateTime:
          description: When the Claimant submitted their details (ISO 8601 with offset).
          example: '2026-10-29T09:15:00+00:00'
          format: date-time
          type: string
        eligible:
          description: Whether you found the Claimant eligible.
          example: true
          type: boolean
        misc:
          additionalProperties: true
          description: Any extra fields, such as what you learned on the call. Stored as sent.
          example:
            qualificationOutcome: qualified
          type: object
        personal:
          description: The Claimant's personal details. `dob` is dd/mm/yyyy.
          example:
            dob: 10/12/1985
            email: ada@example.com
            firstName: Ada
            lastName: Lovelace
            phone: '07700900123'
          properties:
            address1:
              maxLength: 256
              type: string
            address2:
              maxLength: 256
              type: string
            dob:
              pattern: ^(\d{2})\/(\d{2})\/(\d{4})$
              type: string
            email:
              maxLength: 256
              type: string
            firstName:
              maxLength: 256
              type: string
            ipAddress:
              maxLength: 16
              type: string
            lastName:
              maxLength: 256
              type: string
            more:
              additionalProperties:
                nullable: true
              type: object
            phone:
              maxLength: 24
              type: string
            postcode:
              maxLength: 12
              type: string
            title:
              maxLength: 12
              type: string
            town:
              maxLength: 256
              type: string
          type: object
        thirdPartyId:
          description: >-
            Your own reference for this Lead. Sending the same reference again returns the existing Lead instead of
            creating a second one.
          example: partner-000123
          maxLength: 256
          minLength: 1
          type: string
        utm:
          additionalProperties:
            nullable: true
          description: >-
            Tracking parameters. The canonical UTM keys and `fbclid` are stored as columns; any other keys are kept with
            the Lead's extra fields.
          example:
            utm_campaign: autumn
            utm_source: partner
          properties:
            fbclid:
              type: string
            utm_campaign:
              type: string
            utm_content:
              type: string
            utm_id:
              type: string
            utm_medium:
              type: string
            utm_source:
              type: string
            utm_term:
              type: string
          type: object
      required:
        - thirdPartyId
        - dateTime
        - eligible
      type: object
    CreateLeadResponse:
      properties:
        created:
          description: >-
            True when this request created the Lead; false when the reference already matched one of your Project's
            Leads (nothing was written).
          type: boolean
        id:
          description: The Cavis Lead id (`led_…`).
          example: led_v1w2x3y4z5a6b7c8d9e0f1g2
          type: string
      required:
        - id
        - created
      type: object
    InternalErrorResponse:
      properties:
        error:
          enum:
            - internal_error
          type: string
        message:
          type: string
      required:
        - error
        - message
      type: object
    LeadReferenceConflictResponse:
      anyOf:
        - properties:
            error:
              enum:
                - conflict
              type: string
            message:
              type: string
          required:
            - error
            - message
          type: object
        - properties:
            error:
              enum:
                - ambiguous
              type: string
            message:
              type: string
          required:
            - error
            - message
          type: object
    NotFoundResponse:
      properties:
        error:
          enum:
            - not_found
          type: string
        message:
          type: string
      required:
        - error
        - message
      type: object
    ThrottledResponse:
      properties:
        message:
          type: string
      required:
        - message
      type: object
    UnauthorizedResponse:
      properties:
        error:
          enum:
            - unauthorized
          type: string
        message:
          type: string
      required:
        - error
        - message
      type: object
    UpdateLeadConflictResponse:
      anyOf:
        - properties:
            error:
              enum:
                - ambiguous
              type: string
            message:
              type: string
          required:
            - error
            - message
          type: object
        - properties:
            error:
              enum:
                - conflict
              type: string
            message:
              type: string
          required:
            - error
            - message
          type: object
    UpdateLeadRequest:
      additionalProperties: false
      properties:
        consent:
          additionalProperties: false
          description: Marketing consent the Claimant gave. Send only the flags that changed; they can't be null.
          example:
            sms: false
          properties:
            email:
              type: boolean
            post:
              type: boolean
            sms:
              type: boolean
            whatsapp:
              type: boolean
          type: object
        eligible:
          description: Whether you found the Claimant eligible.
          example: true
          type: boolean
        misc:
          additionalProperties: true
          description: >-
            Extra fields, such as what you learned on the call, merged key by key. `null` removes a key; `misc: null`
            clears them all.
          example:
            qualificationOutcome: qualified
          nullable: true
          type: object
        personal:
          additionalProperties: false
          description: >-
            The Claimant's personal details, merged field by field (`more` merges key by key). `dob` is dd/mm/yyyy. If
            the Lead has no personal details yet, they are created. `personal: null` clears every field.
          example:
            address1: 1 New Street
            phone: '07700900456'
          nullable: true
          properties:
            address1:
              maxLength: 256
              nullable: true
              type: string
            address2:
              maxLength: 256
              nullable: true
              type: string
            dob:
              nullable: true
              pattern: ^(\d{2})\/(\d{2})\/(\d{4})$
              type: string
            email:
              maxLength: 256
              nullable: true
              type: string
            firstName:
              maxLength: 256
              nullable: true
              type: string
            ipAddress:
              maxLength: 16
              nullable: true
              type: string
            lastName:
              maxLength: 256
              nullable: true
              type: string
            more:
              additionalProperties:
                nullable: true
              nullable: true
              type: object
            phone:
              maxLength: 24
              nullable: true
              type: string
            postcode:
              maxLength: 12
              nullable: true
              type: string
            title:
              maxLength: 12
              nullable: true
              type: string
            town:
              maxLength: 256
              nullable: true
              type: string
          type: object
        tracking:
          additionalProperties: true
          description: 'The raw tracking parameters, merged key by key. `null` removes a key; `tracking: null` clears them all.'
          example:
            gclid: abc123
          nullable: true
          type: object
        utm:
          additionalProperties: false
          description: 'The canonical UTM parameters and `fbclid`. `null` clears one; `utm: null` clears them all.'
          example:
            utm_campaign: winter
          nullable: true
          properties:
            fbclid:
              nullable: true
              type: string
            utm_campaign:
              nullable: true
              type: string
            utm_content:
              nullable: true
              type: string
            utm_id:
              nullable: true
              type: string
            utm_medium:
              nullable: true
              type: string
            utm_source:
              nullable: true
              type: string
            utm_term:
              nullable: true
              type: string
          type: object
      type: object
    UpdateLeadResponse:
      properties:
        consent:
          properties:
            email:
              type: boolean
            post:
              type: boolean
            sms:
              type: boolean
            whatsapp:
              type: boolean
          required:
            - sms
            - email
            - whatsapp
            - post
          type: object
        dateTime:
          description: When the Claimant submitted their details (ISO 8601).
          example: '2026-10-29T09:15:00.000Z'
          type: string
        dynamoId:
          example: 3f0c2c1e-9a4b-4d2e-8f3a-1b2c3d4e5f60
          type: string
        eligible:
          type: boolean
        id:
          description: The Cavis Lead id (`led_…`).
          example: led_v1w2x3y4z5a6b7c8d9e0f1g2
          type: string
        misc:
          additionalProperties: true
          nullable: true
          type: object
        personal:
          description: Null when the Lead has no personal details.
          nullable: true
          properties:
            address1:
              nullable: true
              type: string
            address2:
              nullable: true
              type: string
            dob:
              description: dd/mm/yyyy.
              nullable: true
              type: string
            email:
              nullable: true
              type: string
            firstName:
              nullable: true
              type: string
            ipAddress:
              nullable: true
              type: string
            lastName:
              nullable: true
              type: string
            more:
              additionalProperties: true
              nullable: true
              type: object
            phone:
              nullable: true
              type: string
            postcode:
              nullable: true
              type: string
            title:
              nullable: true
              type: string
            town:
              nullable: true
              type: string
          required:
            - title
            - firstName
            - lastName
            - email
            - phone
            - address1
            - address2
            - town
            - postcode
            - dob
            - ipAddress
            - more
          type: object
        thirdPartyId:
          example: partner-000123
          nullable: true
          type: string
        tracking:
          additionalProperties: true
          nullable: true
          type: object
        updatedAt:
          description: When the Lead row last changed (ISO 8601).
          nullable: true
          type: string
        utm:
          properties:
            fbclid:
              nullable: true
              type: string
            utm_campaign:
              nullable: true
              type: string
            utm_content:
              nullable: true
              type: string
            utm_id:
              nullable: true
              type: string
            utm_medium:
              nullable: true
              type: string
            utm_source:
              nullable: true
              type: string
            utm_term:
              nullable: true
              type: string
          required:
            - utm_id
            - utm_source
            - utm_medium
            - utm_campaign
            - utm_term
            - utm_content
            - fbclid
          type: object
      required:
        - id
        - thirdPartyId
        - dynamoId
        - dateTime
        - eligible
        - utm
        - tracking
        - consent
        - personal
        - misc
        - updatedAt
      type: object
    ValidationFailedResponse:
      properties:
        error:
          enum:
            - validation_failed
          type: string
        issues:
          items:
            additionalProperties: true
            properties:
              code:
                type: string
              message:
                type: string
              path:
                items:
                  anyOf:
                    - type: string
                    - type: number
                type: array
            required:
              - code
              - path
              - message
            type: object
          type: array
        message:
          type: string
      required:
        - error
        - message
        - issues
      type: object
  securitySchemes:
    QualifierApiKey:
      description: >-
        Qualifier API Key (`cvs_live_…` in production, `cvs_test_…` in staging). Identifies one Project; a Project may
        have several.
      in: header
      name: X-API-Key
      type: apiKey
info:
  contact:
    email: owain.williams@cavis.co.uk
    name: Owain Williams
  description: >-
    Record the Leads you qualify for a Cavis campaign. Each campaign is a

    **Project**, and every Lead you send belongs to the Project your key is for.


    ## Authentication


    Send your Qualifier API Key in the `X-API-Key` header. Cavis issues one key

    per Project and environment: `cvs_live_…` keys work only against production

    and `cvs_test_…` keys only against staging, so your testing can never touch

    production Leads. If you work on several campaigns you hold one key for each.

    You may hold several keys for a Project at once, so you can rotate one without

    downtime; keys expire, and Cavis can revoke one immediately. Keep keys

    server-side: never put one in a browser or mobile app. Any problem with the key

    (missing, unknown, revoked, expired or for the other environment) gets the same

    `401`.


    ## Your reference


    Put your own identifier for the Lead in `thirdPartyId`. Cavis generates its

    own ids; the Cavis Lead id (`led_…`) comes back in the response. Before

    creating anything, Cavis looks your reference up among your Project's Leads,

    trying it as a Cavis Lead id, then as a `thirdPartyId`, then as the original

    submission id.


    ## Updating a Lead


    `PATCH /leads/{reference}` changes one of your Project's Leads. The reference

    is matched the same way: as a Cavis Lead id, then as your `thirdPartyId`, then

    as the original submission id. The body is a JSON Merge Patch (RFC 7396):


    - a field you leave out is unchanged;

    - `null` clears a field;

    - `personal`, `misc` and `tracking` merge key by key, so you send only what
      changed.

    You can change `eligible`, the four `consent` flags, the UTM parameters,

    `tracking`, `personal` and `misc` (record call outcomes there). Anything else,

    such as `thirdPartyId` or `dateTime`, is rejected with `400` and nothing is

    written. Personal details longer than Cavis stores (for example a postcode over

    12 characters) are rejected the same way. The response is the whole Lead.

    Cavis keeps every earlier version of the Lead, attributed to your key.


    | Status | Meaning |

    | ------ | ------- |

    | `200` | Updated (or already as you sent it): the whole Lead. |

    | `404` `not_found` | No Lead in your Project matches the reference. Leads in other Projects are never visible. |

    | `409` `ambiguous` | Your reference matches more than one of your Project's Leads. Nothing was changed; use the
    `led_…` id. |

    | `409` `conflict` | The Lead's personal details have been erased, and your patch includes `personal`. Nothing was
    changed; send it again without `personal`. |

    | `400` | The body failed validation; `issues` lists every problem. |


    ## Creating a Lead: results


    | Status | Meaning |

    | ------ | ------- |

    | `201` | Created: `{ id, created: true }`. |

    | `200` | Your reference already matches one of your Project's Leads. Nothing was written; you get its id with
    `created: false`. |

    | `409` `ambiguous` | Your reference matches more than one of your Project's Leads. Nothing was created. |

    | `409` `conflict` | Your reference is already in use outside your Project. Nothing was created; use another
    reference. |

    | `400` | The body failed validation; `issues` lists every problem. |


    Errors are `{ error, message }`, with a snake_case `error` code.


    ## Retries


    Retrying is safe. If a request times out or returns `500`, send it again with

    the same `thirdPartyId`: if the first attempt created the Lead, you get it

    back with `200` and nothing is written twice.


    ## Limits


    One Lead per request. Each route accepts about

    10 requests per second, with bursts up to

    20, shared across all callers. Above that you

    get `429`: wait and retry with backoff.
  title: Cavis Qualifier Leads API
  version: 1.0.0
openapi: 3.0.3
paths:
  /leads:
    post:
      description: >-
        Creates one Lead in your key's Project, unless `thirdPartyId` already matches one of your Project's Leads, in
        which case that Lead's id is returned and nothing is written. Safe to retry.
      operationId: createLead
      requestBody:
        content:
          application/json:
            examples:
              full:
                summary: A qualified Lead with personal details
                value:
                  consent:
                    email: true
                    post: false
                    sms: true
                    whatsapp: false
                  dateTime: '2026-10-29T09:15:00+00:00'
                  eligible: true
                  misc:
                    callAgent: J. Smith
                    qualificationOutcome: qualified
                  personal:
                    address1: 1 High Street
                    dob: 10/12/1985
                    email: ada@example.com
                    firstName: Ada
                    lastName: Lovelace
                    phone: '07700900123'
                    postcode: LS1 1AA
                    title: Ms
                    town: Leeds
                  thirdPartyId: partner-000123
                  utm:
                    utm_campaign: autumn
                    utm_source: partner
              minimal:
                summary: Only the required fields
                value:
                  dateTime: '2026-10-29T09:20:00+00:00'
                  eligible: false
                  thirdPartyId: partner-000124
            schema:
              $ref: '#/components/schemas/CreateLeadRequest'
        required: true
      responses:
        '200':
          content:
            application/json:
              examples:
                existing:
                  value:
                    created: false
                    id: led_v1w2x3y4z5a6b7c8d9e0f1g2
              schema:
                $ref: '#/components/schemas/CreateLeadResponse'
          description: '`thirdPartyId` already matches one of your Project''s Leads (for example, on a retry). Nothing was written.'
        '201':
          content:
            application/json:
              examples:
                created:
                  value:
                    created: true
                    id: led_v1w2x3y4z5a6b7c8d9e0f1g2
              schema:
                $ref: '#/components/schemas/CreateLeadResponse'
          description: The Lead was created.
        '400':
          content:
            application/json:
              examples:
                invalid:
                  summary: A bad dateTime and eligible
                  value:
                    error: validation_failed
                    issues:
                      - code: invalid_format
                        format: datetime
                        message: Invalid ISO datetime
                        origin: string
                        path:
                          - dateTime
                        pattern: >-
                          /^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$/
                      - code: invalid_type
                        expected: boolean
                        message: 'Invalid input: expected boolean, received string'
                        path:
                          - eligible
                    message: Request body failed validation
              schema:
                $ref: '#/components/schemas/ValidationFailedResponse'
          description: The body isn't JSON or failed validation. `issues` lists every problem. Nothing was written.
        '401':
          content:
            application/json:
              examples:
                unauthorized:
                  value:
                    error: unauthorized
                    message: Missing or invalid X-API-Key
              schema:
                $ref: '#/components/schemas/UnauthorizedResponse'
          description: The `X-API-Key` header is missing, or the key is unknown, revoked, expired or for the other environment.
        '409':
          content:
            application/json:
              examples:
                ambiguous:
                  value:
                    error: ambiguous
                    message: thirdPartyId matches more than one Lead in this Project; nothing was created
                conflict:
                  value:
                    error: conflict
                    message: thirdPartyId is already in use
              schema:
                $ref: '#/components/schemas/LeadReferenceConflictResponse'
          description: >-
            Nothing was created. `ambiguous`: `thirdPartyId` matches more than one of your Project's Leads. `conflict`:
            it is already in use outside your Project. Neither includes a Lead id.
        '429':
          content:
            application/json:
              examples:
                throttled:
                  value:
                    message: Too Many Requests
              schema:
                $ref: '#/components/schemas/ThrottledResponse'
          description: 'Too many requests: the route is throttled. Wait and retry with backoff.'
        '500':
          content:
            application/json:
              examples:
                internalError:
                  value:
                    error: internal_error
                    message: Failed to create the Lead
              schema:
                $ref: '#/components/schemas/InternalErrorResponse'
          description: Unexpected server error. Retry with the same `thirdPartyId`.
      security:
        - QualifierApiKey: []
      summary: Create a Lead
      tags:
        - Leads
  /leads/{reference}:
    patch:
      description: >-
        Updates one of your Project's Leads with a JSON Merge Patch (RFC 7396): a field you leave out is unchanged,
        `null` clears it, and `personal`, `misc` and `tracking` merge key by key. Only the fields in the body can be
        changed; anything else is a `400`. Returns the whole Lead. Sending the same patch again changes nothing.
      operationId: updateLead
      parameters:
        - description: >-
            The Lead: its Cavis Lead id (`led_…`), your `thirdPartyId`, or its original submission id, tried in that
            order among your Project's Leads.
          in: path
          name: reference
          required: true
          schema:
            description: >-
              The Lead: its Cavis Lead id (`led_…`), your `thirdPartyId`, or its original submission id, tried in that
              order among your Project's Leads.
            example: partner-000123
            type: string
      requestBody:
        content:
          application/json:
            examples:
              correction:
                summary: Correct personal details, clear one, withdraw SMS
                value:
                  consent:
                    sms: false
                  personal:
                    address2: null
                    phone: '07700900456'
                  utm:
                    utm_campaign: winter
              qualification:
                summary: Record the outcome of a call
                value:
                  eligible: true
                  misc:
                    qualificationOutcome: qualified
            schema:
              $ref: '#/components/schemas/UpdateLeadRequest'
        required: true
      responses:
        '200':
          content:
            application/json:
              examples:
                updated:
                  value:
                    consent:
                      email: true
                      post: false
                      sms: false
                      whatsapp: false
                    dateTime: '2026-10-29T09:15:00.000Z'
                    dynamoId: 3f0c2c1e-9a4b-4d2e-8f3a-1b2c3d4e5f60
                    eligible: true
                    id: led_v1w2x3y4z5a6b7c8d9e0f1g2
                    misc:
                      callAgent: J. Smith
                      qualificationOutcome: qualified
                    personal:
                      address1: 1 New Street
                      address2: null
                      dob: 10/12/1985
                      email: ada@example.com
                      firstName: Ada
                      ipAddress: null
                      lastName: Lovelace
                      more: null
                      phone: '07700900456'
                      postcode: LS1 1AA
                      title: Ms
                      town: Leeds
                    thirdPartyId: partner-000123
                    tracking:
                      utm_campaign: autumn
                      utm_source: partner
                    updatedAt: '2026-10-30T14:02:11.000Z'
                    utm:
                      fbclid: null
                      utm_campaign: winter
                      utm_content: null
                      utm_id: null
                      utm_medium: null
                      utm_source: partner
                      utm_term: null
              schema:
                $ref: '#/components/schemas/UpdateLeadResponse'
          description: The Lead after the patch.
        '400':
          content:
            application/json:
              examples:
                invalid:
                  summary: A field that can't be patched and a postcode too long
                  value:
                    error: validation_failed
                    issues:
                      - code: too_big
                        inclusive: true
                        maximum: 12
                        message: 'Too big: expected string to have <=12 characters'
                        origin: string
                        path:
                          - personal
                          - postcode
                      - code: unrecognized_keys
                        keys:
                          - thirdPartyId
                        message: 'Unrecognized key: "thirdPartyId"'
                        path: []
                    message: Request body failed validation
              schema:
                $ref: '#/components/schemas/ValidationFailedResponse'
          description: >-
            The body isn't JSON, failed validation, or tried to change a field that can't be patched (`id`, `projectId`,
            `dynamoId`, `thirdPartyId`, `dateTime` and the like). `issues` lists every problem. Nothing was written.
        '401':
          content:
            application/json:
              examples:
                unauthorized:
                  value:
                    error: unauthorized
                    message: Missing or invalid X-API-Key
              schema:
                $ref: '#/components/schemas/UnauthorizedResponse'
          description: The `X-API-Key` header is missing, or the key is unknown, revoked, expired or for the other environment.
        '404':
          content:
            application/json:
              examples:
                notFound:
                  value:
                    error: not_found
                    message: No Lead in this Project matches the reference
              schema:
                $ref: '#/components/schemas/NotFoundResponse'
          description: No Lead in your Project matches the reference. Leads in other Projects are never visible.
        '409':
          content:
            application/json:
              examples:
                ambiguous:
                  value:
                    error: ambiguous
                    message: >-
                      The reference matches more than one Lead in this Project; use the Lead's led_ id instead. Nothing
                      was changed
                conflict:
                  value:
                    error: conflict
                    message: >-
                      This Lead's personal details have been erased and can't be changed; send the patch without
                      personal. Nothing was changed
              schema:
                $ref: '#/components/schemas/UpdateLeadConflictResponse'
          description: >-
            Nothing was changed. `ambiguous`: the reference matches more than one of your Project's Leads; send the
            Lead's `led_…` id instead. `conflict`: the Lead's personal details have been erased, so a patch that
            includes `personal` is refused; send it without `personal`. Neither includes a Lead id.
        '429':
          content:
            application/json:
              examples:
                throttled:
                  value:
                    message: Too Many Requests
              schema:
                $ref: '#/components/schemas/ThrottledResponse'
          description: 'Too many requests: the route is throttled. Wait and retry with backoff.'
        '500':
          content:
            application/json:
              examples:
                internalError:
                  value:
                    error: internal_error
                    message: Failed to update the Lead
              schema:
                $ref: '#/components/schemas/InternalErrorResponse'
          description: Unexpected server error. Retry the same patch.
      security:
        - QualifierApiKey: []
      summary: Update a Lead
      tags:
        - Leads
servers:
  - description: Production (`cvs_live_…` keys)
    url: https://api.cavistech.co.uk
  - description: Staging, for integration testing (`cvs_test_…` keys)
    url: https://af63gbr3aj.execute-api.eu-west-2.amazonaws.com
tags:
  - description: Create and update the Leads you qualify for a Cavis campaign.
    name: Leads
