openapi: 3.1.0
info:
  title: Lifepeaks Public API v2
  version: "2.0.0"
  summary: Headless gift-card commerce with Lifepeaks-controlled payments.
  contact:
    name: Lifepeaks Integration Support
    email: support@lifepeaks.dk
  license:
    name: Proprietary
  description: |
    The Lifepeaks `/v2` REST API. Authenticate with a Bearer API key (`lp_live_…`),
    issued via POST /v2/api-keys or the admin UI.

    For headless commerce, create an authoritative quote and unpaid order, then send
    the buyer to Lifepeaks-hosted QuickPay checkout. Lifepeaks owns the payable amount,
    payment confirmation, commission, canonical order state, and fulfillment. A partner
    API key belongs only on the partner's server and must never be exposed in a browser.

    ## Rate limits

    Every `/v2` request spends one request from a budget held on the API key that made
    it. The default budget is 600 requests per minute per key, and it refills
    continuously, so a client that stays under the rate never sees a rejection. Each key
    has its own budget: exhausting one does not affect another, even in the same company.

    Every response carries the state of that budget. `X-Rate-Limit-Limit` is the number
    of requests a full window allows, `X-Rate-Limit-Remaining` is what is left right now,
    and `X-Rate-Limit-Reset` is the seconds until the budget is full again.

    A request that arrives with an empty budget is answered `429` with `error.code` of
    `rate_limited`, and no work is done. `Retry-After` gives the seconds to wait. A `429`
    is always safe to retry, and retrying a write with the same `Idempotency-Key` cannot
    duplicate it.

    `GET /v2/pickup-points` has a second, tighter limit of its own, because it reaches a
    third party on our account.

    ## Live keys and test keys

    A key is either `lp_live_…` or `lp_test_…`, chosen when it is created.

    Both read and write the same data. There is one Lifepeaks database per environment, so
    a gift card issued with a test key is a real row you can look up and redeem.

    The difference is where the key may take money. What makes a payment real is the
    environment: the demo environment's payment credentials belong to QuickPay's test
    account, so every payment made there is a test payment — use QuickPay's test cards.
    Production's credentials are live, and a test key is refused there with `409` and
    `error.code` of `test_key_not_accepted_in_production`, for a checkout session and for
    a refund alike, before anything is charged.

    ## Money — two different units live under /v2

    **Read this before sending any amount.** Two endpoints groups use a field called
    `amount`, and they mean different things. Getting it wrong is not a validation
    error — it silently issues a gift card worth 100× too much or too little.

    | Where | `amount` means | DKK 500.00 is |
    |---|---|---|
    | `POST /v2/quotes`, `POST /v2/orders` — headless commerce | a whole number in the **smallest unit** of the currency (øre for DKK) | `50000` |
    | `POST /v2/items` — negotiated invoice-settlement issuance | a **decimal** in the currency itself (kroner for DKK) | `500` |

    Headless commerce uses the smallest unit throughout: `amount`, `subtotal`, `fees`,
    `tax`, `total`, `minimum_amount`, `maximum_amount`, and `order_fee` are all whole
    numbers of øre. Whole numbers of the smallest unit avoid rounding errors, which is
    why the payable amount is expressed that way.

    The compatibility issuance and item-operation endpoints (`POST /v2/items`,
    `/v2/items/*`, `/v2/reports/*`) predate that convention and keep decimal amounts in
    the company's own currency, so an existing v1 integration migrates without rescaling.
    Every field says which unit it uses; when in doubt, check the field description
    rather than assuming.

    ## Scopes
    - `companies:read` — list the companies the key may act for, and read one of them
    - `brand:read` — read a company's brand profile and its brand assets
    - `brand:write` — update the brand profile and replace or delete brand assets
    - `credentials:read` — list API keys without returning their plaintext values
    - `credentials:write` — create and revoke API keys
    - `orderpage:read` — read order-page configuration and revisions
    - `orderpage:write` — update order-page configuration and publish revisions
    - `items:read` — list and inspect items, list events, list gift-card campaigns
    - `items:write` — redeem, refund, cancel, activate and resend items; claim a whole order
    - `catalog:read` — list products available to the acting company
    - `quotes:create` — request authoritative Lifepeaks pricing
    - `orders:create` — create unpaid headless orders
    - `orders:read` — read canonical order, payment, and fulfillment state
    - `checkout:create` — create Lifepeaks-hosted QuickPay checkout sessions
    - `orders:settle` — use negotiated, contract-gated settlement
    - `orders:refund` — return money on an order, deliberately not implied by `orders:settle`
    - `webhooks:read` — list webhook endpoints and deliveries
    - `webhooks:write` — create endpoints, rotate secrets, and replay deliveries
    - `pdf_templates:read` — read and preview branded gift-card PDF templates
    - `pdf_templates:write` — upload, publish, and revert branded PDF templates
    - `reports:read` — analytics, claimed-item transactions, subscribers, event participants

    Every operation states the single scope it requires. A key carrying more scopes
    than an operation needs is fine; a key missing the required scope gets `403`.
    `GET /v2/me` and `GET /v2/health` require no scope at all.

    ## Tenancy
    By default every request acts on the company that owns the API key. To act on a
    company Lifepeaks has assigned to yours — the arrangement agencies use — pass that
    company's slug in the optional `company` query parameter. One key can therefore
    serve an agency across all of its assigned companies. A `POST` or `PATCH` may carry
    the same slug as a `company` field in its request body instead, and the query string
    wins when both carry it; `POST /v2/quotes` is the one exception, because its body
    accepts `items` alone.

    A slug that is neither the key's own company nor one assigned to it — and an unknown
    or inactive company — is reported as `404`, not as a permission error, exactly as a
    code, order or event belonging to another company is. The API-key operations and
    `GET /v2/health` take no `company` parameter and always act on the key's own company.

    Three route families express tenancy differently. `GET /v2/companies` and
    `GET /v2/companies/{slug}` enumerate the companies the key may act for, so the key's
    own company is always the starting point. The brand routes name their company in the
    path as `{company_id}`, which accepts the same slugs `GET /v2/companies` returns.
    Neither family reads a `company` query parameter.

    ## Success responses
    A successful response is the payload itself, at the top level of the body — v2 does
    **not** wrap it in the `{ "status": "OK", "response": … }` envelope the legacy v1 API uses.

    ## Errors
    Non-2xx responses return a JSON error envelope:
    ```json
    { "error": { "code": "string", "message": "string", "fields": { "field": ["error"] } } }
    ```
    `fields` is only present on 422 responses.

    | Status | `error.code` | Meaning |
    |---|---|---|
    | 401 | `unauthorized` | Missing, malformed, expired or revoked API key |
    | 403 | `insufficient_scope` | Valid key, but it lacks the scope the operation requires |
    | 404 | `not_found` | The resource does not exist for the company the request acts on. One envelope covers every miss — code, order, event, line, PDF template, upload intent, company — so a 404 never says which |
    | 422 | `validation_failed` | The request broke a business or input rule |
    | 422 | `amount_precision_unsupported` | `POST /v2/orders` only: the order total needs more precision than a payment amount can hold |
    | 422 | `delivery_method_unavailable` | The delivery method named is not one the company offers |
    | 422 | `delivery_destination_unavailable` | The courier does not deliver that way to that country |
    | 422 | `send_at_unsupported` | The delivery method chosen cannot be scheduled |
    | 422 | `greeting_image_not_found` | The greeting picture named does not exist for this company |
    | 429 | `rate_limited` | The key's request budget is empty; wait for `Retry-After` |
    | 503 | `image_store_unavailable` | A greeting picture could not be stored; the upload is still valid |
    | 503 | `pickup_lookup_unavailable` | Collection points could not be looked up. Not an empty result |
    | 503 | `checkout_unavailable` | The payment gateway could not open a session. Retry with the same `Idempotency-Key` |
    | 409 | `test_key_not_accepted_in_production` | A test key tried to take or return a payment in production. Use a live key, or this key on demo |
    | 409 | `refund_in_progress` | Another refund of that order is still being processed. Retry shortly |
    | 403 | `credential_not_attributed` | Every write route: the key predates per-key attribution, so a write cannot be recorded against it. Mint a new key |
    | 403 | `checkout_not_enabled` | Lifepeaks Checkout is not on this company's contract |
    | 409 | `checkout_in_progress` | Another checkout session for the same order is still being opened. Retry after `Retry-After` with the same key |
    | 409 | `order_not_payable` | The order is no longer a pending unpaid gateway order |
    | 403 | `settlement_not_enabled` | The settlement mode asked for is not on this company's contract |
    | 409 | `order_not_settleable` | The order is not an intact pending headless order |
    | 409 | `settlement_reference_conflict` | That order, or that `external_reference`, has already been settled |
    | 409 | `delivery_not_replayable` | That webhook delivery cannot be replayed |
    | 503 | `template_unavailable` | The PDF template exists and its bytes could not be read. Retry |

    ## MCP endpoint
    `POST /mcp` is a JSON-RPC 2.0 endpoint (MCP streamable HTTP transport), not a REST
    resource, so it is intentionally outside this specification. It authenticates with the
    same Bearer API keys and scopes described here. See the MCP page of the documentation
    site for its tools, resources, and prompts.

servers:
  - url: https://api.lifepeaks.dk
    description: Production
  - url: https://api-demo.lifepeaks.dk
    description: Demo
  - url: https://api-dev.lifepeaks.dk
    description: Development and QuickPay testing

security:
  - bearerAuth: []

paths:
  /v2/health:
    get:
      operationId: getHealth
      summary: Check that the API is reachable
      description: |
        A liveness probe. Use it to confirm that your network path, base URL, and API key
        all work before debugging anything more specific, and as the target of an uptime
        monitor.

        It authenticates but requires **no scope**, so the least-privileged key you hold
        can call it. It reports nothing about the calling company — for that, call
        `GET /v2/me`. Being a liveness probe it has no tenant, so it accepts no `company`
        parameter.
      tags: [Health]
      responses:
        "200":
          description: API is healthy
          content:
            application/json:
              schema:
                type: object
                required: [status, version]
                properties:
                  status:
                    type: string
                    description: Always `ok` when the API answered.
                    const: ok
                    example: ok
                  version:
                    type: string
                    description: The API generation serving this request. Always `v2` here.
                    const: v2
                    example: v2
              example:
                status: ok
                version: v2
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v2/me:
    get:
      operationId: getCurrentIntegration
      summary: Describe the calling integration
      description: |
        Returns the company the request acts on, the credential it authenticated with,
        and the commerce settlement modes that company is entitled to use. Call it once
        at start-up to confirm which company a key belongs to, to check the scopes the
        key actually carries before relying on an operation, and to discover whether
        negotiated settlement is available — rather than discovering any of that from a
        `403` in production.

        Requires **no scope**: any valid key can describe itself. This operation is
        read-only and safe to repeat.

        It reports entitlements but never grants them. There is no endpoint that
        self-serves a negotiated settlement mode; Lifepeaks provisions those under a
        signed contract.
      tags: [Account]
      parameters:
        - $ref: "#/components/parameters/Company"
      responses:
        "200":
          description: The acting company, the credential, and its commerce entitlements.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IntegrationContext"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/companies:
    get:
      operationId: listCompanies
      summary: List the companies this key may act for
      description: |
        Returns every company the API key is permitted to address: its own company
        first, then — for a root or agency company — each active company Lifepeaks has
        assigned to it. Use the `id` of any returned company as the `company` query
        parameter on a tenant-scoped operation, or as `{company_id}` on the brand routes.

        An ordinary (non-agency) company always receives exactly one entry, its own.
        Inactive assigned companies and companies belonging to another agency are never
        listed. This operation always starts from the key's own company and does not
        read a `company` query parameter.

        Omitting `limit` returns every permitted company. Send `limit` and
        `starting_after` — whose cursor is a company `id`, that is, its slug — to page.

        Requires `companies:read` scope.
      tags: [Companies]
      x-lifepeaks-scope: companies:read
      parameters:
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: The companies this key may act for.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompanyList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/companies/{slug}:
    get:
      operationId: getCompany
      summary: Get one permitted company
      description: |
        Returns a single company by slug, resolved against the same permitted set
        `GET /v2/companies` returns. Use it to confirm that a slug you hold is still
        addressable and still active before acting on it.

        A slug that is unknown, inactive, or not assigned to the key's company answers
        `404` — the response never distinguishes "no such company" from "not yours".

        Requires `companies:read` scope.
      tags: [Companies]
      x-lifepeaks-scope: companies:read
      parameters:
        - $ref: "#/components/parameters/CompanySlug"
      responses:
        "200":
          description: The requested company.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompanyEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/companies/{company_id}/brand:
    get:
      operationId: getBrandProfile
      summary: Get a company's brand profile
      description: |
        Returns the brand identity Lifepeaks applies to that company's generated
        gift-card PDF and hosted surfaces: display name, tagline, the four brand colors,
        the two typography choices, and the PDF copyright line.

        A company that has never been branded still answers `200`, with Lifepeaks'
        starting values — the company's own name as `display_name`, `#111111` primary
        and text, `#ef233c` secondary, `#ffffff` background, and `null` for every
        optional field. Nothing is created by reading.

        Requires `brand:read` scope.
      tags: [Brand]
      x-lifepeaks-scope: brand:read
      parameters:
        - $ref: "#/components/parameters/BrandCompanyId"
      responses:
        "200":
          description: The company's brand profile.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandProfile"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      operationId: updateBrandProfile
      summary: Update a company's brand profile
      description: |
        Updates one or more brand fields and returns the full profile as it now stands.
        Send only the fields you are changing; anything you omit keeps its current value.

        The request is flat and uses the storage field names (`primary_color`,
        `font_primary`, `pdf_copyright_line`); the response groups the same values under
        `colors`, `typography`, and `pdf`. Every value must be a string or `null` —
        `null` clears an optional field. An unrecognised field name, a non-string value,
        or a color that is not `#rrggbb` is rejected with `422` and nothing is written.

        Saving a brand change regenerates that company's **default** gift-card PDF as a
        new version. It never changes which template is live: a company running a
        published partner override keeps it until it reverts explicitly.

        This operation is not idempotency-keyed. It is a last-writer-wins update, so
        serialise concurrent edits on your side.

        Requires `brand:write` scope.
      tags: [Brand]
      x-lifepeaks-scope: brand:write
      parameters:
        - $ref: "#/components/parameters/BrandCompanyId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BrandProfilePatchRequest"
      responses:
        "200":
          description: The updated brand profile.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandProfile"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/companies/{company_id}/brand/assets:
    get:
      operationId: listBrandAssets
      summary: List a company's brand assets
      description: |
        Lists the brand images currently stored for the company, one entry per role that
        has an image. A role that has never been uploaded is simply absent, so an
        unbranded company answers `200` with an empty `data` array rather than `404`.

        Requires `brand:read` scope.
      tags: [Brand]
      x-lifepeaks-scope: brand:read
      parameters:
        - $ref: "#/components/parameters/BrandCompanyId"
      responses:
        "200":
          description: The brand assets that exist for this company.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandAssetList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/companies/{company_id}/brand/assets/{asset}:
    put:
      operationId: replaceBrandAsset
      summary: Replace one brand asset
      description: |
        Uploads the image for one brand role, replacing whatever that role held before.
        Send the image as `multipart/form-data` under the form field name `file`.

        Accepted formats are PNG and JPEG, up to 5 MB. Anything else — a missing file, a
        failed upload, an unsupported extension, an oversized file, or an unknown role —
        is rejected with `422` and the stored asset is left untouched.

        Replacing an asset regenerates the company's **default** gift-card PDF as a new
        version, exactly as a brand-profile update does, and likewise never switches a
        published partner override back to the default.

        Requires `brand:write` scope.
      tags: [Brand]
      x-lifepeaks-scope: brand:write
      parameters:
        - $ref: "#/components/parameters/BrandCompanyId"
        - $ref: "#/components/parameters/BrandAssetRole"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The PNG or JPEG image to store for this role. Maximum 5 MB.
      responses:
        "200":
          description: The stored asset.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandAssetResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

    delete:
      operationId: deleteBrandAsset
      summary: Delete one brand asset
      description: |
        Removes the stored image for one brand role. The company falls back to whatever
        Lifepeaks renders without that image — deleting the logo does not break the
        generated gift-card PDF, it simply produces one with no logo.

        Deleting regenerates the company's **default** gift-card PDF as a new version.
        Deleting a role that holds no image is not an error. An unknown role answers
        `404`.

        Requires `brand:write` scope.
      tags: [Brand]
      x-lifepeaks-scope: brand:write
      parameters:
        - $ref: "#/components/parameters/BrandCompanyId"
        - $ref: "#/components/parameters/BrandAssetRole"
      responses:
        "200":
          description: The asset was removed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandAssetDeleted"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/api-keys:
    post:
      operationId: createApiKey
      summary: Issue a new API key
      description: |
        Mints a new API key for the calling company and returns its plaintext value.

        **The key is shown once.** It appears only in the `key` field of this response and
        is stored hashed — Lifepeaks cannot show it to you again. Write it straight into
        a managed secret store; if you lose it, revoke the key and issue another.

        A new key can never carry more scopes than the key that created it, so issue
        narrow keys from a broad one rather than the other way round. Give each
        integration its own key with only the scopes it needs, so one can be revoked
        without disturbing the rest.

        Key management always acts on the key's own company and takes no `company`
        parameter, so an agency cannot mint credentials inside a company assigned to it.

        Requires `credentials:write` scope.
      tags: [API Keys]
      x-lifepeaks-scope: credentials:write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ApiKeyCreateRequest"
      responses:
        "201":
          description: API key created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyCreated"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationError"

    get:
      operationId: listApiKeys
      summary: List API keys
      description: |
        Returns every API key issued for the calling company, including keys that have
        been revoked or have expired, so the list doubles as a credential audit. Each
        entry carries its label, scopes, environment, and the timestamps that say whether
        it is still usable.

        Plaintext key values are never returned here — a key is shown only by the call
        that created it. Use the numeric `id` from this list to revoke one.

        Omitting `limit` returns every key. Send `limit` and `starting_after` to page.
        This route takes no `company` parameter. Requires `credentials:read` scope; it is
        read-only and safe to repeat.
      tags: [API Keys]
      x-lifepeaks-scope: credentials:read
      parameters:
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: List of API keys
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, next_cursor]
                properties:
                  object:
                    const: list
                    description: Always `list`.
                  data:
                    type: array
                    description: Keys issued for this company, current and retired, newest first.
                    items:
                      $ref: "#/components/schemas/ApiKey"
                  has_more:
                    type: boolean
                    description: Whether more keys follow this page.
                    example: false
                  next_cursor:
                    type: [string, "null"]
                    description: |
                      Send this back as `starting_after` for the next page, unchanged.
                      `null` when this is the last page.
                    example: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/api-keys/{id}:
    delete:
      operationId: revokeApiKey
      summary: Revoke an API key
      description: |
        Permanently revokes one key. Every subsequent request presenting it answers
        `401`, immediately and without a grace period — so deploy the replacement before
        revoking the key it replaces.

        The record itself is retained, with `revoked_at` set, so the key stays visible in
        `GET /v2/api-keys` as an audit trail. Revocation cannot be undone; issue a new key
        instead.

        A key can revoke itself. Requires `credentials:write` scope.
      tags: [API Keys]
      x-lifepeaks-scope: credentials:write
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
          description: |
            The key's numeric `id`, as returned by `GET /v2/api-keys` and by the create
            response. This is not the `lp_live_…` value and not the public key id.
          example: 42
      responses:
        "204":
          description: Key revoked — no response body
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/products:
    get:
      tags: [Catalog]
      summary: List purchasable products
      operationId: listProducts
      description: |
        Returns the products the acting company can currently sell, with the limits,
        options and fees that apply to that company. Read it at the start of a buyer
        session and render the amount field, quantity control, and delivery choice from
        the response rather than hardcoding them — the values are per-company
        configuration and change without a new API version.

        Four product families share the list, and the `type` field says which one an
        entry is. `gift_card_value` is the company's variable-value gift card, priced by
        the amount the buyer chooses. A `gcv_<id>` product is one experience, an
        `evt_<id>` product one event, and an `so_<id>` product one special offer; each of
        those three is priced by the option the buyer picks — `gcvo_<id>`, `evto_<id>` or
        `sov_<id>` — so its money sits on `options` rather than on the product.

        **Read `fee_basis` on every entry.** It is `per_line` for gift cards, experiences
        and special offers, and `per_ticket` for events, where the fee is charged once
        for every seat. A basket totalled without it is short by a ticket fee on every
        seat after the first.

        Three things a Lifepeaks operator issues by hand never appear here: ticket
        coupons, Saved Special Vouchers and New Special Vouchers. Benefit deals are
        issued to a company rather than bought, so they are absent too; they are read and
        redeemed through `/v2/items` like any other item.

        The value gift card is the first entry whenever the company has one, which keeps
        an integration that reads `data[0]` working. That is compatibility, not contract:
        branch on `type`, and read the value product at
        `GET /v2/products/gift_card_value` when it is the one you want.

        An experience does not need a value gift card behind it. A company that never
        configured gift-card amount limits sells no variable-value card, so the
        `gift_card_value` entry is simply absent and its experiences are listed, quoted
        and ordered as usual.

        Only sellable rows are published. An experience appears while it is active,
        public, of the orderable kind, inside its publication window, and holding at
        least one active option; the catalog never advertises something an order would
        refuse. An event appears only while it is active, public, **fully dated** and
        inside its open window — the Lifepeaks order form hides an undated event and
        still lets a direct link order it, and this API does neither.

        An experience carrying an active gift-card campaign is withheld entirely. The
        Lifepeaks order page prices such an experience at the campaign price and this API
        applies no campaign, so publishing it would sell the same product at full price
        on the same day. It is absent from this list, answers `404` by id, and is neither
        quotable nor orderable until the campaign ends.

        `type` and `category` narrow the list, and `limit` with `starting_after` pages
        it. A filter value no product carries answers with an empty list rather than the
        unfiltered one, so a typo cannot look like a match.

        Requires `catalog:read` scope. This operation is read-only and safe to repeat.
      x-lifepeaks-scope: catalog:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/ProductType'
        - $ref: '#/components/parameters/ProductCategory'
        - $ref: '#/components/parameters/PageLimit'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: The products available to the acting company.
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, next_cursor]
                properties:
                  object:
                    const: list
                    description: Always `list`.
                  data:
                    type: array
                    description: |
                      One entry per product the acting company can sell, the value gift
                      card first. Branch on `type` rather than on position.
                    items:
                      oneOf:
                        - $ref: '#/components/schemas/Product'
                        - $ref: '#/components/schemas/VariantProduct'
                        - $ref: '#/components/schemas/EventProduct'
                        - $ref: '#/components/schemas/SpecialOfferProduct'
                      discriminator:
                        propertyName: type
                        mapping:
                          gift_card: '#/components/schemas/Product'
                          gift_card_variant: '#/components/schemas/VariantProduct'
                          event_ticket: '#/components/schemas/EventProduct'
                          special_offer: '#/components/schemas/SpecialOfferProduct'
                  has_more:
                    type: boolean
                    description: Whether more products follow this page.
                    example: false
                  next_cursor:
                    type: [string, "null"]
                    description: |
                      Send this back as `starting_after` for the next page, unchanged.
                      `null` when this is the last page.
                    example: null
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/products/{product_id}:
    get:
      tags: [Catalog]
      summary: Get one product
      operationId: getProduct
      description: |
        Returns a single product by id, with the same limits, options and fees
        `GET /v2/products` reports for the acting company. Use it to refresh one product
        before quoting without re-reading the whole catalog.

        Every id form works: `gift_card_value` for the variable-value gift card,
        `gcv_<id>` for one experience, `evt_<id>` for one event, `so_<id>` for one
        special offer. The response is the product object itself, not wrapped in a list
        envelope.

        Every miss is the same `404 not_found`, never a `403`. Another company's product,
        a private one, one outside its publication window, one running a gift-card
        campaign, an undated event, a benefit deal, and a plain typo all answer
        identically, so the response cannot confirm that a foreign product exists.
        `gift_card_value` answers `404` too for a company that configured no gift-card
        amount limits, because it sells no variable-value card.

        Requires `catalog:read` scope. This operation is read-only and safe to repeat.
      x-lifepeaks-scope: catalog:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/ProductId'
      responses:
        '200':
          description: The requested product.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/Product'
                  - $ref: '#/components/schemas/VariantProduct'
                  - $ref: '#/components/schemas/EventProduct'
                  - $ref: '#/components/schemas/SpecialOfferProduct'
                discriminator:
                  propertyName: type
                  mapping:
                    gift_card: '#/components/schemas/Product'
                    gift_card_variant: '#/components/schemas/VariantProduct'
                    event_ticket: '#/components/schemas/EventProduct'
                    special_offer: '#/components/schemas/SpecialOfferProduct'
        '404': { $ref: '#/components/responses/NotFound' }
        default: { $ref: '#/components/responses/Error' }
  /v2/pickup-points:
    get:
      tags: [Catalog]
      summary: Find the courier's collection points near a postcode
      operationId: listPickupPoints
      description: |
        Returns the shops and parcel lockers the courier can deliver a printed gift card
        to, near one postcode. Ask for them once the buyer has given a postcode, and let
        the buyer choose one.

        This is the one delivery fact not already on the product. Which methods a
        company offers, what each costs and where the courier ships are published in
        `delivery_methods` on `GET /v2/products`, because they change with a company's
        configuration and not with a request. Collection points open, close and move, so
        they are asked of the courier there and then.

        That also makes this the one catalog route with a rate limit: 60 lookups per
        company per minute. Past that it answers `429` with `error.code` of
        `rate_limited` and a `Retry-After` header. Look points up when the buyer finishes
        typing a postcode, not on every keystroke.

        `country` must be a two-letter ISO 3166-1 code, and it must be one the product's
        postal delivery method lists as a destination. A country name instead of a code
        is refused rather than guessed at, because the courier reads anything it does not
        recognise as Denmark and would answer Danish points for a foreign postcode.

        A postcode the courier serves no points near answers an empty list, not an
        error. An environment that cannot reach the courier answers `503` with
        `pickup_lookup_unavailable` instead, so an empty list always means "no points
        here" and never "the lookup is broken".

        Requires `catalog:read` scope. This operation is read-only and safe to repeat.
      x-lifepeaks-scope: catalog:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - name: country
          in: query
          required: true
          description: |
            Destination country as a two-letter ISO 3166-1 code, for example `DK`. It
            must be one the product's postal delivery method lists as a destination.
          schema: { type: string, pattern: '^[A-Za-z]{2}$' }
          example: DK
        - name: postcode
          in: query
          required: true
          description: |
            The buyer's postcode, at most 12 letters, digits, spaces or hyphens, and
            starting with a letter or a digit.
          schema: { type: string, maxLength: 12, pattern: '^[A-Za-z0-9][A-Za-z0-9 \-]{0,11}$' }
          example: "2200"
      responses:
        '200':
          description: The courier's collection points near that postcode.
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, next_cursor]
                properties:
                  object:
                    const: list
                    description: Always `list`.
                  data:
                    type: array
                    description: |
                      The points the courier returned, in its own order. Empty when it
                      serves none near that postcode.
                    items: { $ref: '#/components/schemas/PickupPoint' }
                  has_more:
                    type: boolean
                    description: |
                      Always `false`. The courier answers with every point it has for a
                      postcode, so there is nothing to page through.
                    example: false
                  next_cursor:
                    type: [string, "null"]
                    description: Always `null`, for the same reason `has_more` is always `false`.
                    example: null
        '422': { $ref: '#/components/responses/ValidationError' }
        '429':
          description: |
            Too many lookups for this company in the last minute. Wait for the number of
            seconds in `Retry-After` and try again.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error:
                  code: rate_limited
                  message: Too many pickup-point lookups. Try again in 23 seconds.
        '503':
          description: |
            Collection points cannot be looked up right now — this environment cannot
            reach the courier. It is NOT an empty result: do not tell a buyer their
            postcode has no collection points on the strength of it. Wait for the
            seconds in `Retry-After` and ask again.
          headers:
            Retry-After:
              description: Seconds to wait before asking again.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error:
                  code: pickup_lookup_unavailable
                  message: Collection points cannot be looked up right now. This is not an empty result.
        default: { $ref: '#/components/responses/Error' }
  /v2/greeting-images/upload-intents:
    post:
      tags: [Greeting]
      summary: Reserve a slot for a buyer's own picture
      operationId: createGreetingImageUploadIntent
      description: |
        Step one of putting a buyer's own photograph on the greeting page. You declare
        the file you are about to upload — its SHA-256 checksum, exact byte size, and
        MIME type — and Lifepeaks returns a short-lived, single-purpose upload target.

        Each declaration is checked before a slot is reserved, and each has its own
        `error.code` on `422`: `invalid_sha256` for anything that is not 64 lowercase
        hexadecimal characters, `invalid_byte_size` for a size that is not a whole number
        of at most 10,485,760 bytes, and `invalid_mime` for a type outside `image/jpeg`,
        `image/png` and `image/webp`.

        Declaring the file up front is what makes the upload verifiable: the bytes that
        arrive are checked against the checksum and size you committed to here, so a
        truncated or substituted file is rejected at finalize rather than printed on a
        card.

        A greeting picture may be `image/jpeg`, `image/png` or `image/webp`, and at most
        10,485,760 bytes.

        **The slot expires 15 minutes after it is created.** Upload and finalize within
        that window; afterwards, reserve a new slot rather than retrying the old one. An
        expired slot that was never used is deleted, along with anything uploaded to it.

        The response carries `upload.url`, `upload.method`, and `upload.headers`. **Send
        the bytes to the URL the slot gives you, with its headers exactly as given** —
        do not hardcode either. In a deployed environment the URL is a short-lived
        signed storage URL and the headers carry its signature, so dropping or altering
        one makes the upload fail the signature check; only in local development is the
        target a route on this API.

        **Idempotency.** `Idempotency-Key` is required. A replay returns the same slot,
        with a freshly generated upload target — the target itself is never stored, so a
        replayed slot that has already expired answers an error and you must reserve a
        new one.

        Requires `orders:create` scope: staging a buyer's picture is part of placing that
        buyer's order.
      x-lifepeaks-scope: orders:create
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/GreetingImageUploadIntentRequest' }
      responses:
        '201':
          description: Upload slot reserved, with its short-lived upload target.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GreetingImageUploadIntentEnvelope' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/greeting-images/upload-intents/{intent_id}/content:
    put:
      tags: [Greeting]
      summary: Upload the declared bytes (local development only)
      operationId: uploadGreetingImageContent
      description: |
        Receives the raw image bytes for an upload slot. The body is the file itself,
        not a multipart form.

        **Do not call this path directly.** It is the upload target a slot hands out in
        local development; in a deployed environment the slot returns a short-lived
        signed storage URL instead and this route is never used. Always send the bytes
        to `upload.url` from the slot's response, with the method and headers it names,
        so the same integration code works in every environment.

        **`Content-Length` is required here.** Without a declared length there is
        nothing to check the upload against before reading it, and a chunked body would
        slip past the size guard entirely, so a request that omits it — or sends a
        non-numeric value — answers `411` with `error.code` of `length_required`. A
        declared length above 10,485,760 bytes is rejected before the body is read at
        all.

        Answering `202` means the bytes were stored, not that they were accepted — the
        checksum, the size and the file's own leading bytes are all verified at
        finalize.

        Requires `orders:create` scope.
      x-lifepeaks-scope: orders:create
      parameters:
        - $ref: '#/components/parameters/GreetingUploadIntentId'
        - $ref: '#/components/parameters/Company'
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
              description: The raw image bytes, matching the checksum and size the slot declared.
      responses:
        '202':
          description: Bytes stored, pending validation at finalize.
          content:
            application/json:
              schema:
                type: object
                required: [upload_intent]
                properties:
                  upload_intent:
                    type: object
                    description: The slot the bytes were stored against.
                    required: [id, status]
                    properties:
                      id:
                        type: string
                        pattern: '^gimg_[a-f0-9]{32}$'
                        description: The upload slot id from the path.
                        example: gimg_5a3c81e0d74b426f90ac17b5e2d8630f
                      status:
                        type: string
                        enum: [pending, finalized, consumed]
                        description: |
                          The slot's state, which storing bytes does not change: it is
                          still `pending` until finalize has checked them. The `202` is
                          what tells you the bytes arrived. An upload slot only ever has
                          these three states.
                        example: pending
        '404': { $ref: '#/components/responses/NotFound' }
        '411':
          description: |
            The request carried no usable `Content-Length`. The upload was not read.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error:
                  code: length_required
                  message: Send the upload with a Content-Length header.
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/greeting-images/upload-intents/{intent_id}/finalize:
    post:
      tags: [Greeting]
      summary: Turn an upload into a usable greeting picture
      operationId: finalizeGreetingImage
      description: |
        Step three. Checks the uploaded bytes against the slot and turns them into a
        picture an order can name as `greeting.image.upload_id`.

        Finalizing checks the declared SHA-256, the byte size, and the file's own
        leading bytes. The declared MIME type is only a claim: a file announced as
        `image/png` that does not begin with PNG's signature is refused, because what
        this accepts is drawn into a PDF and e-mailed.

        **The picture is single-use.** Exactly one order may name it, which is what
        stops one buyer's photograph reaching another buyer's card. Reserve a new slot
        for every buyer.

        A finalized picture that no order uses is deleted after seven days. Finalize
        when the buyer has chosen the picture, not days ahead of checkout.

        Rejections carry a specific `error.code` so your integration can tell the buyer
        what to fix:

        | `error.code` | Status | Meaning |
        |---|---|---|
        | `upload_checksum_mismatch` | 422 | The bytes do not match the declared SHA-256 |
        | `upload_size_mismatch` | 422 | The bytes do not match the declared byte size |
        | `upload_mime_mismatch` | 422 | The file is not the image type it was declared as |
        | `uploaded_object_missing` | 422 | Nothing was uploaded for this slot |
        | `upload_intent_expired` | 422 | The 15-minute window closed; reserve a new slot |
        | `upload_intent_already_finalized` | 409 | Handled as a replay, not an error, when the same bytes are finalized again |
        | `greeting_image_already_used` | 409 | An order has already taken this picture |
        | `image_store_unavailable` | 503 | The bytes are fine; storing them failed. Retry after `Retry-After` |
        | `not_found` | 404 | No such slot for this company. The shared 404 envelope, so it reads the same as any other miss |

        **Idempotency.** `Idempotency-Key` is required. Finalizing the same slot twice
        returns the same picture rather than storing a second one.

        Requires `orders:create` scope.
      x-lifepeaks-scope: orders:create
      parameters:
        - $ref: '#/components/parameters/GreetingUploadIntentId'
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '201':
          description: A stored picture, ready to be named on one order.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GreetingImageEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '503':
          description: |
            The picture could not be stored right now — object storage refused the
            write, or the image pipeline is unavailable. Your upload was accepted and
            is unchanged: wait for the seconds in `Retry-After` and finalize again with
            the same `Idempotency-Key`.
          headers:
            Retry-After:
              description: Seconds to wait before finalizing again.
              schema: { type: integer, example: 30 }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error:
                  code: image_store_unavailable
                  message: >-
                    The picture could not be stored right now. The upload is still
                    valid — finalize it again shortly.
        default: { $ref: '#/components/responses/Error' }
  /v2/quotes:
    post:
      tags: [Quotes]
      summary: Create an authoritative quote
      operationId: createQuote
      description: |
        Prices a buyer's selection and returns the amount Lifepeaks will actually charge,
        broken down into subtotal, fees, tax, and total. Call it whenever the selection
        changes and render the returned figures — never a total your own frontend
        computed. Lifepeaks recalculates the payable amount again at order creation, so a
        locally derived total that disagrees will simply be overruled.

        A selection is either one value gift-card line or one to twenty experience lines,
        never both — the two families cannot share an order, and a mixed request is
        rejected with `422`. An experience line names a product and one of its options
        and carries no money of its own: the option's configured price is authoritative,
        so sending `amount` on such a line is rejected too. Gift-card campaigns are not
        applied to experience lines.

        A quote carries an `expires_at`. Refresh it once that passes, and refresh it
        before sending the buyer to checkout if the selection changed in between.

        Quoting reserves nothing and charges nothing, so it is safe to repeat and takes
        no idempotency key. A stock-limited option is priced only up to its remaining
        stock, but nothing is held, so the same check runs again when the order is
        created. Requires `quotes:create` scope.
      x-lifepeaks-scope: quotes:create
      parameters:
        - $ref: '#/components/parameters/Company'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuoteRequest' }
      responses:
        '200':
          description: The Lifepeaks-calculated quote for this selection.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quote' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/orders:
    get:
      tags: [Orders]
      summary: List orders
      operationId: listOrders
      description: |
        Returns the acting company's orders, newest first, as the same full order object
        `GET /v2/orders/{order_id}` returns — parties, greeting and delivery included.
        Use it to reconcile a day's takings, to find the order behind a buyer's e-mail
        address when they write in, or to sweep up orders whose webhook you missed.

        **It is not a substitute for reading one order.** When you need the authoritative
        state of a particular order, read that order: this is a window over many, and a
        filtered page can lag a state change you are waiting on.

        Filters combine with AND, and each may be repeated. `status=paid&status=fulfilled`,
        `status[]=paid&status[]=fulfilled` and `status=paid,fulfilled` all mean the same
        thing, because that is what different HTTP clients produce for "more than one of
        these". An unknown value in `status` or `payment_status` answers `422` naming the
        values that exist, rather than quietly matching nothing.

        `email` matches the BUYER, which is the card's sender — the person who paid — not
        the recipient it was addressed to. Capitalisation is ignored.

        Pages with `limit` and `starting_after`, answering `has_more` and `next_cursor`.
        Without a `limit` you get the first 25 orders. The orders arrive under `data`, in
        the same `{object: "list", data: [...]}` envelope `GET /v2/products` and
        `GET /v2/companies` answer with.

        Requires `orders:read` scope. This operation is read-only and safe to repeat.
      x-lifepeaks-scope: orders:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - name: status
          in: query
          required: false
          description: Lifecycle states to include. Repeatable, or comma-separated.
          schema:
            type: array
            items:
              type: string
              enum: [pending_payment, paid, fulfilled, cancelled, expired]
          example: [paid, fulfilled]
        - name: payment_status
          in: query
          required: false
          description: Payment states to include. Repeatable, or comma-separated.
          schema:
            type: array
            items:
              type: string
              enum: [pending, authorized, captured, failed, cancelled, refunded, partially_refunded]
          example: [captured]
        - name: created_after
          in: query
          required: false
          description: |
            Only orders created at or after this moment. An RFC 3339 timestamp, or a
            plain `YYYY-MM-DD` date. One that cannot be read answers `422`.
          schema: { type: string }
          example: '2026-09-01T00:00:00+02:00'
        - name: created_before
          in: query
          required: false
          description: |
            Only orders created at or before this moment, in the same two formats.
          schema: { type: string }
          example: '2026-09-30'
        - name: email
          in: query
          required: false
          description: |
            Only orders whose BUYER used this e-mail address. That is the card's sender,
            the person who paid, not the recipient it was addressed to. Capitalisation is
            ignored.
          schema: { type: string, format: email }
          example: anna@example.com
        - name: client_reference
          in: query
          required: false
          description: |
            Only the orders carrying this `client_reference`, the value you sent when the
            order was created. It is how you find your own basket again.
          schema: { type: string }
          example: cart_918233
        - name: limit
          in: query
          required: false
          description: How many orders to return, 1 to 100. Defaults to 25.
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
          example: 25
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: A page of orders, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, next_cursor]
                properties:
                  object:
                    const: list
                    description: Always `list`.
                  data:
                    type: array
                    description: |
                      The matching orders, newest first. An empty array is a successful
                      "nothing matched", not an error.
                    items: { $ref: '#/components/schemas/Order' }
                  has_more:
                    type: boolean
                    description: |
                      Whether another page follows. Stop when it is `false`; never infer
                      the end of a list from a short page.
                    example: true
                  next_cursor:
                    type: [string, "null"]
                    description: |
                      Send this back as `starting_after` for the next page, unchanged.
                      `null` when this is the last page.
                    example: po_2f8c1d4e6a7b8c9d0e1f2a3b4c5d6e7f
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
    post:
      tags: [Orders]
      summary: Create an unpaid order
      operationId: createOrder
      description: |
        Creates an order in `pending_payment` and returns its canonical `po_…` id. Call
        it after the buyer confirms the quoted selection and before sending them to
        checkout. Lifepeaks recalculates the payable amount here, so compare the returned
        `amounts` with the quote you displayed before redirecting.

        Creating an order takes no money, confirms no payment, and issues no gift card.
        Fulfilment begins only after Lifepeaks confirms payment.

        **One order is one payment, however many lines it has.** A value order is one
        line; an experience order is one to twenty, each priced by the option it names.
        The response echoes them as `line_items`, each with its own id, fee and subtotal.
        An order is all-or-nothing: if any line cannot be written, nothing is created and
        the idempotency key is released so the identical request can simply be retried.

        **The total must be a number a payment can hold.** A Lifepeaks payment amount
        carries at most six significant digits, so `9000.70` and `100050.00` are storable
        and `10024.95` is not — a rule about digits, not about size. The subtotal, the
        fees and the total are checked before anything is written, and a total past the
        limit answers `422` with `error.code` `amount_precision_unsupported` on `items`:
        no order, no payment, no card, no stock movement, and the key released. Split the
        basket over two orders, or pick amounts that leave no øre on the total.
        `POST /v2/quotes` does not apply this rule, so a basket that priced cleanly can
        still be refused here.

        **Idempotency.** `Idempotency-Key` is required. Generate and durably store one key
        per order attempt before the first request, then reuse that exact key for every
        retry of the same order. A retry with the same key and the same body replays the
        original response instead of creating a second order — no second card is issued
        and no stock is deducted twice; the same key with a different body answers `409
        idempotency_conflict`. A duplicate that arrives while the first request is still
        running answers `409 request_in_progress` with `Retry-After`. Use a separate key
        for the checkout-session call — never the order key.

        Requires `orders:create` scope.
      x-lifepeaks-scope: orders:create
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderRequest' }
      responses:
        '201':
          description: Unpaid order. Creation never confirms payment.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422':
          description: |
            The request broke a business or input rule, and nothing is written whichever
            one it was.

            | `error.code` | Meaning |
            |---|---|
            | `validation_failed` | A field is missing, malformed, or not one this company offers |
            | `amount_precision_unsupported` | The order total needs more precision than a payment amount can hold |
            | `delivery_method_unavailable` | `delivery.method` is not one this company offers |
            | `delivery_destination_unavailable` | The courier does not deliver that way to that country |
            | `send_at_unsupported` | The chosen method cannot be scheduled |
            | `greeting_image_not_found` | The `design_id` or `upload_id` names no picture this company can use |
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                validationFailed:
                  summary: A line broke a rule
                  value:
                    error:
                      code: validation_failed
                      message: The commerce request is invalid.
                      fields:
                        items.0.quantity:
                          - We are sorry, You can buy maximum 20 number of vouchers.
                amountPrecisionUnsupported:
                  summary: The total needs more than six significant digits
                  value:
                    error:
                      code: amount_precision_unsupported
                      message: The commerce request is invalid.
                      fields:
                        items:
                          - >-
                            This order total cannot be stored exactly (LIF-439): a
                            payment amount may carry at most 6 significant digits. Order
                            fewer lines, or choose amounts with no øre.
        default: { $ref: '#/components/responses/Error' }
  /v2/orders/{order_id}:
    get:
      tags: [Orders]
      summary: Get canonical order state
      operationId: getOrder
      description: |
        Returns the authoritative state of one headless order: its lifecycle `status`,
        the `payment` and `fulfillment` sub-states, the amounts Lifepeaks charged, and
        any settlement metadata.

        This is the only trustworthy evidence that a buyer paid. A browser returning from
        checkout proves nothing, however successful its URL looks — resolve your own
        checkout reference to the stored `po_…` id on your server and read the order.
        Poll it with backoff on the return page, and treat a signed webhook as a prompt
        to read it rather than as the answer itself.

        An order belonging to another company answers `404`, exactly as an unknown id
        does. Requires `orders:read` scope; this operation is read-only and safe to
        repeat.
      x-lifepeaks-scope: orders:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/PartnerOrderId'
      responses:
        '200':
          description: The canonical order.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '404': { $ref: '#/components/responses/NotFound' }
        default: { $ref: '#/components/responses/Error' }
  /v2/orders/{order_id}/refund:
    post:
      tags: [Orders]
      summary: Refund a paid order
      operationId: refundOrder
      description: |
        Returns money to the buyer through the same gateway that took it. Send an
        `amount` to refund part of the order, or no body at all to refund everything
        still refundable.

        **A test key cannot refund in production.** A refund is a payment in reverse, so
        an `lp_test_` key is answered `409` with `error.code` of
        `test_key_not_accepted_in_production`, before anything moves.

        Amounts are whole numbers in the smallest unit of the currency, like every other
        v2 amount. A decimal is refused rather than rounded: sending `12.5` means you are
        thinking in kroner, and silently making it `12` or `13` is the worst way to find
        that out.

        **Read `refunded` and `refundable` off the response rather than assuming your
        `amount` went through in full.** A gift card used as payment absorbs part of a
        refund before the card is touched, and the gateway clamps, so the figures come
        back from the payment itself rather than from what was asked for.

        Only a paid order can be refunded: anything else answers `409` with
        `order_not_refundable`, an order already refunded in full answers `409` with
        `order_fully_refunded`, and an `amount` above what is left answers `422` with
        `refund_amount_too_large` naming the figure that is still refundable. A gateway
        that declines answers `502` with `refund_declined`: nothing was refunded and
        nothing is held, so a retry with a new key is accepted straight away rather than
        having to wait the order out. Another refund of the same order genuinely still
        being processed answers `409` with `refund_in_progress` — wait a moment and send
        the same request again; a declined refund never leaves one of these behind. An
        order whose stored total cannot be represented exactly answers `409` with
        `amount_precision_unsupported`, and cannot be refunded through the API at all.

        **A refund that succeeded at the gateway is never lost.** If the money moved and
        Lifepeaks then failed to finish recording it, you still get `200` with the
        refund, and the same `Idempotency-Key` returns that same answer afterwards. That
        is the property to trust: retrying is safe, and a retry never refunds twice.

        Requires `orders:refund` scope, deliberately separate from `orders:settle`: a key
        that may settle an invoice should not thereby be able to move money back out.

        **Idempotency.** `Idempotency-Key` is required, and the amount is part of what it
        covers, so the same key with a different amount answers `409` rather than
        refunding twice.
      x-lifepeaks-scope: orders:refund
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/PartnerOrderId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: |
                Send nothing to refund everything still refundable. Any field other than
                `amount` is refused rather than ignored.
              properties:
                amount:
                  type: integer
                  minimum: 1
                  description: |
                    How much to return. Omit it to refund the whole remaining balance.
                    Whole number in the smallest unit of the currency (øre for DKK).
                    50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding
                    errors.
                  example: 12500
      responses:
        '200':
          description: What was actually refunded, read back off the payment.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderRefund' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '502':
          description: |
            The payment gateway declined the refund. Nothing was refunded, and the
            request can be retried with a new idempotency key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error:
                  code: refund_declined
                  message: The payment gateway declined the refund. Nothing was refunded.
        default: { $ref: '#/components/responses/Error' }
  /v2/orders/{order_id}/gift-card.pdf:
    get:
      tags: [Orders]
      summary: Download the fulfilled gift-card PDF
      operationId: getOrderGiftCardPdf
      description: |
        Streams the gift-card PDF Lifepeaks produced when it fulfilled the order, as
        `application/pdf` with a `Content-Disposition: attachment` filename of
        `gift-card.pdf`. Use it to attach the card to your own confirmation email or to
        offer a download from your account area.

        The document exists only once fulfilment has completed. An order that is still
        unpaid or still processing, an order belonging to another company, and an unknown
        id all answer `404` with the standard JSON error envelope — so branch on the
        response `Content-Type`, not on the status code alone. Do not poll this endpoint
        to detect fulfilment; read `GET /v2/orders/{order_id}` and fetch the PDF once
        `fulfillment.status` is `fulfilled`.

        **This route serves a single-line order.** An order with several product lines
        has no single gift card, so it answers `409 multiple_gift_cards`; download each
        card from `GET /v2/orders/{order_id}/lines/{line_id}/gift-card.pdf` instead. A
        fulfilled multi-line order is offered no order-level `gift_card_pdf` link at all,
        so following the links on the order never reaches this refusal.

        Requires `orders:read` scope. This operation is read-only and safe to repeat.
      x-lifepeaks-scope: orders:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/PartnerOrderId'
      responses:
        '200':
          description: The gift-card PDF bytes. No storage URL or credential is exposed.
          headers:
            Content-Disposition:
              description: Always `attachment; filename="gift-card.pdf"`.
              schema:
                type: string
                example: attachment; filename="gift-card.pdf"
          content:
            application/pdf:
              schema:
                type: string
                format: binary
                description: The rendered gift-card document.
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            The order has several product lines, so no single document is the gift card.
            `error.code` is `multiple_gift_cards`; request each line's card separately.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error:
                  code: multiple_gift_cards
                  message: This order has several product lines; request each line's gift card separately.
        default: { $ref: '#/components/responses/Error' }
  /v2/orders/{order_id}/lines/{line_id}/gift-card.pdf:
    get:
      tags: [Orders]
      summary: Download one line's gift-card PDF
      operationId: getOrderLineGiftCardPdf
      description: |
        Streams the gift-card PDF of ONE product line of a fulfilled order, as
        `application/pdf` with a `Content-Disposition: attachment` filename of
        `gift-card.pdf`. An order that bought several experiences produces several cards,
        and this is the route that addresses the right one: take the `url` from that
        line's `gift_card_pdf` block on `GET /v2/orders/{order_id}` rather than
        assembling it yourself.

        **One line is one document, however many cards it bought.** A line with
        `quantity` above one is issued as a bundle, and this download lays every card of
        the bundle out in a single PDF — the same document the Lifepeaks order page has
        always produced for a bundle. There is no per-card route and no per-card id.

        It answers exactly like the order-level route in every other respect. The
        document exists only once fulfilment has completed; an unfulfilled order, a line
        of a different order, an order belonging to another company, and an unknown id
        all answer `404` with the standard JSON error envelope, so branch on the response
        `Content-Type` rather than on the status code alone. A line whose cards have since
        been deleted answers `404` as well, rather than handing back a gift card with no
        code on it.

        An experience carrying its own PDF template is branded by that template, so the
        cards of one order need not look alike.

        Requires `orders:read` scope. This operation is read-only and safe to repeat.
      x-lifepeaks-scope: orders:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/PartnerOrderId'
        - $ref: '#/components/parameters/PartnerOrderLineId'
      responses:
        '200':
          description: |
            This line's gift-card PDF bytes. No storage URL or credential is exposed.
          headers:
            Content-Disposition:
              description: Always `attachment; filename="gift-card.pdf"`.
              schema:
                type: string
                example: attachment; filename="gift-card.pdf"
          content:
            application/pdf:
              schema:
                type: string
                format: binary
                description: The rendered gift-card document for this line.
        '404': { $ref: '#/components/responses/NotFound' }
        default: { $ref: '#/components/responses/Error' }
  /v2/orders/{order_id}/checkout-sessions:
    post:
      tags: [Orders]
      summary: Create Lifepeaks-hosted checkout
      operationId: createCheckoutSession
      description: |
        Opens a short-lived Lifepeaks-hosted QuickPay checkout for an unpaid order and
        returns the `url` to redirect the buyer to. Lifepeaks takes the payable amount,
        currency, approved return URL, and payment configuration from the order and the
        company, so the call carries **no request body** — sending one is rejected with
        `422`. A partner cannot substitute the merchant, the amount, the commission, the
        callback, or the capture decision.

        The session expires. If the buyer abandons checkout, create a **new session for
        the same order** with a new idempotency key; never create a second order.

        **Idempotency.** `Idempotency-Key` is required and must be independent of the key
        used to create the order. A first execution answers `201`; a replay of the same
        key answers `200` with the same session, and reusing an active session is
        deliberate rather than an error. Both statuses are success. The same key with a
        different target answers `409`, and `409` with `Retry-After: 1` means an
        identical request is still in flight — wait and retry rather than generating a
        new key.

        `503` means the payment provider was unreachable; the order is untouched and the
        call can be retried with the same key. Requires `checkout:create` scope.
      x-lifepeaks-scope: checkout:create
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/PartnerOrderId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Reused active checkout session.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckoutSession' }
        '201':
          description: New checkout session.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckoutSession' }
        '409':
          description: |
            An idempotency conflict, a checkout already being created, or a test key
            asking production to take a payment.

            | `error.code` | Meaning | Retry? |
            |---|---|---|
            | `test_key_not_accepted_in_production` | Production's payment credentials are live, so a test key cannot start checkout there | No — use a live key, or this key on demo |
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '503': { $ref: '#/components/responses/Error' }
        default: { $ref: '#/components/responses/Error' }
  /v2/orders/{order_id}/settle:
    post:
      tags: [Orders]
      summary: Apply contract-gated settlement
      operationId: settleOrder
      description: |
        Records that an order was settled outside Lifepeaks-hosted checkout — by invoice,
        on location, or through a payment processor the partner operates — and returns
        the order with immutable, audit-backed settlement metadata attached.

        This is **not** a public alternative to hosted checkout. It is disabled by
        default and requires the `orders:settle` scope **and** an active negotiated
        commercial entitlement covering the company, the credential, and the specific
        settlement mode; without one the call answers `403`. It also cannot be used once
        a hosted checkout session has started for the order, which answers `409`. Check
        `commerce.modes` on `GET /v2/me` to see which modes a company actually holds.

        **Idempotency.** `Idempotency-Key` is required and must be independent of the
        order and checkout keys. Settlement is recorded once; a replay of the same key
        returns the same result rather than settling twice.

        Do not design an integration around this operation until Lifepeaks has confirmed
        the contract.
      x-lifepeaks-scope: orders:settle
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/PartnerOrderId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [mode, external_reference]
              properties:
                mode:
                  description: |
                    The settlement mode to record. It must be one the company holds an
                    active entitlement for, as reported by `commerce.modes` on
                    `GET /v2/me`; anything else answers `403`.
                    `invoice_settlement` — Lifepeaks invoices the partner.
                    `pay_on_location` — the buyer pays at the venue.
                    `external_processor` — the partner's own processor took the payment.
                  enum: [invoice_settlement, pay_on_location, external_processor]
                  example: invoice_settlement
                external_reference:
                  type: string
                  minLength: 1
                  maxLength: 200
                  description: |
                    Your own reference for the settlement — the invoice number, till
                    receipt, or processor transaction id it can be reconciled against.
                    Stored verbatim on the immutable audit record, so send something
                    stable and meaningful rather than a random token. 1 to 200 characters.
                  example: INV-2026-004417
            example:
              mode: invoice_settlement
              external_reference: INV-2026-004417
      responses:
        '200':
          description: Settled order with immutable audit-backed settlement metadata.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/webhook-endpoints:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      operationId: listWebhookEndpoints
      description: |
        Returns every webhook endpoint registered for the acting company, active and
        disabled alike, with its subscribed event types, consecutive failure count, and
        current secret version. Use it to audit what is registered and to spot an
        endpoint Lifepeaks has disabled after repeated failures.

        Signing secrets are never returned here. A secret is shown exactly once, by the
        call that creates it or rotates it; `secret_last4` is only enough to tell two
        secrets apart in a log.

        Omitting `limit` returns every endpoint. Send `limit` and `starting_after` to
        page.

        Requires `webhooks:read` scope. This operation is read-only and safe to repeat.
      x-lifepeaks-scope: webhooks:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/PageLimit'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: Endpoint list. Signing secrets are never returned.
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, next_cursor]
                properties:
                  object:
                    const: list
                    description: Always `list`.
                  data:
                    type: array
                    description: Endpoints registered for the acting company, oldest first.
                    items: { $ref: '#/components/schemas/WebhookEndpoint' }
                  has_more:
                    type: boolean
                    description: Whether more endpoints follow this page.
                    example: false
                  next_cursor:
                    type: [string, "null"]
                    description: |
                      Send this back as `starting_after` for the next page, unchanged.
                      `null` when this is the last page.
                    example: null
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
    post:
      tags: [Webhooks]
      summary: Register a webhook endpoint
      operationId: createWebhookEndpoint
      description: |
        Registers a publicly reachable HTTPS URL to receive signed order events, and
        returns its `whsec_…` signing secret.

        **The secret is shown once.** Only the `201` first execution carries it; store it
        in a managed secret store before doing anything else. If that response is lost,
        rotate the secret rather than registering a duplicate endpoint.

        **Idempotency.** `Idempotency-Key` is required. A replay of the same key answers
        `200` with the endpoint but deliberately **without** the secret, so a retry can
        never leak it a second time. The same key with a different body answers `409`.

        Requires `webhooks:write` scope. See the signed-webhooks guide for the
        verification contract you must implement before going live.
      x-lifepeaks-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointRequest' }
      responses:
        '200':
          description: Idempotent replay of the previously created endpoint, without the secret.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '201':
          description: Newly created endpoint with its signing secret shown once.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpointWithSecret' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/webhook-endpoints/{endpoint_id}/rotate-secret:
    post:
      tags: [Webhooks]
      summary: Rotate an endpoint signing secret
      operationId: rotateWebhookSecret
      description: |
        Issues a new signing secret for one endpoint, increments its `secret_version`,
        and returns the new plaintext secret once. Rotate on a schedule, and immediately
        whenever a secret may have been exposed or a registration response was lost.

        **Lifepeaks runs the overlap for you.** The secret being replaced stays valid for
        24 hours, and the response returns the deadline as `previous_secret_expires_at`.
        Every delivery in that window is signed with **both** secrets, so a receiver that
        still holds only the old one keeps verifying while you deploy the new one. There
        is no downtime to engineer around and nothing to switch over at a precise moment:
        install the new secret before the deadline and the old one expires by itself.

        During the window `Lifepeaks-Webhook-Signature` carries comma-separated `v1=`
        values, newest first. Compare your computed signature against **every** value in
        the header and accept a match on any of them — do not try to select one by
        version. `Lifepeaks-Webhook-Secret-Version` names the current version only, so it
        cannot tell you which signature a previous secret produced. The verifier published
        in the signed-webhooks guide already does this correctly.

        Rotating again inside an open window retires the older secret immediately: only
        two secrets are ever live at once.

        **Idempotency.** `Idempotency-Key` is required and must be specific to this
        rotation — reusing the registration key answers `409`. Only the first execution
        carries `secret`; a replay returns the endpoint without it, so treat a missing
        `secret` as "this rotation already happened" and go find the value you stored.

        Requires `webhooks:write` scope.
      x-lifepeaks-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/EndpointId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: >-
            Updated endpoint. The first execution includes the new one-time secret;
            an idempotent replay returns the endpoint without the secret.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpointSecretResult' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422':
          $ref: '#/components/responses/ValidationError'
        default: { $ref: '#/components/responses/Error' }
  /v2/webhook-endpoints/{endpoint_id}/ping:
    post:
      tags: [Webhooks]
      summary: Send a test event to an endpoint
      operationId: pingWebhookEndpoint
      description: |
        Queues a `webhook.ping` event for one endpoint and returns the delivery it
        created. Use it to prove a new endpoint end to end — that the URL is reachable,
        that your signature check accepts a real Lifepeaks signature, and that your
        handler answers `2xx` — without waiting for a buyer to place an order.

        The event is a real one. It is signed with the endpoint's current secret, carries
        the same headers every other delivery carries, retries on the same schedule, and
        appears in `GET /v2/webhook-deliveries` beside your order events. A handler that
        does not recognise `webhook.ping` should answer `2xx` and ignore it rather than
        failing, the same as any other type it does not act on.

        The body carries nothing: `data` is the endpoint's own id. Send no request
        fields — one is refused with `422` rather than ignored.

        Answers `202`, not `200`: the delivery is queued, and its outcome arrives later.
        Read it back from `GET /v2/webhook-deliveries` rather than inferring success from
        this response.

        A disabled endpoint answers `404`, the same as one that never existed.

        **Idempotency.** `Idempotency-Key` is required, and it doubles as the event's
        identity: two pings with the same key are one event and one delivery, so a
        retried request cannot flood your handler. Use a fresh key for each ping you
        actually want to send.

        Requires `webhooks:write` scope.
      x-lifepeaks-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/EndpointId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '202':
          description: The queued test delivery.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookDelivery' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/webhook-endpoints/{endpoint_id}:
    delete:
      tags: [Webhooks]
      summary: Disable a webhook endpoint
      operationId: deleteWebhookEndpoint
      description: |
        Disables one endpoint and cancels its pending deliveries. Lifepeaks stops
        attempting delivery immediately; already-delivered events are unaffected.

        The endpoint record is retained rather than erased, so its delivery history stays
        readable through `GET /v2/webhook-deliveries`. Deleting is not a way to recover a
        lost signing secret — rotate the secret instead.

        Requires `webhooks:write` scope. Repeating the call on an endpoint that is
        already gone answers `404`; treat that as "already disabled".
      x-lifepeaks-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/EndpointId'
      responses:
        '204': { description: Endpoint disabled and pending deliveries cancelled. }
        '404': { $ref: '#/components/responses/NotFound' }
        default: { $ref: '#/components/responses/Error' }
  /v2/webhook-deliveries:
    get:
      tags: [Webhooks]
      summary: List recent webhook deliveries
      operationId: listWebhookDeliveries
      description: |
        Returns the acting company's most recent delivery attempts, newest first, with
        the event each carried, how many attempts it has taken, the HTTP status your
        endpoint last returned, and when the next retry is due. Use it to diagnose a
        receiver that is rejecting events and to find deliveries worth replaying.

        This is a recent-activity window rather than a complete archive. It pages with
        `limit` and `starting_after`, like the other cursor lists: read `next_cursor` off
        a response and send it back unchanged until `has_more` is `false`. There is no
        `offset` and no `order`.

        Requires `webhooks:read` scope. This operation is read-only and safe to repeat.
      x-lifepeaks-scope: webhooks:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - name: limit
          in: query
          required: false
          description: |
            How many of the most recent deliveries to return, 1 to 100. Defaults to 50.
            This route accepts no `offset` and no `order`.
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
          example: 50
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: Most recent deliveries.
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, next_cursor]
                properties:
                  object:
                    const: list
                    description: Always `list`.
                  data:
                    type: array
                    description: Delivery attempts, newest first.
                    items: { $ref: '#/components/schemas/WebhookDelivery' }
                  has_more:
                    type: boolean
                    description: |
                      Whether another page follows. Stop when it is `false`; never infer
                      the end of a list from a short page.
                    example: true
                  next_cursor:
                    type: [string, "null"]
                    description: |
                      Send this back as `starting_after` for the next page, unchanged.
                      `null` when this is the last page.
                    example: wd_1c0e9a7f
        '422':
          description: >-
            The `starting_after` cursor names no delivery of this company. A cursor is
            resolved against the acting company, so one lifted from another tenant reads
            as unknown rather than seeking into their list. Start the listing again.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        default: { $ref: '#/components/responses/Error' }
  /v2/webhook-deliveries/{delivery_id}/replay:
    post:
      tags: [Webhooks]
      summary: Replay a delivered or exhausted delivery
      operationId: replayWebhookDelivery
      description: |
        Requeues one delivery that has finished its lifecycle — either `delivered` or
        `dead` after exhausting its retries — and returns it with `replay_count`
        incremented. Use it to recover from a receiver outage or a bug that dropped
        events your side had already acknowledged.

        A replay re-sends the original event with its original `evt_…` id, so a correctly
        implemented receiver deduplicates it and returns `204` without repeating any
        side effect. Replaying a delivery that is still pending or retrying answers
        `409`; wait for it to settle instead.

        **Idempotency.** `Idempotency-Key` is required and must be independent of the
        registration and rotation keys. A replay of the same key returns the same
        delivery rather than queuing a second copy.

        Requires `webhooks:write` scope.
      x-lifepeaks-scope: webhooks:write
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/DeliveryId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Delivery queued for replay.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookDelivery' }
        '403':
          description: >-
            `credential_not_attributed` — the key predates per-key attribution, so a
            replay cannot be recorded against it. Mint a new key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '409': { $ref: '#/components/responses/Conflict' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }

  /v2/order-page:
    get:
      operationId: getOrderPage
      summary: Get order-page config
      description: |
        Returns the current order-page configuration for the company the request acts on,
        including all safe field values and image URLs.
        Pass `lang` to retrieve i18n-translated values for a specific locale (e.g. `da-DK`, `en-GB`).
        Defaults to `en-GB`.
        Requires `orderpage:read` scope.
      tags: [Order Page]
      parameters:
        - $ref: "#/components/parameters/Company"
        - name: lang
          in: query
          required: false
          schema:
            type: string
            example: da-DK
          description: BCP 47 locale for i18n fields (default `en-GB`)
      responses:
        "200":
          description: Current order-page config
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderPageConfig"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      operationId: updateOrderPage
      summary: Update order-page config
      description: |
        Partially updates the order-page configuration.
        Only fields present in the safe-field catalog (see GET /v2/order-page/schema) are accepted.
        Sensitive fields (raw HTML/CSS/JS, payment amounts, commissions) are not writable.
        Pass `lang` (in the request body or as a query parameter) to write i18n-capable fields
        in a specific locale. Omit to update the default locale (`en-GB`).
        Returns the full updated config on success.
        Requires `orderpage:write` scope.
      tags: [Order Page]
      parameters:
        - $ref: "#/components/parameters/Company"
        - name: lang
          in: query
          required: false
          schema:
            type: string
            example: da-DK
          description: BCP 47 locale for i18n fields (overridden by `lang` in request body)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderPagePatchRequest"
      responses:
        "200":
          description: Updated order-page config
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderPageConfig"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/order-page/schema:
    get:
      operationId: getOrderPageSchema
      summary: List safe configurable fields
      description: |
        Returns the allow-list of self-serviceable order-page fields with their types,
        max lengths, i18n support, and grouping.
        Fields absent from this list are not writable via the API.
        Requires `orderpage:read` scope.
      tags: [Order Page]
      parameters:
        - $ref: "#/components/parameters/Company"
      responses:
        "200":
          description: Field schema
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderPageSchema"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/items:
    post:
      operationId: createItems
      summary: Issue invoice-settled gift cards directly
      description: |
        Negotiated compatibility operation for companies with an active Lifepeaks
        invoice-settlement contract. It creates one or more gift cards and returns the
        created items together with the `order_id` of the new order. New headless buyer
        journeys use `POST /v2/orders` and Lifepeaks-hosted checkout instead.

        The cards are created for the API key's own company, or for an assigned company
        when `company` is supplied. The order is settled by invoice; the pay-on-location
        (POS/PMS) flow of the legacy v1 API is not available to API keys.

        With `capture` left at its default `true` the cards are activated immediately.
        Pass `capture: false` to create them dormant and settle later with
        `POST /v2/orders/{order_id}/capture`.

        Delivery follows the platform rule: when `receiver_email` equals `sender_email`
        — which is what happens when both are omitted, since both default to
        `no-email@lifepeaks.dk` — **no email is sent at all** and it is up to you to
        deliver the code. Pass a distinct `receiver_email` to have Lifepeaks email the
        gift card.

        `amount` here is a decimal in the currency itself, where `500` means DKK 500.00
        — not the whole øre `POST /v2/orders` takes.

        Requires both `orders:create` and `orders:settle` scopes plus an active contract.
      tags: [Gift Cards]
      parameters:
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/RequestGuid"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GiftCardCreateRequest"
      responses:
        "201":
          description: Gift card order created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GiftCardCreated"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
    get:
      operationId: listItems
      summary: Search items
      description: |
        Lists the items (gift cards, tickets, special offers, benefit deals) of the
        company the request acts on.

        **At least one of `order_id`, `group_code` or `search` must be supplied**, and
        `search` must be 5 characters or longer — otherwise the request fails with `422`.

        A search that matches nothing is a success, not an error: the response is
        `{ "object": "list", "data": [], "has_more": false, "next_cursor": null }`.

        Requires `items:read` scope.
      tags: [Items]
      parameters:
        - $ref: "#/components/parameters/Company"
        - name: order_id
          in: query
          required: false
          schema:
            type: string
          description: |
            Return every item of this order. It also switches the sort to the order's own
            item sequence, so the rows come back in the order they were issued.
          example: devOEVLB1EW33
        - name: group_code
          in: query
          required: false
          schema:
            type: string
          description: Return every item sharing this group code.
          example: G7K2M9
        - name: search
          in: query
          required: false
          schema:
            type: string
            minLength: 5
          description: |
            Free-text search across the item code, the sender and receiver name and
            email, and a few further columns. Must be at least 5 characters; a shorter
            value fails with `422`.
          example: 77xrB3e44T
        - name: status
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/ItemStatusId"
          description: Return only items with this status id.
          example: 6
        - name: type
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/ItemTypeFilter"
          description: Return only items of this type.
          example: GIFTCARD
        - name: only_claimable
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: |
            Return only items that can currently be redeemed. Combined with `order_id`,
            an order that exists but has no claimable item yields an empty list rather
            than a 404-style "nothing to display".
          example: true
        - $ref: "#/components/parameters/DateFrom"
        - $ref: "#/components/parameters/DateTo"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Order"
      responses:
        "200":
          description: Matching items
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ItemList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/items/{code}:
    get:
      operationId: getItem
      summary: Get one item by code
      description: |
        Returns the full detail row for a single item code belonging to the company
        the request acts on. The response is the item object itself, not wrapped in a
        list. A code from another company answers `404`.

        Requires `items:read` scope.
      tags: [Items]
      parameters:
        - $ref: "#/components/parameters/ItemCode"
        - $ref: "#/components/parameters/Company"
      responses:
        "200":
          description: The item
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Item"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/items/{code}/document.pdf:
    get:
      operationId: getItemDocumentPdf
      summary: Download one issued item's document
      description: |
        Streams the printable document of one issued item as `application/pdf`, with a
        `Content-Disposition: attachment` filename of `document.pdf`.

        **It serves every family**: a gift card, an event ticket, a special-offer voucher
        or a benefit deal. The renderer dispatches on what the code actually is, so one
        route covers them all.

        This is the per-item counterpart of the per-line download on a headless order.
        Reach for `GET /v2/orders/{order_id}/lines/{line_id}/gift-card.pdf` when you hold
        a `po_…` order and want everything one line bought in a single file; reach for
        this one when you hold a code — from `GET /v2/items`, from a redemption screen,
        or from a benefit deal that was never bought through an order at all.

        It answers to exactly the same tenancy rule as reading the item, so a benefit deal
        is printable by the company it was issued to as well as by the company that issued
        it. A code belonging to another company, an unknown code, and a document that
        cannot be produced all answer the same `404` with the standard JSON error
        envelope — branch on the response `Content-Type`, not on the status alone.

        Requires `items:read` scope. This operation is read-only and safe to repeat.
      x-lifepeaks-scope: items:read
      tags: [Items]
      parameters:
        - $ref: "#/components/parameters/ItemCode"
        - $ref: "#/components/parameters/Company"
      responses:
        "200":
          description: The item's document bytes. No storage URL or credential is exposed.
          headers:
            Content-Disposition:
              description: Always `attachment; filename="document.pdf"`.
              schema:
                type: string
                example: attachment; filename="document.pdf"
          content:
            application/pdf:
              schema:
                type: string
                format: binary
                description: The rendered document.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/items/{code}/claim:
    post:
      operationId: claimItem
      summary: Redeem an item
      description: |
        Redeems (claims) an item — the whole item, a number of pieces, or an amount.

        Also accepts a **dormant campaign code**, which is activated first and then
        redeemed, and — for a company configured with the Wonderbox/GoDream
        passthrough — a numeric vendor code, which is claimed at the vendor and
        imported.

        A full-value `amount` or `piece` claim on a claimable gift card is treated as a
        claim of the whole item, so indivisible products can be redeemed through the
        same call.

        Requires `items:write` scope.
      tags: [Items]
      parameters:
        - $ref: "#/components/parameters/ItemCode"
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/RequestGuid"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ClaimRequest"
      responses:
        "200":
          description: Redemption result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClaimResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/items/{code}/refund:
    post:
      operationId: refundItem
      summary: Reverse a redemption
      description: |
        Reverses a previous redemption, returning value or pieces to the item.
        The item's `available_refund` flag must be `true`; when it is not, the
        `refund_error` text from the item row is returned as a `422`.

        Requires `items:write` scope.
      tags: [Items]
      parameters:
        - $ref: "#/components/parameters/ItemCode"
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/RequestGuid"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RefundRequest"
      responses:
        "200":
          description: Refund result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RefundResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/items/{code}/cancel:
    post:
      operationId: cancelItem
      summary: Cancel or discard an item
      description: |
        Cancels the item, or discards it when the item is in a state where discarding
        is the applicable action — the response message says which one happened.
        Takes no request body.

        Requires `items:write` scope.
      tags: [Items]
      parameters:
        - $ref: "#/components/parameters/ItemCode"
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/RequestGuid"
      responses:
        "200":
          description: Item cancelled or discarded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResult"
              example:
                message: Item 77xrB3e44T has been cancelled!
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/items/{code}/activate:
    post:
      operationId: activateItem
      summary: Activate a dormant campaign code
      description: |
        Activates a pre-printed / dormant code from one of the company's campaigns,
        turning it into a live item.

        When the campaign was set up without a fixed value, `amount` is required and
        must be numeric; when the campaign carries its own value, `amount` is ignored.

        Requires `items:write` scope.
      tags: [Items]
      parameters:
        - $ref: "#/components/parameters/ItemCode"
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/RequestGuid"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ActivateRequest"
      responses:
        "200":
          description: Item activated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ActivateResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/items/{code}/resend:
    post:
      operationId: resendItem
      summary: Re-send an item by email
      description: |
        Emails the item (gift card PDF or ticket) to the given address and records the
        resend in the item's action log.

        Requires `items:write` scope.
      tags: [Items]
      parameters:
        - $ref: "#/components/parameters/ItemCode"
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/RequestGuid"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResendRequest"
      responses:
        "200":
          description: Item re-sent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResult"
              example:
                message: Item 77xrB3e44T has been resend to buyer@example.com!
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/orders/{order_id}/capture:
    post:
      operationId: captureOrder
      summary: Activate a contract-settled compatibility order
      description: |
        Negotiated compatibility operation that activates an invoice-settled gift-card
        order created with `capture: false`. It does not capture card funds and must not
        be used to confirm buyer payment. Headless integrations confirm payment only from
        canonical order state after Lifepeaks-hosted checkout.

        Only uncaptured orders belonging to the company the request acts on can be
        activated; anything else answers `404`. Takes no request body.

        Requires `orders:settle` scope plus an active invoice-settlement contract.
      tags: [Orders]
      parameters:
        - $ref: "#/components/parameters/LegacyOrderId"
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/RequestGuid"
      responses:
        "200":
          description: Order captured
          content:
            application/json:
              schema:
                type: object
                required: [order_id, message]
                properties:
                  order_id:
                    type: string
                    description: The order that was activated, echoing the path parameter.
                    example: devOEVLB1EW33
                  message:
                    type: string
                    description: |
                      Human-readable confirmation, in the response language. The wording
                      is not a stable contract.
                    example: Order devOEVLB1EW33 has been captured!
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/orders/{order_id}/claim:
    post:
      operationId: claimOrder
      summary: Redeem a whole order
      description: |
        Fully redeems every item of an order in one call, optionally narrowed to a
        single group with `group_code`. When the order cannot be redeemed as a group
        the response is `422` and the items must be claimed one by one.

        Requires `items:write` scope.
      tags: [Orders]
      parameters:
        - $ref: "#/components/parameters/LegacyOrderId"
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/RequestGuid"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                group_code:
                  type: string
                  description: |
                    Redeem only the items sharing this group code, instead of the whole
                    order. Omit it to redeem everything.
                  example: G7K2M9
      responses:
        "200":
          description: Order redeemed
          content:
            application/json:
              schema:
                type: object
                required: [info, message]
                properties:
                  info:
                    type: string
                    description: Short summary of what happened, in the response language.
                    example: Whole Order claimed
                  message:
                    type: string
                    description: |
                      Human-readable detail naming the order, in the response language.
                      The wording is not a stable contract.
                    example: Whole Order devOEVLB1EW33 has been fully claimed!
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/events:
    get:
      operationId: listEvents
      summary: List events
      description: |
        Lists the events of the company the request acts on, with their ticket counts.

        Requires `items:read` scope.
      tags: [Events]
      parameters:
        - $ref: "#/components/parameters/Company"
        - name: online
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: |
            Return only events that are currently online — visible in the order form and
            selling tickets.
          example: true
        - name: private
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: |
            Return private events instead of public ones. Private events are reachable
            only by direct link.
          example: false
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Events
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, next_cursor]
                properties:
                  object:
                    const: list
                    description: Always `list`.
                  data:
                    type: array
                    description: The matching events, with their ticket counts.
                    items:
                      $ref: "#/components/schemas/Event"
                  has_more:
                    type: boolean
                    description: |
                      Whether another page follows. Stop when it is `false`; never infer
                      the end of a list from a short page.
                    example: true
                  next_cursor:
                    type: [string, "null"]
                    description: |
                      Send this back as `starting_after` for the next page, unchanged. It
                      is opaque: do not build one, parse one, or do arithmetic on one.
                      `null` when this is the last page.
                    example: bzI1
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/events/{slug}/participants:
    get:
      operationId: listEventParticipants
      summary: List event participants
      description: |
        Lists the participants of one event of the company the request acts on.

        Guarded by `reports:read` rather than `items:read` because the rows carry
        participant personal data.

        An event with no participants answers `200` with an empty array — that is a real
        page a cursor walk reaches. `404` means only that no such event exists for this
        company.
      tags: [Events]
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
          description: Event slug, as returned by GET /v2/events.
          example: hotel-party-nr1
        - $ref: "#/components/parameters/Company"
        - name: search
          in: query
          required: false
          schema:
            type: string
          description: |
            Free-text search over the participant rows: the buyer's name, e-mail and
            phone, the note they left at checkout, and their reference number. It does
            NOT search the ticket type.
          example: jane
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Participants
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, next_cursor]
                properties:
                  object:
                    const: list
                    description: Always `list`.
                  data:
                    type: array
                    description: |
                      The event's participants for this page. An empty array is a real
                      page — an event with no participants answers `200`, and `404` means
                      only that no such event exists for this company.
                    items:
                      $ref: "#/components/schemas/Participant"
                  has_more:
                    type: boolean
                    description: |
                      Whether another page follows. Stop when it is `false`; never infer
                      the end of a list from a short page.
                    example: true
                  next_cursor:
                    type: [string, "null"]
                    description: |
                      Send this back as `starting_after` for the next page, unchanged. It
                      is opaque: do not build one, parse one, or do arithmetic on one.
                      `null` when this is the last page.
                    example: bzI1

        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/promotions:
    get:
      operationId: listPromotions
      summary: List gift-card campaigns
      description: |
        Lists the company's active gift-card campaigns — the discounted and added-value
        offers that can be applied when creating a card, by passing a campaign's `id` as
        `promotion_id` on `POST /v2/items`.

        Campaigns are created by hand and the list is short, so omitting `limit` returns
        all of them. Send `limit` and `starting_after` to page. Rows are ordered by
        `name`, and by `id` among campaigns sharing a name, so a paged walk visits every
        campaign exactly once.

        Requires `items:read` scope.
      tags: [Gift Cards]
      parameters:
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: Active gift-card campaigns
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, next_cursor]
                properties:
                  object:
                    const: list
                    description: Always `list`.
                  data:
                    type: array
                    description: The company's active discounted and added-value campaigns.
                    items:
                      $ref: "#/components/schemas/Promotion"
                  has_more:
                    type: boolean
                    description: Whether more campaigns follow this page.
                    example: false
                  next_cursor:
                    type: [string, "null"]
                    description: |
                      Send this back as `starting_after` for the next page, unchanged.
                      `null` when this is the last page.
                    example: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/reports/analytics:
    get:
      operationId: getAnalytics
      summary: Item analytics
      description: |
        A reporting view of the company's items: the same rows as `GET /v2/items` but
        reduced to the analytic columns (type, title, status, amounts, VAT, currency,
        validity, creation time, language) and with no per-item availability flags,
        buyer identity or claim links.

        Unlike `GET /v2/items` this endpoint has no mandatory filter — call it with
        nothing but a date range.

        Requires `reports:read` scope.
      tags: [Reports]
      parameters:
        - $ref: "#/components/parameters/Company"
        - name: search
          in: query
          required: false
          schema:
            type: string
          description: |
            Free-text search over the same columns as `GET /v2/items`. Unlike
            `GET /v2/items` this route applies no minimum length, so a short term is
            searched rather than refused.
          example: 77xrB3e44T
        - name: status
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/ItemStatusId"
          description: Return only rows with this status id.
          example: 6
        - $ref: "#/components/parameters/DateFrom"
        - $ref: "#/components/parameters/DateTo"
        - $ref: "#/components/parameters/Offset"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Order"
      responses:
        "200":
          description: Analytics rows
          content:
            application/json:
              schema:
                type: object
                required: [items, itemsCount]
                properties:
                  items:
                    type: array
                    description: The matching analytics rows for this page of results.
                    items:
                      $ref: "#/components/schemas/ItemSummary"
                  itemsCount:
                    type: integer
                    description: |
                      Number of rows in this page, not the total that match. Page with
                      `offset` until you receive a short page.
                    example: 1
                  filter:
                    allOf:
                      - $ref: "#/components/schemas/ListFilter"
                    description: Development-only echo of the applied list settings.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v2/reports/claimed-items:
    get:
      operationId: getClaimedItems
      summary: Claimed-item transactions
      description: |
        The redemption ledger: one row per claim / refund / status transaction across
        gift cards, special offers and tickets, newest first by default.

        **Send both `date_from` and `date_to`.** The `422` fires only when both are
        missing; a request carrying one of them is accepted and answers an empty page,
        because the missing bound is compared against nothing and matches no row.

        Requires `reports:read` scope.
      tags: [Reports]
      parameters:
        - $ref: "#/components/parameters/Company"
        - name: date_from
          in: query
          required: false
          schema:
            type: string
            format: date
          description: >-
            Start of the interval (inclusive), `YYYY-MM-DD`. Not enforced on its own —
            omitting only this one is accepted and returns nothing, so send it with
            `date_to`.
          example: "2026-01-01"
        - name: date_to
          in: query
          required: false
          schema:
            type: string
            format: date
          description: >-
            End of the interval (inclusive), `YYYY-MM-DD`. Not enforced on its own —
            omitting only this one is accepted and returns nothing, so send it with
            `date_from`.
          example: "2026-01-31"
        - $ref: "#/components/parameters/Offset"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Order"
      responses:
        "200":
          description: Claim transactions
          content:
            application/json:
              schema:
                type: object
                required: [items, itemsCount]
                properties:
                  items:
                    type: array
                    description: The transactions in this page of results.
                    items:
                      $ref: "#/components/schemas/ClaimedItem"
                  itemsCount:
                    type: integer
                    description: |
                      Number of transactions in this page, not the total in the interval.
                      Page with `offset` until you receive a short page.
                    example: 1
                  filter:
                    allOf:
                      - $ref: "#/components/schemas/ListFilter"
                    description: Development-only echo of the applied list settings.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/subscribers:
    get:
      operationId: listSubscribers
      summary: List newsletter subscribers
      description: |
        Lists the confirmed subscribers collected through the company's order pages.
        When that company is a root company, the subscribers of its child companies are
        included.

        `date_from` and `date_to` filter by signup date and only take effect when
        **both** are present and both parse as `YYYY-MM-DD`; otherwise the whole
        interval is silently ignored and all subscribers are returned.

        This list grows with every buyer, so omitting `limit` returns the first 50 rows
        and `has_more: true` rather than everything. Rows are ordered by `id`; page with
        `starting_after` until `has_more` is `false`. (`GET /v2/events` and its
        participants default to 10, `GET /v2/webhook-deliveries` to 50, and
        `GET /v2/orders` to 25.)

        Requires `reports:read` scope.
      tags: [Reports]
      parameters:
        - $ref: "#/components/parameters/Company"
        - $ref: "#/components/parameters/DateFrom"
        - $ref: "#/components/parameters/DateTo"
        - name: limit
          in: query
          required: false
          description: |
            How many subscribers to return, 1 to 100. A value outside that range is
            clamped into it and a value that is not a number is ignored. Defaults to 50.
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
          example: 50
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: Subscribers
          content:
            application/json:
              schema:
                type: object
                required: [object, data, has_more, next_cursor]
                properties:
                  object:
                    const: list
                    description: Always `list`.
                  data:
                    type: array
                    description: |
                      Confirmed subscribers, including those of child companies when the
                      acting company is a root company, ordered by `id`.
                    items:
                      $ref: "#/components/schemas/Subscriber"
                  has_more:
                    type: boolean
                    description: Whether more subscribers follow this page.
                    example: true
                  next_cursor:
                    type: [string, "null"]
                    description: |
                      Send this back as `starting_after` for the next page, unchanged: the
                      `id` of the last subscriber on this page. `null` when this is the last page.
                    example: "50"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /v2/order-page/revisions:
    get:
      tags: [Order Page]
      summary: List order-page revisions
      operationId: listOrderPageRevisions
      description: |
        Returns the version history of the acting company's hosted order page for one
        locale — every draft, the revision currently published, and the archived
        revisions it superseded — newest first.

        Each entry is a summary without the field values, so the list stays small enough
        to render as a history panel. Read one revision in full with
        `GET /v2/order-page/drafts/{revision_id}`, which resolves any revision id, not
        only drafts.

        This route pages with neither `offset` nor `limit`; narrow it with `lang`.
        Requires `orderpage:read` scope, and is read-only and safe to repeat.
      x-lifepeaks-scope: orderpage:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/Language'
      responses:
        '200':
          description: Newest revision first.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    description: Revision summaries for the requested locale, newest first.
                    items: { $ref: '#/components/schemas/OrderPageRevisionSummary' }
        default: { $ref: '#/components/responses/Error' }
  /v2/order-page/revisions/{revision_id}/restore:
    post:
      tags: [Order Page]
      summary: Restore a revision into a new draft
      operationId: restoreOrderPageRevision
      description: |
        Copies an older revision into a brand-new draft and returns it. Use it to roll
        back a change that went live: restore the revision you want, review the draft,
        then publish it.

        Restoring is non-destructive in both directions. The source revision stays
        immutable, and nothing reaches buyers until you publish the returned draft — the
        live page is untouched by this call.

        **Idempotency.** `Idempotency-Key` is required, so a retry after a timeout returns
        the draft that was already created rather than making a second copy. The same key
        with a different revision answers `409`.

        Requires `orderpage:write` scope.
      x-lifepeaks-scope: orderpage:write
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/RevisionId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 100
                  description: |
                    Label for the new draft, shown in the revision history. Omit it and
                    the draft is created unnamed. 1 to 100 characters.
                  example: Roll back autumn campaign
            example:
              name: Roll back autumn campaign
      responses:
        '201':
          description: New draft copied from the selected revision.
          headers:
            ETag:
              description: |
                Optimistic-concurrency tag for editing the new draft. It matches the
                `etag` field in the body; send it as `If-Match` on the first edit.
              schema:
                type: string
                example: '"opr_7c1e93a4b60d48f2a5e7c081b3d92f64:v1"'
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderPageRevision' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/order-page/drafts:
    post:
      tags: [Order Page]
      summary: Create an order-page draft
      operationId: createOrderPageDraft
      description: |
        Starts an isolated draft of the hosted order page for one locale. This is the
        recommended way to make a customer-facing change: edit the draft, preview it,
        then publish — rather than patching the live page directly with
        `PATCH /v2/order-page`.

        The draft begins as a copy of the currently published revision, or of
        `from_revision_id` when you name one. Creating it changes nothing buyers can see.

        Store the returned `id` and `etag`. Every edit needs the newest `etag` as
        `If-Match`, which is what stops two editors overwriting each other silently.

        **Idempotency.** `Idempotency-Key` is required, so a retry returns the draft that
        was already created instead of littering the history with duplicates.

        Requires `orderpage:write` scope.
      x-lifepeaks-scope: orderpage:write
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                lang:
                  type: string
                  default: en-GB
                  description: |
                    BCP 47 locale this draft edits, for example `da-DK`. A draft covers
                    one locale; translate a page by drafting each locale separately.
                  example: da-DK
                name:
                  type: string
                  minLength: 1
                  maxLength: 100
                  description: |
                    Label for the draft, shown in the revision history. Omit it and the
                    draft is created unnamed. 1 to 100 characters.
                  example: Autumn campaign
                from_revision_id:
                  type: string
                  pattern: '^opr_[a-f0-9]{32}$'
                  description: |
                    Seed the draft from this revision instead of the currently published
                    one. Use it to branch from a specific point in the history.
                  example: opr_2b8f04d7c19e4a6035fd82c7e1b940a3
            example:
              lang: da-DK
              name: Autumn campaign
      responses:
        '201':
          description: Draft created; response ETag matches its `etag` field.
          headers:
            ETag:
              description: Optimistic-concurrency tag for the new draft; matches its `etag` field.
              schema:
                type: string
                example: '"opr_7c1e93a4b60d48f2a5e7c081b3d92f64:v1"'
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderPageRevision' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/order-page/drafts/{revision_id}:
    get:
      tags: [Order Page]
      summary: Get a draft
      operationId: getOrderPageDraft
      description: |
        Returns one revision in full — its metadata and every stored field value.
        Despite the path, it resolves any revision id belonging to the acting company,
        so use it to inspect a published or archived revision as well as a draft.

        Read it before editing to obtain the current `etag`, and again after a `412` to
        reconcile someone else's change. A revision belonging to another company answers
        `404`.

        Requires `orderpage:read` scope. This operation is read-only and safe to repeat.
      x-lifepeaks-scope: orderpage:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/RevisionId'
      responses:
        '200':
          description: Tenant-scoped revision.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderPageRevision' }
        '404': { $ref: '#/components/responses/NotFound' }
        default: { $ref: '#/components/responses/Error' }
    patch:
      tags: [Order Page]
      summary: Update a draft
      operationId: updateOrderPageDraft
      description: |
        Writes field values into a draft and returns it with a **new** `etag`. Send only
        the fields you are changing; anything you omit keeps its current value. Only
        names from `GET /v2/order-page/schema` are accepted — an unknown field, a wrong
        type, or an over-length value is rejected with `422` and nothing is written.

        **`If-Match` is required.** Send the `etag` you last read. A stale tag answers
        `412`, which means someone else edited the draft in the meantime: re-read it,
        reconcile your change, and retry with the new tag. Each successful edit issues a
        new tag, so a client that keeps posting its original tag will keep getting `412`.

        Editing a draft never touches the live page; publishing does. This operation
        takes no idempotency key because `If-Match` already makes a blind retry safe.

        Requires `orderpage:write` scope.
      x-lifepeaks-scope: orderpage:write
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/RevisionId'
        - $ref: '#/components/parameters/IfMatch'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fields]
              properties:
                fields:
                  type: object
                  additionalProperties: true
                  description: |
                    Map of safe field name to new value, using the names and types from
                    `GET /v2/order-page/schema`. Include only the fields you are changing.
                  example:
                    orderform_company_box_bg: '#2d3a8c'
                    orderform_signup_text: Tilmeld dig eksklusive fordele
              additionalProperties: false
            example:
              fields:
                orderform_company_box_bg: '#2d3a8c'
                orderform_signup_text: Tilmeld dig eksklusive fordele
      responses:
        '200':
          description: Updated draft with a new ETag.
          headers:
            ETag:
              description: The draft's new tag. Send this one as `If-Match` on the next edit.
              schema:
                type: string
                example: '"opr_7c1e93a4b60d48f2a5e7c081b3d92f64:v2"'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderPageRevision' }
        '404': { $ref: '#/components/responses/NotFound' }
        '412': { $ref: '#/components/responses/PreconditionFailed' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/order-page/drafts/{revision_id}/preview:
    get:
      tags: [Order Page]
      summary: Preview exact draft payload
      operationId: previewOrderPageDraft
      description: |
        Returns exactly what the hosted order page would render from this draft: its
        field values combined with the company's current image resources, assembled
        fresh rather than served from any cache. Use it as the review step before
        publishing.

        The response is sent as `private, no-store`. It describes an unpublished draft,
        so do not cache it, log it, or expose it on a public URL of your own.

        Requires `orderpage:read` scope. This operation is read-only and safe to repeat.
      x-lifepeaks-scope: orderpage:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/RevisionId'
      responses:
        '200':
          description: Non-cached draft fields combined with current image resources.
          headers:
            Cache-Control:
              description: Always `private, no-store` — the payload describes an unpublished draft.
              schema: { type: string, example: 'private, no-store' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderPagePreview' }
        '404': { $ref: '#/components/responses/NotFound' }
        default: { $ref: '#/components/responses/Error' }
  /v2/order-page/drafts/{revision_id}/publish:
    post:
      tags: [Order Page]
      summary: Publish a reviewed draft
      operationId: publishOrderPageDraft
      description: |
        Makes a draft the live hosted order page. This is the release boundary — the only
        call in the draft workflow that buyers can see the effect of.

        The previously published revision is archived rather than overwritten, so it
        stays in the history and can be restored later with
        `POST /v2/order-page/revisions/{revision_id}/restore`.

        **`If-Match` is required** and must carry the draft's newest `etag`, which
        guarantees you are publishing the version you reviewed and not an edit that
        landed after your preview. A stale tag answers `412`.

        **Idempotency.** `Idempotency-Key` is required and must be independent of the key
        used to create the draft, so a timeout on publish cannot leave you guessing
        whether the page went live.

        Requires `orderpage:write` scope.
      x-lifepeaks-scope: orderpage:write
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/RevisionId'
        - $ref: '#/components/parameters/IfMatch'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Published revision; previous published revision is archived.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderPageRevision' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '412': { $ref: '#/components/responses/PreconditionFailed' }
        default: { $ref: '#/components/responses/Error' }

  /v2/pdf-templates:
    get:
      tags: [PDF Templates]
      summary: Get branded gift-card PDF templates
      operationId: listPdfTemplates
      description: |
        Returns the acting company's gift-card PDF template versions for one locale,
        together with the version currently selected for new orders. Use it to show which
        document buyers receive today and what earlier versions exist.

        Every company always has a usable template: Lifepeaks generates a default from
        the company's brand profile, so `current` is populated even when no partner
        template has ever been uploaded.

        `current` is resolved in this order, and a published partner version always
        outranks a generated default:

        1. published partner version for the exact locale;
        2. published partner version for the same base language, any region;
        3. published partner version for `en-GB`;
        4. the company default for the requested locale.

        Tiers 2 and 3 let one published override serve every locale a partner sells in, so
        `current` for a locale you never uploaded artwork for can still report
        `source: "partner"`.

        `templates` omits versions with status `uploading`. Those exist only after a
        finalize that crashed part-way, can never be published, and are collected
        automatically — so the list stays a view of versions you can actually act on.
        `GET /v2/pdf-templates/{template_id}` still returns such a version, reporting
        `"status": "uploading"`, if you hold its id.

        Requires `pdf_templates:read` scope. This operation is read-only and safe to
        repeat.
      x-lifepeaks-scope: pdf_templates:read
      parameters:
        - $ref: '#/components/parameters/Company'
        - in: query
          name: lang
          required: false
          description: |
            BCP 47 locale to report, for example `da-DK` or `en-GB`. Templates are stored
            per locale. Defaults to `da-DK`.
          schema: { type: string, default: da-DK, pattern: '^[a-z]{2}(?:-[A-Z]{2})?$' }
          example: da-DK
      responses:
        '200':
          description: Company-scoped template versions and current selection.
          content:
            application/json:
              schema:
                type: object
                required: [templates, current]
                properties:
                  templates:
                    type: array
                    description: Every stored version for this company and locale.
                    items: { $ref: '#/components/schemas/PdfTemplate' }
                  current:
                    allOf:
                      - $ref: '#/components/schemas/PdfTemplate'
                    description: The version new orders are currently rendered from.
        default: { $ref: '#/components/responses/Error' }
  /v2/pdf-templates/{template_id}:
    get:
      tags: [PDF Templates]
      summary: Get one template version
      operationId: getPdfTemplate
      description: |
        Returns one stored template version: its locale, whether Lifepeaks generated it
        or a partner uploaded it, its lifecycle status, size, page count, and checksum.

        Use it to poll a version you just finalized, or to confirm which artwork a
        version id refers to before publishing it. Unlike the list, this returns a version
        left in `uploading` by a crashed finalize as well, so a stuck id reads back as
        `"status": "uploading"` rather than as `404`. A version belonging to another
        company answers `404`.

        Requires `pdf_templates:read` scope. This operation is read-only and safe to
        repeat.
      x-lifepeaks-scope: pdf_templates:read
      parameters:
        - $ref: '#/components/parameters/PdfTemplateId'
        - $ref: '#/components/parameters/Company'
      responses:
        '200':
          description: The requested template version.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PdfTemplateEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
        default: { $ref: '#/components/responses/Error' }
  /v2/pdf-templates/upload-intents:
    post:
      tags: [PDF Templates]
      summary: Create a PDF upload intent
      operationId: createPdfUploadIntent
      description: |
        Step one of publishing partner artwork. You declare the file you are about to
        upload — its locale, SHA-256 checksum, exact byte size, and MIME type — and
        Lifepeaks returns a short-lived, single-purpose upload target.

        Declaring the file up front is what makes the upload verifiable: the bytes that
        arrive are checked against the checksum and size you committed to here, so a
        truncated or substituted file is rejected at finalize rather than published.

        **The intent expires 15 minutes after it is created.** Upload and finalize within
        that window; afterwards, create a new intent rather than retrying the old one. An
        expired intent that was never finalized is garbage-collected — it and the object it
        reserved are deleted the next time the company creates an intent — so an abandoned
        upload cannot be resumed later.

        The response carries `upload.url`, `upload.method`, and `upload.headers`. **Send
        the bytes to the URL the intent gives you, with its headers exactly as given** —
        do not hardcode either. In a deployed environment the URL is a short-lived signed
        storage URL and the headers carry its signature, so dropping or altering one makes
        the upload fail the signature check; only in local development is the target a
        route on this API.

        **Idempotency.** `Idempotency-Key` is required. A replay returns the same intent,
        with a freshly generated upload target — the target itself is never stored, so a
        replayed intent that has already expired answers an error and you must create a
        new one.

        Requires `pdf_templates:write` scope.
      x-lifepeaks-scope: pdf_templates:write
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PdfUploadIntentRequest' }
      responses:
        '201':
          description: Upload intent created, with its short-lived upload target.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PdfUploadIntentEnvelope' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/pdf-templates/upload-intents/{intent_id}/content:
    put:
      tags: [PDF Templates]
      summary: Upload the declared bytes (local development only)
      operationId: uploadPdfIntentContent
      description: |
        Receives the raw PDF bytes for an upload intent. The body is the file itself, not
        a multipart form.

        **Do not call this path directly.** It is the upload target an intent hands out
        in local development; in a deployed environment the intent returns a short-lived
        signed storage URL instead and this route is never used. Always send the bytes to
        `upload.url` from the intent response, with the method and headers it names, so
        the same integration code works in every environment.

        **`Content-Length` is required here.** Without a declared length there is nothing
        to check the upload against before reading it, and a chunked body would slip past
        the size guard entirely, so a request that omits it — or sends a non-numeric value
        — answers `411` with `error.code` of `length_required`. A declared length above the
        8,000,000-byte ceiling is rejected before the body is read at all. Most HTTP
        clients set the header for you when the body is a file; check yours if you see a
        `411`.

        This is specific to the local target. A partner uploading to storage in a deployed
        environment sends the `Content-Length` the intent already listed in
        `upload.headers`, where it is part of the signature.

        Answering `202` means the bytes were stored, not that they were accepted — the
        checksum, size, MIME, and PDF structure are all verified at finalize.

        Requires `pdf_templates:write` scope.
      x-lifepeaks-scope: pdf_templates:write
      parameters:
        - $ref: '#/components/parameters/UploadIntentId'
        - $ref: '#/components/parameters/Company'
      requestBody:
        required: true
        content:
          application/pdf:
            schema:
              type: string
              format: binary
              description: The raw PDF bytes, matching the checksum and size the intent declared.
      responses:
        '202':
          description: Bytes stored, pending validation at finalize.
          content:
            application/json:
              schema:
                type: object
                required: [upload_intent]
                properties:
                  upload_intent:
                    type: object
                    description: The intent the bytes were stored against.
                    required: [id, status]
                    properties:
                      id:
                        type: string
                        pattern: '^pi_[a-f0-9]{32}$'
                        description: The upload intent id from the path.
                        example: pi_5a3c81e0d74b426f90ac17b5e2d8630f
                      status:
                        type: string
                        enum: [pending, finalized]
                        description: |
                          The slot's state, which storing bytes does not change: it is
                          still `pending` until finalize has validated the file and
                          produced a version. The `202` is what tells you the bytes
                          arrived.
                        example: pending
        '404': { $ref: '#/components/responses/NotFound' }
        '411':
          description: |
            The request carried no usable `Content-Length`. The upload was not read.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error:
                  code: length_required
                  message: Send the upload with a Content-Length header.
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/pdf-templates/upload-intents/{intent_id}/finalize:
    post:
      tags: [PDF Templates]
      summary: Validate an upload into a template version
      operationId: finalizePdfTemplate
      description: |
        Step three. Verifies the uploaded object against the intent and turns it into an
        immutable template version with status `validated`. The template id does not exist
        until this call succeeds; it comes back in the response.

        Finalizing checks the declared SHA-256, the byte size, and the MIME type, then
        parses the document for real: the PDF signature, a version between 1.3 and 1.7, at
        most 10 pages, no encryption, no active content, and a structure the renderer can
        actually read. It also records the page count. A file that fails any check is
        rejected and no version is created, so an invalid document can never be published.

        Validating does **not** make the artwork live. Publish it separately once you have
        previewed it.

        Rejections carry a specific `error.code` so your integration can tell the caller
        what to fix:

        | `error.code` | Status | Meaning |
        |---|---|---|
        | `upload_checksum_mismatch` | 422 | The bytes do not match the declared SHA-256 |
        | `upload_size_mismatch` | 422 | The bytes do not match the declared byte size |
        | `upload_mime_mismatch` | 422 | The object is not the declared MIME type |
        | `uploaded_object_missing` | 422 | Nothing was uploaded for this intent |
        | `upload_intent_expired` | 422 | The 15-minute window closed; create a new intent |
        | `invalid_pdf_signature` | 422 | The file does not begin as a PDF |
        | `unsupported_pdf_version` | 422 | The PDF version is outside 1.3 to 1.7 |
        | `pdf_page_limit_exceeded` | 422 | More than 10 pages |
        | `encrypted_pdf_not_supported` | 422 | The document is encrypted |
        | `active_content_not_supported` | 422 | It embeds JavaScript or another active action |
        | `invalid_pdf_structure` | 422 | The document could not be parsed |
        | `upload_intent_already_finalized` | 409 | This intent already produced a version |
        | `not_found` | 404 | No such intent for this company — and the same answer for an unknown template or an unknown company. The shared 404 envelope, so a miss never says which resource it was |

        Two failures are **not** the partner's fault and are worth retrying: when the
        malware scanner or the PDF parser is temporarily down, finalize answers `503` with
        `Retry-After: 30` and `error.code` of `malware_scanner_unavailable` or
        `pdf_validation_unavailable`. Retry with the same idempotency key rather than
        treating the document as invalid. Every `422` above is a real rejection and
        retrying it unchanged will fail the same way.

        **Idempotency.** `Idempotency-Key` is required. Finalizing the same intent twice
        returns the same version rather than creating a second one.

        Requires `pdf_templates:write` scope.
      x-lifepeaks-scope: pdf_templates:write
      parameters:
        - $ref: '#/components/parameters/UploadIntentId'
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '201':
          description: A validated, unpublished template version.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PdfTemplateEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
        default: { $ref: '#/components/responses/Error' }
  /v2/pdf-templates/{template_id}/preview:
    get:
      tags: [PDF Templates]
      summary: Preview a template version
      operationId: previewPdfTemplate
      description: |
        Streams one template version back as `application/pdf` so it can be reviewed
        before publication. Do this between finalize and publish — it is the last point at
        which a mistake costs nothing.

        The response is served as an **attachment**, under
        `Content-Security-Policy: default-src 'none'; sandbox`. Partner artwork is never
        rendered in a page context: download the bytes and open them in a PDF viewer,
        rather than pointing an `iframe` or `embed` at this endpoint and expecting it to
        display.

        No storage key, bucket, or internal identifier is ever exposed, so a preview
        cannot be shared outside your authenticated integration.

        A version belonging to another company answers `404`. `503` means the stored
        object is temporarily unreachable; the version is intact and the call can be
        retried.

        Requires `pdf_templates:read` scope. This operation is read-only and safe to
        repeat.
      x-lifepeaks-scope: pdf_templates:read
      parameters:
        - $ref: '#/components/parameters/PdfTemplateId'
        - $ref: '#/components/parameters/Company'
      responses:
        '200':
          description: The template document. No storage credentials are exposed.
          headers:
            Content-Disposition:
              description: Always `attachment; filename="gift-card-template.pdf"`.
              schema:
                type: string
                example: attachment; filename="gift-card-template.pdf"
            Content-Security-Policy:
              description: |
                Always `default-src 'none'; sandbox`, which stops the document being
                rendered in a page context.
              schema:
                type: string
                example: default-src 'none'; sandbox"
          content:
            application/pdf:
              schema:
                type: string
                format: binary
                description: The template document.
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Error' }
        default: { $ref: '#/components/responses/Error' }
  /v2/pdf-templates/{template_id}/publish:
    post:
      tags: [PDF Templates]
      summary: Publish a validated template version
      operationId: publishPdfTemplate
      description: |
        Step five, and the release boundary. Selects a `validated` version as the artwork
        for **future** orders in its locale, and marks the version it replaces
        `superseded`.

        Orders already placed are unaffected: a v2 order records the template version in
        force when it was created, so a later publish never rewrites a document a buyer
        has already received.

        A partner version that is `validated` or `superseded` can be published, so
        artwork retired by a revert or by a later publish can be brought back without
        re-uploading it. Publishing a version that is already live is a no-op and returns
        it unchanged.

        Only two things are refused, both `409`: a Lifepeaks-generated default
        (`partner_template_required` — use `revert-to-default` to select the generated
        artwork instead), and a version that never passed validation
        (`validated_template_required`).

        **Idempotency.** `Idempotency-Key` is required, so a timeout cannot leave you
        unsure whether the artwork went live.

        Requires `pdf_templates:write` scope.
      x-lifepeaks-scope: pdf_templates:write
      parameters:
        - $ref: '#/components/parameters/PdfTemplateId'
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The published version. It applies to future orders only.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PdfTemplateEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }
  /v2/pdf-templates/revert-to-default:
    post:
      tags: [PDF Templates]
      summary: Revert future orders to the generated default
      operationId: revertPdfTemplateToDefault
      description: |
        Selects the Lifepeaks-generated, brand-derived default for future orders. This is
        the rollback for a publish that turned out wrong.

        **Reverting is company-wide, not per locale.** It supersedes *every* published
        partner version the company holds, across all locales, and then publishes the
        generated default for the locale you name. It has to work that way: the selection
        chain lets a published override for one locale serve another, so retiring a single
        locale would leave the override still reachable through the base-language and
        `en-GB` tiers.

        Nothing is deleted. Superseded partner versions stay in the history and can be
        published again later without re-uploading, and orders already placed keep the
        document they were created with.

        **Idempotency.** `Idempotency-Key` is required. Reverting when the default is
        already selected is not an error, creates no new version, and is safe to repeat.

        Requires `pdf_templates:write` scope.
      x-lifepeaks-scope: pdf_templates:write
      parameters:
        - $ref: '#/components/parameters/Company'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                language:
                  type: string
                  default: da-DK
                  description: |
                    BCP 47 locale whose generated default should be published. Defaults to
                    `da-DK`. Note this selects *which default goes live* — every locale's
                    published partner version is superseded regardless of what you pass.
                  example: da-DK
            example:
              language: da-DK
      responses:
        '200':
          description: The generated default, now selected for future orders.
          headers:
            Idempotency-Key: { $ref: '#/components/headers/IdempotencyKey' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PdfTemplateEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        default: { $ref: '#/components/responses/Error' }


components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: lp_live_…
      description: |
        Company-scoped API key, sent as `Authorization: Bearer lp_live_…`.

        The plaintext value is `lp_live_` followed by a 32-character hexadecimal key id,
        an underscore, and a 64-character hexadecimal secret — for example
        `lp_live_4f2c9a71b83d4e6fa0c15d27e93b6a08_5d8e1c04b7a9f3620e4d81c7a5b93f0d2e6c48a1b309f75d84e2c6b19a03d7f5`.
        Lifepeaks stores only a hash of the secret, so a key is shown exactly once when it
        is issued.

        Keep it **server-side**. A browser or mobile app must call your own backend, and
        only your backend may call Lifepeaks. Never place a key in client JavaScript, a
        mobile binary, source control, analytics, browser storage, or logs. Issue one via
        `POST /v2/api-keys` or the admin UI, and revoke it the moment it may have leaked.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, minLength: 16, maxLength: 255, pattern: '^[\x21-\x7E]+$' }
      description: |
        A key that identifies one logical durable action, so a retry cannot perform it
        twice. 16 to 255 visible ASCII characters, with no spaces.

        Generate it and store it durably **before** the first request, alongside the
        operation name and the exact request body. Reuse that same key for every retry of
        the same action — a network timeout is an unknown outcome, not a failure, and
        retrying with the same key replays the recorded response instead of acting again.

        Never reuse a key across different actions: creating an order and creating its
        checkout session need separate keys. The same key with a different body answers
        `409 idempotency_conflict`; an identical request still in flight answers `409
        request_in_progress` with `Retry-After`. Do not mint a fresh key inside a retry
        loop — that is how duplicate orders get created.
      example: order_checkout_018f4f37b949

    RequestGuid:
      name: X-Request-Guid
      in: header
      required: false
      schema:
        type: string
      description: |
        Replay token for the compatibility issuance and item operations, which use this
        instead of `Idempotency-Key`.

        Every successful response to one of these operations carries an `X-Request-Guid`
        response header. Store it, and if the call times out on your side, retry the
        identical request with that value as a request header: when the original in fact
        completed, the recorded response is replayed — same body, same status — and the
        action does not run a second time.

        Replays are scoped to the company the request acted on and require
        authentication. Only successful responses are recorded, so a failed request is
        genuinely safe to retry for real.
      example: 3f5a1c8e-6b40-4d27-9a13-e0c74b25d8f1

    PageLimit:
      name: limit
      in: query
      required: false
      description: |
        How many rows to return, 1 to 100. A value outside that range is clamped into
        it and a value that is not a number is ignored, so a list never fails because
        of its `limit`. Omitting it returns every row on this route.
      schema: { type: integer, minimum: 1, maximum: 100 }
      example: 50

    StartingAfter:
      name: starting_after
      in: query
      required: false
      description: |
        Cursor for the next page: the `id` of the last row of the page you already
        have. The next page begins with the row after it. An id that is not in this
        list answers `422`, so a stale cursor cannot look like the end of the list.

        Send back whatever the list's own `id` field held — a number on most lists, a
        company slug on `GET /v2/companies` — rather than assuming a shape.
      schema: { type: string }
      example: "8842"

    Company:
      name: company
      in: query
      required: false
      schema:
        type: string
      description: |
        Slug of the company this request acts on. Omit it and the request acts on the
        API key's own company. Supply it to act on a company Lifepeaks has assigned to
        yours — the arrangement agencies use.

        One rule says where the slug goes: this query parameter is accepted on every
        operation that honours the acting company, and a `POST` or `PATCH` may carry the
        same slug as a `company` field in its request body instead, with the query string
        winning when both carry it. `POST /v2/quotes` is the one exception, because its
        body accepts `items` alone — name the company in its query string.

        Any different slug must name an active company assigned to the key's company.
        An unknown, inactive, or unassigned slug answers `404 not_found`, without
        revealing whether the company exists.
      example: acme-hotels

    ItemCode:
      name: code
      in: path
      required: true
      schema:
        type: string
      description: The item's code, as printed on the gift card / ticket.
      example: 77xrB3e44T

    PartnerOrderId:
      name: order_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^po_[a-f0-9]{32}$'
      description: Public headless order id returned by POST /v2/orders.
      example: po_0123456789abcdef0123456789abcdef

    PartnerOrderLineId:
      name: line_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^poli_[a-f0-9]{32}$'
      description: |
        Public line id, from the order's `line_items[].id`. It must belong to the order
        named in the path; a line of another order is a miss like any other.
      example: poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d

    LegacyOrderId:
      name: order_id
      in: path
      required: true
      schema:
        type: string
      description: |
        Order id of a compatibility issuance order, as returned by
        `POST /v2/items`. These ids are short alphanumeric strings and are **not**
        interchangeable with the `po_…` ids of headless orders.
      example: devOEVLB1EW33

    CompanySlug:
      name: slug
      in: path
      required: true
      schema:
        type: string
        pattern: '^[a-zA-Z0-9_-]+$'
      description: |
        Public slug of a company the API key may act for, as returned in the `id` field
        of `GET /v2/companies`. A slug that is unknown, inactive, or not assigned to the
        key's company answers `404`.
      example: acme-hotels

    BrandCompanyId:
      name: company_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^[a-zA-Z0-9_-]+$'
      description: |
        Public slug of the company whose brand is being read or written, as returned in
        the `id` field of `GET /v2/companies`. The brand routes carry their company in
        the path and ignore the `company` query parameter. A slug the key may not act
        for answers `404`.
      example: acme-hotels

    BrandAssetRole:
      name: asset
      in: path
      required: true
      schema:
        type: string
        enum: [logo, logo_on_light, pdf_background]
      description: |
        Which brand image to act on.
        `logo` — the primary company logo.
        `logo_on_light` — the variant used on light backgrounds.
        `pdf_background` — the background artwork of the generated gift-card PDF.
        Any other value answers `404`.
      example: logo

    ProductId:
      name: product_id
      in: path
      required: true
      schema:
        type: string
        pattern: '^(gift_card_value|gcv_[1-9][0-9]*|evt_[1-9][0-9]*|so_[1-9][0-9]*)$'
      description: |
        Catalog product id, as returned in the `id` field of `GET /v2/products`:
        `gift_card_value` for the company's variable-value gift card, `gcv_<id>` for one
        of its experiences, `evt_<id>` for one of its events, `so_<id>` for one of its
        special offers. Any other value answers `404`, and so does a product the acting
        company cannot sell.
      example: gift_card_value

    ProductType:
      name: type
      in: query
      required: false
      description: |
        Narrow the list to one product family: `gift_card` for the company's
        variable-value gift card, `gift_card_variant` for its experiences, `event_ticket`
        for its events, `special_offer` for its discounted offers. A value no product
        carries answers with an empty list rather than the unfiltered one.
      schema:
        type: string
        enum: [gift_card, gift_card_variant, event_ticket, special_offer]
      example: gift_card_variant

    ProductCategory:
      name: category
      in: query
      required: false
      description: |
        Narrow the list to one experience category, given as a `cat_<id>` from a
        product's `categories`. A category belongs to an experience, so naming one also
        drops the value gift card from the list. An unknown category answers with an
        empty list.
      schema:
        type: string
        pattern: '^cat_[1-9][0-9]*$'
      example: cat_22

    EndpointId:
      name: endpoint_id
      in: path
      required: true
      schema: { type: string, pattern: '^wh_[a-f0-9]{32}$' }
      description: |
        Webhook endpoint id, as returned by `POST /v2/webhook-endpoints` and listed by
        `GET /v2/webhook-endpoints`. Always `wh_` followed by 32 hexadecimal characters.
      example: wh_9d41b7e0c8524fa63b1e70d5482ac9f6

    DeliveryId:
      name: delivery_id
      in: path
      required: true
      schema: { type: string, pattern: '^wd_[a-f0-9]{32}$' }
      description: |
        Webhook delivery id, as listed by `GET /v2/webhook-deliveries`. Always `wd_`
        followed by 32 hexadecimal characters. It identifies one delivery attempt chain,
        not the event — the event has its own `evt_…` id.
      example: wd_1a6f38c05b7d49e2ac83f10b7e526d94

    PdfTemplateId:
      name: template_id
      in: path
      required: true
      schema: { type: string, pattern: '^pt_[a-f0-9]{32}$' }
      description: |
        Gift-card PDF template version id, as listed by `GET /v2/pdf-templates` and
        returned by finalize. Always `pt_` followed by 32 hexadecimal characters. Each
        finalized upload produces a new version id; publishing selects one of them.
      example: pt_63c07e19a4b85d2f701ea9c6b3d4820f

    UploadIntentId:
      name: intent_id
      in: path
      required: true
      schema: { type: string, pattern: '^pi_[a-f0-9]{32}$' }
      description: |
        Upload intent id, as returned by `POST /v2/pdf-templates/upload-intents`. Always
        `pi_` followed by 32 hexadecimal characters. For the upload itself, take the whole
        path from the intent's `upload.url` rather than assembling it yourself.
      example: pi_5a3c81e0d74b426f90ac17b5e2d8630f

    GreetingUploadIntentId:
      name: intent_id
      in: path
      required: true
      schema: { type: string, pattern: '^gimg_[a-f0-9]{32}$' }
      description: |
        Greeting-picture upload slot id, as returned by
        `POST /v2/greeting-images/upload-intents`. Always `gimg_` followed by 32
        hexadecimal characters. For the upload itself, take the whole path from the
        slot's `upload.url` rather than assembling it yourself.
      example: gimg_5a3c81e0d74b426f90ac17b5e2d8630f

    RevisionId:
      name: revision_id
      in: path
      required: true
      schema: { type: string, pattern: '^opr_[a-f0-9]{32}$' }
      description: |
        Order-page revision id, as listed by `GET /v2/order-page/revisions`. Always
        `opr_` followed by 32 hexadecimal characters. It identifies one immutable point
        in the page's history, whether that revision is a draft, published, or archived.
      example: opr_7c1e93a4b60d48f2a5e7c081b3d92f64

    IfMatch:
      name: If-Match
      in: header
      required: true
      schema: { type: string }
      description: |
        The revision's current `etag`, quoted exactly as it was returned. It makes the
        write conditional, so two editors cannot silently overwrite each other.

        Read the revision, send back the `etag` you received, and use the new tag from
        the response for your next write. A tag that no longer matches answers `412`:
        re-read the revision, reconcile your change, and retry.
      example: '"opr_7c1e93a4b60d48f2a5e7c081b3d92f64:v2"'

    Language:
      name: lang
      in: query
      required: false
      schema: { type: string, default: en-GB, pattern: '^[A-Za-z]{2,8}(-[A-Za-z0-9]{2,8})*$' }
      description: |
        BCP 47 locale to read or write, for example `da-DK` or `en-GB`. Order-page copy
        and gift-card PDF templates are stored per locale. Defaults to `en-GB`.
      example: da-DK

    Offset:
      name: offset
      in: query
      required: false
      schema:
        type: integer
        default: 0
        minimum: 0
      description: Rows to skip. Non-numeric or negative values become `0`.

    Limit:
      name: limit
      in: query
      required: false
      schema:
        type: integer
        default: 10
        maximum: 100
      description: Maximum rows to return. Values above `100` are capped at `100`.

    Order:
      name: order
      in: query
      required: false
      schema:
        type: string
        enum: [asc, desc]
        default: desc
      description: Sort direction. Anything other than `asc` is treated as `desc`.

    DateFrom:
      name: date_from
      in: query
      required: false
      schema:
        type: string
        format: date
      description: Include rows from this date onwards (inclusive), `YYYY-MM-DD`.
      example: "2026-01-01"

    DateTo:
      name: date_to
      in: query
      required: false
      schema:
        type: string
        format: date
      description: Include rows up to this date (inclusive), `YYYY-MM-DD`.
      example: "2026-01-31"

  headers:
    IdempotencyKey:
      schema: { type: string }
      description: Echoes the request idempotency key.

  responses:
    Error:
      description: Request failed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

    Conflict:
      description: Resource state or idempotency conflict.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

    ServiceUnavailable:
      description: |
        A Lifepeaks dependency is temporarily unavailable, not a problem with the request.
        The response carries `Retry-After` in seconds; retry with the same idempotency key
        rather than treating the request as rejected.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            example: 30
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: pdf_validation_unavailable
              message: PDF validation is temporarily unavailable. Retry shortly.

    PreconditionFailed:
      description: If-Match does not match the current draft.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: unauthorized
              message: Valid credentials required.

    Forbidden:
      description: Authenticated but insufficient scope
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: insufficient_scope
              message: "Missing scope: orderpage:write"

    NotFound:
      description: |
        No such record for the company the request acts on. Also returned when the
        `company` parameter names an unknown or inactive company, or one that is not
        assigned to yours. v2 does not distinguish "does not exist" from "belongs to
        another company".
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: not_found
              message: Code is not valid!

    ValidationError:
      description: Request body failed validation
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          example:
            error:
              code: validation_failed
              message: Invalid request.
              fields:
                label:
                  - Label is required.

  schemas:
    # ── Account, companies, and brand ─────────────────────────────────────────

    IntegrationContext:
      type: object
      description: Who the caller is, what the credential may do, and how it may settle.
      required: [api_version, company, credential, commerce]
      properties:
        api_version:
          type: string
          description: The API generation serving this request. Always `v2`.
          const: v2
          example: v2
        company:
          type: object
          description: The company this request acts on.
          required: [id, name]
          properties:
            id:
              type: string
              description: |
                The company's public slug — the same value used as the `company` query
                parameter and as `{company_id}` on the brand routes.
              example: acme-hotels
            name:
              type: string
              description: The company's display name.
              example: Acme Hotels
        credential:
          type: object
          description: The API key that authenticated this request.
          required: [id, label, environment, scopes, expires_at]
          properties:
            id:
              type: string
              description: |
                Public key id — 32 hexadecimal characters, the middle segment of the
                plaintext key. Safe to log; it is not the secret.
              example: 4f2c9a71b83d4e6fa0c15d27e93b6a08
            label:
              type: string
              description: The human-readable label the key was issued with.
              example: Storefront production
            environment:
              type: string
              description: |
                The key's environment label. Send `environment` on
                `POST /v2/api-keys` to choose it; omit it and the key is `live`. It
                decides which QuickPay merchant a checkout under this key reaches, and
                nothing else — a test key reads and writes the same data a live one does.

                **A test key can only mint test keys.** Asking for `live` from one, or
                omitting the field so the default would apply, answers `422` with
                `error.code` of `validation_failed` and `environment` named in
                `error.fields`. It is refused rather than quietly downgraded, so a test
                key with `credentials:write` cannot be escalated into a live one, and a
                caller is never left thinking it asked for something it did not get.
              enum: [live, test]
              example: live
            scopes:
              type: array
              description: |
                The scopes this key actually carries. Check against it before calling an
                operation instead of discovering a missing scope as a `403`.
              items:
                $ref: "#/components/schemas/Scope"
              example: [catalog:read, quotes:create, orders:create, orders:read, checkout:create]
            expires_at:
              type: [string, "null"]
              format: date-time
              description: |
                ISO 8601 expiry, or `null` when the key does not expire. Past this
                moment every request with the key answers `401`.
              example: "2027-02-01T00:00:00+00:00"
        commerce:
          type: object
          description: How this company is permitted to settle orders.
          required: [modes, default_mode, negotiated_settlement]
          properties:
            modes:
              type: array
              description: |
                Every settlement mode the company currently holds. `lifepeaks_checkout`
                is always present; the others appear only under an active negotiated
                contract, and only these values may be sent to
                `POST /v2/orders/{order_id}/settle`.
              items:
                type: string
                enum: [lifepeaks_checkout, invoice_settlement, pay_on_location, external_processor]
              example: [lifepeaks_checkout]
            default_mode:
              type: string
              description: The mode used when nothing is negotiated. Always `lifepeaks_checkout`.
              const: lifepeaks_checkout
              example: lifepeaks_checkout
            negotiated_settlement:
              type: boolean
              description: |
                `true` when the company holds at least one negotiated mode beyond hosted
                checkout — a shorthand for `modes` having more than one entry.
              example: false
      example:
        api_version: v2
        company:
          id: acme-hotels
          name: Acme Hotels
        credential:
          id: 4f2c9a71b83d4e6fa0c15d27e93b6a08
          label: Storefront production
          environment: live
          scopes: [catalog:read, quotes:create, orders:create, orders:read, checkout:create]
          expires_at: "2027-02-01T00:00:00+00:00"
        commerce:
          modes: [lifepeaks_checkout]
          default_mode: lifepeaks_checkout
          negotiated_settlement: false

    Company:
      type: object
      description: One company the API key is permitted to act for.
      required: [id, name, status, relationship, parent_company_id]
      properties:
        id:
          type: string
          description: |
            The company's public slug. Pass it as the `company` query parameter on a
            tenant-scoped operation, or as `{company_id}` on the brand routes.
          example: acme-hotels
        name:
          type: string
          description: The company's display name.
          example: Acme Hotels
        status:
          type: string
          description: |
            Whether the company is currently trading. Assigned companies are only listed
            while `active`, so `inactive` can appear only for the key's own company.
          enum: [active, inactive]
          example: active
        relationship:
          type: string
          description: |
            How this company relates to the key. `owner` is the company that owns the
            key; `managed` is a company Lifepeaks has assigned to it.
          enum: [owner, managed]
          example: owner
        parent_company_id:
          type: [string, "null"]
          description: |
            Slug of the key's own company when this company is assigned to it, and `null`
            otherwise — including for the key's own company.
          example: null

    CompanyList:
      type: object
      description: The companies this API key may act for.
      required: [object, data, has_more, next_cursor]
      properties:
        object:
          const: list
          description: Always `list`.
        data:
          type: array
          description: The key's own company first, then each active assigned company.
          items:
            $ref: "#/components/schemas/Company"
        has_more:
          type: boolean
          description: Whether more companies follow this page.
          example: false
        next_cursor:
          type: [string, "null"]
          description: |
            Send this back as `starting_after` for the next page, unchanged.
            `null` when this is the last page.
          example: null
      example:
        object: list
        has_more: false
        next_cursor: null
        data:
          - id: acme-hotels
            name: Acme Hotels
            status: active
            relationship: owner
            parent_company_id: null
          - id: acme-spa
            name: Acme Spa
            status: active
            relationship: managed
            parent_company_id: acme-hotels

    CompanyEnvelope:
      type: object
      description: A single company, wrapped in its object envelope.
      required: [object, data]
      properties:
        object:
          const: company
          description: Always `company`.
        data:
          $ref: "#/components/schemas/Company"
      example:
        object: company
        data:
          id: acme-spa
          name: Acme Spa
          status: active
          relationship: managed
          parent_company_id: acme-hotels

    BrandProfile:
      type: object
      description: |
        The brand identity Lifepeaks applies when it generates a company's gift-card PDF
        and hosted surfaces. A company that has never been branded still reads back a
        complete profile, filled with Lifepeaks' starting values.
      required: [object, company_id, display_name, tagline, colors, typography, pdf]
      properties:
        object:
          const: brand
          description: Always `brand`.
        company_id:
          type: string
          description: Public slug of the company this profile belongs to.
          example: acme-hotels
        display_name:
          type: string
          description: |
            The name shown on branded documents. Falls back to the company's own name
            when no brand name has been set. At most 255 characters.
          example: Acme Hotels
        tagline:
          type: [string, "null"]
          description: |
            Short line shown under the display name, or `null` when unset. At most 500
            characters.
          example: Rest well, travel far
        colors:
          type: object
          description: The four brand colors, each a `#rrggbb` hex string.
          required: [primary, secondary, background, text]
          properties:
            primary:
              type: string
              pattern: '^#[0-9a-fA-F]{6}$'
              description: Primary brand color. Defaults to `#111111`.
              example: "#1a2b3c"
            secondary:
              type: string
              pattern: '^#[0-9a-fA-F]{6}$'
              description: Accent color. Defaults to `#ef233c`.
              example: "#ef233c"
            background:
              type: string
              pattern: '^#[0-9a-fA-F]{6}$'
              description: Page and document background. Defaults to `#ffffff`.
              example: "#ffffff"
            text:
              type: string
              pattern: '^#[0-9a-fA-F]{6}$'
              description: Body text color. Defaults to `#111111`.
              example: "#111111"
        typography:
          type: object
          description: Font family names, or `null` where none has been chosen.
          required: [primary, secondary]
          properties:
            primary:
              type: [string, "null"]
              description: Heading font family name. At most 80 characters.
              example: Inter
            secondary:
              type: [string, "null"]
              description: Body font family name. At most 80 characters.
              example: Source Serif 4
        pdf:
          type: object
          description: Settings that apply only to the generated gift-card PDF.
          required: [copyright_line]
          properties:
            copyright_line:
              type: [string, "null"]
              description: |
                Footer line printed on the generated PDF, or `null` when unset. At most
                500 characters.
              example: © 2026 Acme Hotels A/S
      example:
        object: brand
        company_id: acme-hotels
        display_name: Acme Hotels
        tagline: Rest well, travel far
        colors:
          primary: "#1a2b3c"
          secondary: "#ef233c"
          background: "#ffffff"
          text: "#111111"
        typography:
          primary: Inter
          secondary: Source Serif 4
        pdf:
          copyright_line: © 2026 Acme Hotels A/S

    BrandProfilePatchRequest:
      type: object
      additionalProperties: false
      description: |
        The brand fields to change. Send only what you are changing; omitted fields keep
        their current value and `null` clears an optional one. The request is flat and
        uses storage field names, while the response groups the same values under
        `colors`, `typography`, and `pdf`. Any other field name is rejected with `422`.
      properties:
        display_name:
          type: [string, "null"]
          maxLength: 255
          description: Name shown on branded documents. At most 255 characters.
          example: Acme Hotels
        tagline:
          type: [string, "null"]
          maxLength: 500
          description: Short line under the display name. At most 500 characters.
          example: Rest well, travel far
        primary_color:
          type: [string, "null"]
          pattern: '^#[0-9a-fA-F]{6}$'
          description: Primary brand color as `#rrggbb`. Reads back as `colors.primary`.
          example: "#1a2b3c"
        secondary_color:
          type: [string, "null"]
          pattern: '^#[0-9a-fA-F]{6}$'
          description: Accent color as `#rrggbb`. Reads back as `colors.secondary`.
          example: "#ef233c"
        background_color:
          type: [string, "null"]
          pattern: '^#[0-9a-fA-F]{6}$'
          description: Background color as `#rrggbb`. Reads back as `colors.background`.
          example: "#ffffff"
        text_color:
          type: [string, "null"]
          pattern: '^#[0-9a-fA-F]{6}$'
          description: Body text color as `#rrggbb`. Reads back as `colors.text`.
          example: "#111111"
        font_primary:
          type: [string, "null"]
          maxLength: 80
          description: Heading font family name. Reads back as `typography.primary`.
          example: Inter
        font_secondary:
          type: [string, "null"]
          maxLength: 80
          description: Body font family name. Reads back as `typography.secondary`.
          example: Source Serif 4
        pdf_copyright_line:
          type: [string, "null"]
          maxLength: 500
          description: |
            Footer line for the generated gift-card PDF. Reads back as
            `pdf.copyright_line`.
          example: © 2026 Acme Hotels A/S
      example:
        display_name: Acme Hotels
        tagline: Rest well, travel far
        primary_color: "#1a2b3c"

    BrandAsset:
      type: object
      description: One stored brand image.
      required: [role, url, original_name, updated_at]
      properties:
        role:
          type: string
          description: |
            Which image this is. `logo` — primary company logo. `logo_on_light` — variant
            for light backgrounds. `pdf_background` — background artwork of the generated
            gift-card PDF.
          enum: [logo, logo_on_light, pdf_background]
          example: logo
        url:
          type: string
          format: uri
          description: Public URL the stored image is served from.
          example: https://cdn.lifepeaks.dk/company/acme-hotels/logo.png
        original_name:
          type: string
          description: Filename the image was uploaded under, kept for recognition.
          example: acme-logo.png
        updated_at:
          type: [string, "null"]
          description: When the image was last replaced, as `YYYY-MM-DD HH:MM:SS`.
          example: "2026-08-14 09:22:41"

    BrandAssetList:
      type: object
      description: The brand images that exist for a company.
      required: [object, data]
      properties:
        object:
          const: list
          description: Always `list`.
        data:
          type: array
          description: |
            One entry per role that has an image. Roles with no image are omitted, so
            this array is empty for an unbranded company.
          items:
            $ref: "#/components/schemas/BrandAsset"
      example:
        object: list
        data:
          - role: logo
            url: https://cdn.lifepeaks.dk/company/acme-hotels/logo.png
            original_name: acme-logo.png
            updated_at: "2026-08-14 09:22:41"

    BrandAssetResult:
      type: object
      description: The asset as stored after an upload.
      required: [asset]
      properties:
        asset:
          type: object
          required: [role, url, original_name]
          description: The newly stored image.
          properties:
            role:
              type: string
              description: The role the image was stored under.
              enum: [logo, logo_on_light, pdf_background]
              example: logo
            url:
              type: string
              format: uri
              description: Public URL the stored image is served from.
              example: https://cdn.lifepeaks.dk/company/acme-hotels/logo.png
            original_name:
              type: string
              description: Filename of the uploaded file.
              example: acme-logo.png
      example:
        asset:
          role: logo
          url: https://cdn.lifepeaks.dk/company/acme-hotels/logo.png
          original_name: acme-logo.png

    BrandAssetDeleted:
      type: object
      description: Confirmation that a brand asset was removed.
      required: [deleted, role]
      properties:
        deleted:
          type: boolean
          description: Always `true` on a successful delete.
          const: true
          example: true
        role:
          type: string
          description: The role that was cleared.
          enum: [logo, logo_on_light, pdf_background]
          example: logo
      example:
        deleted: true
        role: logo

    # ── Headless commerce ─────────────────────────────────────────────────────

    LineItemRequest:
      type: object
      title: Value gift-card line
      additionalProperties: false
      description: |
        One value gift-card line of a quote or order request. The buyer chooses the
        amount, so this line carries one. An order that buys experiences uses
        `ExperienceLineItemRequest` instead, and the two families never appear in the
        same request.
      required: [product_id, amount, quantity]
      properties:
        product_id:
          type: string
          const: gift_card_value
          description: |
            Product being bought, from `GET /v2/products`. `gift_card_value` is the
            company's variable-value gift card; an experience line names a `gcv_<id>`
            product instead.
          example: gift_card_value
        amount:
          type: integer
          minimum: 1
          description: |
            Face value of one gift card. Must lie between the product's
            `pricing.minimum_amount` and `pricing.maximum_amount`.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
            **Not the same unit as `amount` on `POST /v2/items`**, which takes
            decimal kroner — sending `500` here buys a DKK 5.00 card.
          example: 50000
        quantity:
          type: integer
          minimum: 1
          description: |
            How many identical cards to buy. Must not exceed the product's
            `quantity.maximum`, which is enforced rather than advertised, and the
            quantities of every line together may not exceed 1000 cards in one order.
          example: 1
      example:
        product_id: gift_card_value
        amount: 50000
        quantity: 1

    ExperienceLineItemRequest:
      type: object
      title: Experience line
      additionalProperties: false
      description: |
        One experience line of a quote or order request. The option's configured price
        is authoritative, so this line names what is being bought and how many, and
        never how much it costs.
      required: [product_id, product_option_id, quantity]
      properties:
        product_id:
          type: string
          pattern: '^gcv_[0-9]+$'
          description: |
            The experience being bought, from `GET /v2/products` — a `gcv_<id>` product
            of `type` `gift_card_variant`. It must be the experience the option below
            belongs to; a mismatched pair is rejected with `422` on this field.
          example: gcv_4821
        product_option_id:
          type: string
          pattern: '^gcvo_[0-9]+$'
          description: |
            The option the buyer picked, from that product's `options` array. It sets
            the price and the quantity bounds of this line. An option may appear only
            once per request. An option that `GET /v2/products` does not publish — a
            foreign, private, inactive or de-scheduled one — is rejected with `422` and
            the message `Product does not exist.`, the same answer an invented id gets.
          example: gcvo_9107
        quantity:
          type: integer
          minimum: 1
          description: |
            How many cards to buy from this option. It must satisfy the option's
            `quantity.minimum` and `quantity.maximum` from the catalog — the published
            maximum is the enforced one — and a limited option is never sold beyond its
            remaining stock. The quantities of every line together may not exceed 1000
            cards in one order.
          example: 2
      example:
        product_id: gcv_4821
        product_option_id: gcvo_9107
        quantity: 2

    EventTicketLineItemRequest:
      type: object
      title: Event ticket line
      additionalProperties: false
      description: |
        One event-ticket line of a quote or order request. The option's configured price
        is authoritative, so this line names the event, the ticket type, how many seats,
        and whatever the organiser asked the buyer for.

        **The ticket fee is charged per seat, not per line** — the event product says so
        with `fee_basis` of `per_ticket`. A line of two seats pays it twice.

        An event cannot be posted: `delivery.method` of `postal` on an order carrying
        one is rejected with `422`, and `amounts.shipping` is always `0`.
      required: [product_id, product_option_id, quantity]
      properties:
        product_id:
          type: string
          pattern: '^evt_[0-9]+$'
          description: |
            The event being bought, from `GET /v2/products` — an `evt_<id>` product of
            `type` `event_ticket`. It must be the event the option below belongs to; a
            mismatched pair is rejected with `422` on this field.
          example: evt_412
        product_option_id:
          type: string
          pattern: '^evto_[0-9]+$'
          description: |
            The ticket type the buyer picked, from that product's `options` array. It
            sets the price, the fee and the quantity bounds of this line. An option may
            appear only once per request. An option `GET /v2/products` does not publish
            — one belonging to a foreign, private, inactive or undated event — is
            rejected with `422` and the message `Product does not exist.`
          example: evto_9310
        quantity:
          type: integer
          minimum: 1
          description: |
            How many seats to buy from this ticket type. It must satisfy the option's
            `quantity.minimum` and `quantity.maximum`, and a sold-out ticket type is
            never oversold. An event whose `capacity_mode` is `per_order` caps the seats
            of that event across every line of the order at its `tickets_per_event`.
            The quantities of every line together may not exceed 1000 items.
          example: 2
        ticket:
          allOf:
            - $ref: '#/components/schemas/TicketDetailsRequest'
          description: |
            Optional. What the organiser asked this buyer for. Each member is required
            only when the event or the option demands it, so read the product first and
            send what it asks for.
      example:
        product_id: evt_412
        product_option_id: evto_9310
        quantity: 2
        ticket:
          comment: Two vegetarians
          collection_point_id: epp_31

    SpecialOfferLineItemRequest:
      type: object
      title: Special offer line
      additionalProperties: false
      description: |
        One special-offer line of a quote or order request. The option's configured price
        is authoritative, so this line names what is being bought and how many, never how
        much it costs.

        The order fee is charged once per line whatever the quantity, which is what the
        product's `fee_basis` of `per_line` says. A special offer cannot be posted:
        `delivery.method` of `postal` is rejected with `422` and `amounts.shipping` is
        always `0`.
      required: [product_id, product_option_id, quantity]
      properties:
        product_id:
          type: string
          pattern: '^so_[0-9]+$'
          description: |
            The offer being bought, from `GET /v2/products` — an `so_<id>` product of
            `type` `special_offer`. It must be the offer the option below belongs to; a
            mismatched pair is rejected with `422` on this field.
          example: so_884
        product_option_id:
          type: string
          pattern: '^sov_[0-9]+$'
          description: |
            The voucher the buyer picked, from that product's `options` array. An option
            may appear only once per request. One `GET /v2/products` does not publish is
            rejected with `422` and the message `Product does not exist.`
          example: sov_5501
        quantity:
          type: integer
          minimum: 1
          description: |
            How many vouchers to buy from this option. It must satisfy the option's
            `quantity.minimum` and `quantity.maximum`, and a limited option is never sold
            beyond its remaining stock. A quantity outside those bounds is rejected with
            `422` under `below_minimum` or `above_maximum`; losing the last voucher to
            another buyer between the quote and the order answers `409` with
            `insufficient_stock`. The quantities of every line together may not exceed
            1000 items.
          example: 1
      example:
        product_id: so_884
        product_option_id: sov_5501
        quantity: 1

    TicketDetailsRequest:
      type: object
      additionalProperties: false
      description: |
        What the organiser of an event asked the buyer for, carried on an event line.
        Every member is optional until the event or the option demands it, and a key this
        object does not list is rejected with `422` rather than ignored.
      properties:
        comment:
          type: string
          maxLength: 500
          description: |
            The buyer's note to the organiser. Accepted only while the event's
            `event.comment.available` is `true`; sending one otherwise is rejected.
            `event.comment.label` is the question to put to the buyer.

            **One comment per event, not per line.** Two lines of the same event carrying
            different comments are rejected rather than one being silently dropped,
            because the engine keeps a single comment per event.
          example: Two vegetarians
        code:
          type: string
          maxLength: 100
          description: |
            The code the organiser demanded. Required when the option's `requires.code`
            is `true`, and stored on the issued ticket.
          example: STAFF-2026
        collection_point_id:
          type: string
          pattern: '^epp_[0-9]+$'
          description: |
            Where the ticket is collected at the event, from that event's own
            `event.collection_points`. Required when the option's
            `requires.collection_point` is `true`.

            **Not a courier collection point.** `GET /v2/pickup-points` answers with
            `gls-…` ids for posting a gift card; these are places at the event itself.
          example: epp_31
        address:
          allOf:
            - $ref: '#/components/schemas/TicketAddressRequest'
          description: |
            Where the ticket is delivered by hand. Required when the option's
            `requires.address` is `true` and no collection point is given. It is an
            ALTERNATIVE to `collection_point_id`: sending both is rejected.
      example:
        comment: Two vegetarians
        code: STAFF-2026
        collection_point_id: epp_31

    TicketAddressRequest:
      type: object
      additionalProperties: false
      description: |
        Where an event ticket is delivered by hand. All three fields are required
        together: they are written to one row, and a half-filled address delivers
        nothing useful to the organiser.
      required: [name, street, city]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Who the ticket is delivered to.
          example: Bo Hansen
        street:
          type: string
          minLength: 1
          maxLength: 255
          description: Street and house number.
          example: Gothersgade 12
        city:
          type: string
          minLength: 1
          maxLength: 255
          description: City.
          example: København K

    QuoteRequest:
      type: object
      additionalProperties: false
      description: The selection to price.
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 20
          description: |
            The lines to price. One value gift-card line, or one to twenty lines of a
            single other family — experiences, event tickets or special offers. Mixing
            families in one request is rejected with `422`, and so is a second value
            line. Naming the same option twice is rejected too — buy several of one
            option with `quantity`. The quantities of every line together may not exceed
            1000 items.
          items:
            oneOf:
              - $ref: '#/components/schemas/LineItemRequest'
              - $ref: '#/components/schemas/ExperienceLineItemRequest'
              - $ref: '#/components/schemas/EventTicketLineItemRequest'
              - $ref: '#/components/schemas/SpecialOfferLineItemRequest'
        delivery:
          allOf:
            - $ref: '#/components/schemas/DeliveryRequest'
          description: |
            **Send this to price the postage.** A quote without it reports
            `amounts.shipping` of `0`, and a postal order then charges more than the
            quote showed — which is the exact failure the field exists to prevent. Send
            the same object you will send to `POST /v2/orders`.
        greeting:
          allOf:
            - $ref: '#/components/schemas/GreetingRequest'
          description: |
            Accepted so one body shape serves both routes, and ignored: a greeting costs
            nothing and changes no total. Send it or leave it out.
        sender:
          allOf:
            - $ref: '#/components/schemas/SenderParty'
          description: |
            Accepted so one body shape serves both routes, and ignored: a buyer's
            identity changes no price. Its `company` is validated when the order is
            created, not here, so a quote never refuses a malformed one.
        discount_code:
          allOf:
            - $ref: '#/components/schemas/DiscountCodeRequest'
          description: |
            Optional. The discount code the buyer supplied. Quote with it the moment they
            apply it, so the figure you show is the figure the order charges.
      example:
        items:
          - product_id: gcv_4821
            product_option_id: gcvo_9107
            quantity: 2
          - product_id: gcv_4821
            product_option_id: gcvo_9108
            quantity: 1
        delivery:
          method: postal
          pickup_point_id: gls-DK-1234
          address:
            kind: private
            name: Bo Hansen
            street: Gothersgade 12
            postcode: "1123"
            city: København K
            country: DK
            email: bo@example.com

    Party:
      type: object
      additionalProperties: false
      description: A named person on an order — the buyer, or the gift's recipient.
      required: [name, email]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Full name as it should appear on the gift card. 1 to 255 characters.
          example: Alex Sender
        email:
          type: string
          format: email
          maxLength: 255
          description: |
            Email address. The recipient's address is where Lifepeaks delivers the gift
            card once the order is fulfilled. At most 255 characters.
          example: alex@example.com
      example:
        name: Alex Sender
        email: alex@example.com

    SenderParty:
      type: object
      additionalProperties: false
      description: |
        The buyer — the one side of an order that may be a business.

        `company` sits here and nowhere else. It is the buyer's OWN company, the one that
        paid. The company an envelope is addressed to is
        `delivery.address.company_name`, a different field with a different meaning, so a
        recipient never carries a `company` and one sent there is rejected.
      required: [name, email]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Full name as it should appear on the gift card. 1 to 255 characters.
          example: Alex Sender
        email:
          type: string
          format: email
          maxLength: 255
          description: The buyer's email address. At most 255 characters.
          example: alex@example.com
        company:
          allOf:
            - $ref: '#/components/schemas/SenderCompany'
          description: |
            Optional. The buyer's own company, when a business is buying rather than a
            private person. Omit it entirely for a private buyer.
      example:
        name: Alex Sender
        email: alex@example.com
        company:
          name: Acme ApS
          vat_number: DK12345678

    SenderCompany:
      type: object
      additionalProperties: false
      description: |
        The company that bought the order.

        **The address is optional AS A SET.** Send `street`, `postcode`, `city` and
        `country` together, or send none of them; three of the four is rejected with
        `422` naming the one left out. A company with only a name and a VAT number is a
        complete answer. That rule cannot be expressed in a flat `required` list, which
        is why it is stated here.

        **A product may demand one.** An experience or a special offer whose
        `company_required` is `true`, and an event whose `event.requires.company` is
        `true`, refuse an order with no company — and refuse one carrying a company
        without its address — with `422` and an `error.code` of
        `sender_company_required`.

        Sending this also turns on the selling partner's business-buyer text on the
        generated document, where the partner has configured one.
      required: [name, vat_number]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: The company's name. 1 to 255 characters.
          example: Acme ApS
        vat_number:
          type: string
          minLength: 1
          maxLength: 50
          description: |
            The company's VAT registration number — the CVR number for a Danish company.
            **Required whenever this object is present**, matching what the Lifepeaks
            order form asks of every company buyer.
          example: DK12345678
        street:
          type: string
          minLength: 1
          maxLength: 255
          description: Street and house number. Part of the all-or-nothing address set.
          example: Gothersgade 12
        postcode:
          type: string
          minLength: 1
          maxLength: 255
          description: Postal code. Part of the all-or-nothing address set.
          example: "1123"
        city:
          type: string
          minLength: 1
          maxLength: 255
          description: City. Part of the all-or-nothing address set.
          example: København K
        country:
          type: string
          pattern: '^[A-Za-z]{2}$'
          description: |
            ISO 3166-1 alpha-2 country code. Part of the all-or-nothing address set. It
            is stored as the country's English name and read back as the alpha-2 code
            you sent; a value the ISO table does not know is rejected with `422`.
          example: DK
      example:
        name: Acme ApS
        vat_number: DK12345678
        street: Gothersgade 12
        postcode: "1123"
        city: København K
        country: DK

    DiscountCodeRequest:
      type: string
      minLength: 5
      maxLength: 25
      description: |
        A discount code the buyer supplied, exactly as they typed it — matching is
        case-insensitive. It names a campaign the partner created in the Lifepeaks back
        office; no API mints one, and nothing in the request says what a code is worth.

        Lifepeaks resolves it against the acting company and calculates the money. The
        answer comes back as `amounts.discount`, as each line's own `discount`, and as
        the top-level `discount` object naming the code and its percentage.

        **What a code can reduce:** value gift-card lines, experience lines, and postage.
        A shipping campaign comes off `amounts.shipping` itself and leaves
        `amounts.discount` at `0`. **Fees are never discounted**, and neither is an event
        ticket or a special offer — an order made only of those is rejected with
        `discount_code_not_applicable`.

        An unknown, switched-off, unopened, expired or exhausted code is rejected with
        `discount_code_not_found`: one answer for all five, so the field cannot be used
        to discover a competitor's campaigns.
      example: BLACKFRIDAY

    OrderRequest:
      type: object
      additionalProperties: false
      description: Everything Lifepeaks needs to create one unpaid order.
      required: [items, sender, recipient, return_url]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 20
          description: |
            The lines to order, matching what you quoted. One value gift-card line, or
            one to twenty lines of a single other family — experiences, event tickets or
            special offers. However many lines an order carries, it is paid once, in one
            checkout. The quantities of every line together may not exceed 1000 items,
            and an order total needing more than six significant digits is refused with
            `amount_precision_unsupported` before anything is written.

            **A quote reserved nothing.** This route locks the option rows inside its own
            transaction and re-runs every minimum, maximum, stock and capacity check
            against the locked figures before it writes, so an option that sold out
            between the quote and the order is refused here rather than oversold.
          items:
            oneOf:
              - $ref: '#/components/schemas/LineItemRequest'
              - $ref: '#/components/schemas/ExperienceLineItemRequest'
              - $ref: '#/components/schemas/EventTicketLineItemRequest'
              - $ref: '#/components/schemas/SpecialOfferLineItemRequest'
        sender:
          allOf:
            - $ref: '#/components/schemas/SenderParty'
          description: |
            The buyer. Their name appears on the gift card as the giver, and their
            `company` says a business bought it rather than a private person.
        recipient:
          allOf:
            - $ref: '#/components/schemas/Party'
          description: |
            Who receives the gift card. Lifepeaks emails the card to this address after
            payment is confirmed.
        client_reference:
          type: string
          minLength: 1
          maxLength: 100
          description: |
            Your own identifier for this purchase — a cart or basket id. Stored on the
            order and echoed back on every read, which makes it the natural key for
            reconciling Lifepeaks orders against your records. 1 to 100 characters.
          example: cart_018f4f37b949
        locale:
          type: string
          default: da-DK
          pattern: '^[a-z]{2}(-[A-Z]{2})?$'
          description: |
            BCP 47 locale for the hosted checkout page and the delivery email, for
            example `da-DK` or `en-GB`. Defaults to `da-DK`.
          example: da-DK
        return_url:
          type: string
          format: uri
          maxLength: 2000
          description: |
            HTTPS URL the buyer's browser is sent back to after checkout. It must be an
            origin Lifepeaks has approved for the company. Treat the return purely as a
            signal to go and read the canonical order — arriving here proves nothing
            about payment. At most 2000 characters.
          example: https://partner.example/checkout/return
        greeting:
          allOf:
            - $ref: '#/components/schemas/GreetingRequest'
          description: |
            Optional. The buyer's message and the picture that goes with it. It applies
            to every card the order produces.
        delivery:
          allOf:
            - $ref: '#/components/schemas/DeliveryRequest'
          description: |
            Optional. How and when the card reaches its recipient. Read
            the product's `delivery_methods` first and offer only what it reports as
            available.
        marketing_consent:
          allOf:
            - $ref: '#/components/schemas/MarketingConsentRequest'
          description: |
            Optional. What the buyer answered when you asked to send them marketing.
            Omit it and nothing is recorded.
        discount_code:
          allOf:
            - $ref: '#/components/schemas/DiscountCodeRequest'
          description: |
            Optional. The discount code the buyer supplied. A limited-use code is
            consumed when the order is written, not when it was quoted, so one that ran
            out in between is refused here.
        company:
          type: string
          description: |
            Slug of the company to act as, for a key assigned to more than one. This
            operation accepts it in the body as well as in the `company` query
            parameter; the query string wins when both carry it. Omit it and the request
            acts as the key's own company. A slug that is unknown, inactive or not
            assigned to the key fails with `404 not_found`.
          example: acme-hotels
      example:
        items:
          - product_id: gift_card_value
            amount: 50000
            quantity: 1
        sender:
          name: Alex Sender
          email: alex@example.com
        recipient:
          name: Sam Recipient
          email: sam@example.com
        greeting:
          message: Tillykke med dagen!
          image:
            source: design
            design_id: gid_4711
        delivery:
          method: recipient_email
          send_at: '2026-12-24T09:00:00+01:00'
        client_reference: cart_018f4f37b949
        locale: da-DK
        return_url: https://partner.example/checkout/return

    DeliveryMethods:
      type: array
      description: |
        How a buyer can receive this card, and what each way costs. It is a property of
        the company rather than of the product, so every product of one company carries
        the same list — and there is no separate route for it, so the list a storefront
        renders and the list an order is checked against cannot drift apart.

        All three methods are always present. One the company does not offer comes back
        with `available` of `false` rather than being left out, so your page can say
        "this partner does not post gift cards" instead of hiding an option it cannot
        explain. Do not offer an unavailable method: an order naming it is refused.

        An `id` here is what an order sends as `delivery.method`.
      items: { $ref: '#/components/schemas/DeliveryMethod' }

    Personalization:
      type: object
      description: |
        What a buyer may add to a card of their own — a greeting, and a picture beside
        it. Like the delivery methods, it is a property of the company, so every product
        of one company carries the same block.
      required: [greeting, designs, amount_images]
      properties:
        greeting:
          type: object
          description: The greeting a buyer may write, and the limits it is held to.
          required: [available, max_length, image]
          properties:
            available:
              type: boolean
              description: |
                Whether this company's buyers are offered a greeting at all. Branch on
                it rather than assuming, and read it per product: it is what the
                Lifepeaks-hosted order page would offer the same buyer. It is `true`
                for every company today.
              example: true
            max_length:
              type: integer
              description: |
                The longest `greeting.message` an order accepts, in characters. Bound
                your character counter by this figure rather than a number of your own:
                it is the number the order path rejects at.
              example: 2000
            image:
              type: object
              description: |
                The limits a buyer's own photograph is held to when you upload one
                through `POST /v2/greeting-images/upload-intents`. They are the same
                values that route enforces, so your copy and its rejection cannot
                disagree.
              required: [accepted_mime, max_bytes]
              properties:
                accepted_mime:
                  type: array
                  description: The file types a buyer's picture may be.
                  items: { type: string }
                  example: [image/jpeg, image/png, image/webp]
                max_bytes:
                  type: integer
                  description: The largest picture accepted, in bytes.
                  example: 10485760
        designs:
          type: array
          description: |
            The pictures this company offers for the greeting page. An id from this list
            is what an order names as `greeting.image.design_id`. Empty means the company
            offers none, which is normal: read it as "do not show the picture chooser",
            and let the buyer write a message on its own.
          items: { $ref: '#/components/schemas/GreetingDesign' }
        amount_images:
          type: array
          description: |
            The decorative pictures the Lifepeaks order page shows beside the amount
            step, as plain URLs. There is nothing to choose here — they are scenery, and
            a buyer never picks one, which is why they carry no ids. The pictures a buyer
            CAN pick are `designs`.

            A company that has uploaded none of its own falls back to its greeting
            pictures and then to Lifepeaks' defaults, so this is usually non-empty. Show
            them, or leave them out, as your own design calls for.
          items:
            type: string
            format: uri
            description: One picture, ready to render.
          example:
            - https://cdn.lifepeaks.dk/dev/order_form_amount_images/thumb/330x200/strip-a.jpg

    Terms:
      type: object
      description: |
        What a buyer accepts before paying. Your storefront collects the consent on its
        own page, so it has to be able to show the same terms the Lifepeaks-hosted order
        page shows.

        Exactly one of the two is ever set, so there is nothing to choose between: link
        to `url` when it is present, render `html` when it is not. Both are `null` for a
        company that has published no terms at all.
      required: [url, html]
      properties:
        url:
          type: [string, "null"]
          format: uri
          description: |
            The company's own terms page, when it set one. Link to it.
          example: https://acme-hotels.example/terms
        html:
          type: [string, "null"]
          description: |
            The terms as HTML, assembled the way the Lifepeaks order page assembles
            them, when the company set no link of its own. It is sanitised before it
            leaves the API: the formatting an operator wrote survives, and nothing in it
            can execute on your page. Render it inside your own layout.
          example: "<p>Gift cards are valid for 36 months.</p>"

    DeliveryMethod:
      type: object
      description: |
        One way a buyer can receive a gift card, and what it costs this company's
        buyers. Every product of the company carries the same list, because how a card
        travels is a property of the company rather than of the product.
      required: [id, label, available, price, price_varies, supports_send_at]
      properties:
        label:
          type: string
          description: |
            What the Lifepeaks-hosted order page calls this method, in the response
            language. Show it, or write your own copy — the `id` is what an order names.
          example: with GLS
        id:
          type: string
          enum: [sender_email, recipient_email, postal]
          description: |
            `sender_email` e-mails the card to the buyer, who passes it on. Its cost is
            zero, because the card travels on the payment receipt. `recipient_email`
            e-mails it straight to the recipient. `postal` posts a printed card.
          example: postal
        available:
          type: boolean
          description: |
            Whether this company offers the method. A method with `false` here is still
            listed, with its prices, so your page can explain why it is not on offer —
            but an order naming it is refused.
          example: true
        price:
          type: [integer, "null"]
          description: |
            A FROM-price: the cheapest this method can cost anywhere, or `null` when no
            price is configured for it. It is the figure a landing page shows with a
            "from"; the exact price for a chosen country and address comes from
            `POST /v2/quotes`.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 6000
        price_varies:
          type: boolean
          description: |
            Whether more than one price sits behind `price` — by destination, by shape
            of address, or because each card after the first adds `extra_item_price`.
            When it is `true`, show `price` as a from-price and quote the order before
            you show a total. It is always `false` on an e-mail method, which is free.
          example: true
        supports_send_at:
          type: boolean
          description: |
            Whether an order using this method may carry `delivery.send_at`. True on the
            recipient's own e-mail, and on `postal` for a company that posts its own
            cards — that company controls when the card leaves, so it can hold it back.
            A courier-carried postal method cannot be scheduled. Read the flag rather
            than assuming the method.
          example: false
        carrier:
          type: string
          description: |
            Postal only. The courier carrying the card, or `own_post` when the company
            posts its cards itself.
          example: gls
        pickup_point_required:
          type: boolean
          description: |
            Postal only. Whether the buyer must choose a collection point from
            `GET /v2/pickup-points`.
          example: true
        extra_item_price:
          type: integer
          description: |
            Postal only. What each card after the first adds to one postal order.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 1000
        phone_required:
          type: boolean
          description: |
            Postal only. Whether the company requires a phone number with a postal
            address.
          example: false
        destinations:
          type: array
          description: |
            Postal only. Where the card can be sent, and what each shape of address
            costs there.
          items: { $ref: '#/components/schemas/DeliveryDestination' }
        priorities:
          type: array
          description: |
            Postal only, and only for a company that posts its own cards: how fast the
            card can travel, and what each speed costs. An order names one of these ids
            as `delivery.shipping_priority`.

            **The key is absent whenever there is no real choice to publish** — fewer
            than two priorities, two that price the same, or a default that is not the
            cheapest. Read its presence as "offer the buyer a choice" and leave the
            control out otherwise. A company shipping with a courier never publishes it,
            because a courier offers one speed.
          items: { $ref: '#/components/schemas/ShippingPriority' }
      example:
        id: postal
        label: with GLS
        available: true
        price: 6000
        price_varies: true
        supports_send_at: false
        carrier: gls
        pickup_point_required: true
        extra_item_price: 1000
        phone_required: false
        destinations:
          - country: DK
            name: Denmark
            pickup_price: 6000
            private_address_price: null
            company_address_price: 7000

    ShippingPriority:
      type: object
      description: |
        One speed a company that posts its own cards offers, and what it costs.
      required: [id, label, description, hint, price, default]
      properties:
        id:
          type: string
          pattern: '^spri_[0-9]+$'
          description: |
            The id an order names as `delivery.shipping_priority`.
          example: spri_2
        label:
          type: string
          description: |
            What the Lifepeaks-hosted order page calls this speed, in the response
            language.
          example: Quickbrev
        description:
          type: [string, "null"]
          description: The company's own line about this speed, or `null`.
          example: 1-2 hverdage
        hint:
          type: [string, "null"]
          description: A further note the company configured, or `null`.
          example: null
        price:
          type: integer
          description: |
            What posting ONE card at this speed costs, excluding the surcharge for each
            card after the first — the same convention `destinations[].*_price` uses.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 7500
        default:
          type: boolean
          description: |
            Whether this is the speed an order with no `shipping_priority` is charged.
            Exactly one entry carries `true`, and it is always the cheapest — it is the
            price `destinations[].*_price` already publishes.
          example: false
      example:
        id: spri_2
        label: Quickbrev
        description: 1-2 hverdage
        hint: null
        price: 7500
        default: false

    DeliveryDestination:
      type: object
      description: |
        One country a printed card can be sent to, with one price per shape of address.
        A price of `null` means that shape is not offered there.
      required: [country, name, pickup_price, private_address_price, company_address_price]
      properties:
        country:
          type: string
          description: The country as a two-letter ISO 3166-1 code.
          example: DK
        name:
          type: string
          description: The country's English name, as the courier lists it.
          example: Denmark
        pickup_price:
          type: [integer, "null"]
          description: |
            What it costs to send the card to a collection point in this country, or
            `null` when collection points are not offered there.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 6000
        private_address_price:
          type: [integer, "null"]
          description: |
            What it costs to send the card to a home address in this country, or `null`
            when home delivery is not offered there.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: null
        company_address_price:
          type: [integer, "null"]
          description: |
            What it costs to send the card to a business address in this country, or
            `null` when business delivery is not offered there.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 7000

    PostalAddressRead:
      type: object
      description: |
        Where a printed gift card was sent, as the order reads it back. It mirrors
        `PostalAddress`, which is the REQUEST shape, with one difference that matters to
        a generated client: every member is read from a database column, and a column
        that was never filled in reads `null` rather than an empty string.
      required: [kind, name, company_name, street, postcode, city, country, email, phone]
      properties:
        kind:
          type: string
          enum: [private, company]
          description: |
            Derived from whether a business name was stored, not echoed from the request.
          example: private
        name:
          type: [string, "null"]
          description: Who the card was addressed to.
          example: Bo Hansen
        company_name:
          type: [string, "null"]
          description: The business name, or `null` on a private address.
          example: null
        street:
          type: [string, "null"]
          description: Street and number the card was sent to.
          example: Gothersgade 12
        postcode:
          type: [string, "null"]
          description: Postcode it was sent to.
          example: "1123"
        city:
          type: [string, "null"]
          description: City or town it was sent to.
          example: København K
        country:
          type: [string, "null"]
          description: The destination as a two-letter ISO 3166-1 code.
          example: DK
        email:
          type: [string, "null"]
          description: Where the courier sent its track-and-trace notice.
          example: bo@example.com
        phone:
          type: [string, "null"]
          description: |
            The number given for the courier, or `null` when none was required.
          example: null

    PickupPoint:
      type: object
      description: |
        One shop or parcel locker the courier can deliver a printed card to.
      required: [object, id, name, address, latitude, longitude]
      properties:
        object:
          const: pickup_point
          description: Always `pickup_point`.
        id:
          type: string
          description: |
            The courier's own id for this point. It is what an order names as its
            collection point; treat it as opaque.
          example: gls-DK-1234
        name:
          type: string
          description: The shop's name, as the courier gives it.
          example: Kiosk Nørrebro
        address:
          type: string
          description: |
            The full address on one line — street, postcode, city and country, as the
            courier composes it. Show it as given rather than splitting it.
          example: Griffenfeldsgade 1, 2200, København N, DK
        latitude:
          type: [number, "null"]
          description: Latitude for a map pin, or `null` when the courier gave none.
          example: 55.6889
        longitude:
          type: [number, "null"]
          description: Longitude for a map pin, or `null` when the courier gave none.
          example: 12.5501

    GreetingDesign:
      type: object
      description: |
        One picture this company offers for the greeting page of a gift card. These
        print on the greeting page; they are not the artwork of the card itself, which
        is fixed per company and set in the Lifepeaks back office.
      required: [id, url, thumbnail_url, label]
      properties:
        id:
          type: string
          pattern: '^gid_[0-9]+$'
          description: |
            The picture's id, which an order names as `greeting.image.design_id`.
          example: gid_4711
        url:
          type: string
          format: uri
          description: The full-size picture, which is what prints on the greeting page.
          example: https://cdn.lifepeaks.dk/dev/images/birthday.jpg
        thumbnail_url:
          type: string
          format: uri
          description: A small version, for the chooser on your page.
          example: https://cdn.lifepeaks.dk/dev/images/thumb/330x200/birthday.jpg
        label:
          type: string
          description: |
            The position label the Lifepeaks order page uses for this picture, such as
            `Image1`. These pictures carry no names of their own.
          example: Image1

    GreetingImageUploadIntentRequest:
      type: object
      additionalProperties: false
      description: |
        The file a buyer is about to upload, declared before the bytes are sent.
      required: [sha256, byte_size, mime]
      properties:
        sha256:
          type: string
          pattern: '^[a-f0-9]{64}$'
          description: |
            SHA-256 of the file, as 64 lowercase hexadecimal characters. The bytes that
            arrive are checked against it at finalize.
          example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
        byte_size:
          type: integer
          minimum: 1
          maximum: 10485760
          description: |
            The file's exact size in bytes, at most 10,485,760. The bytes that arrive
            are checked against it at finalize.
          example: 248130
        mime:
          type: string
          enum: [image/jpeg, image/png, image/webp]
          description: |
            The file's type. Only these three are accepted, and the file's own leading
            bytes must agree with what you declare here.
          example: image/jpeg
        company:
          type: string
          description: |
            Slug of the company to act as, for a key assigned to more than one. This
            operation accepts it in the body as well as in the `company` query
            parameter; the query string wins when both carry it.
          example: acme-hotels

    GreetingImageUploadIntentEnvelope:
      type: object
      required: [upload_intent]
      description: The reserved upload slot.
      properties:
        upload_intent:
          $ref: '#/components/schemas/GreetingImageUploadIntent'

    GreetingImageUploadIntent:
      type: object
      description: |
        A reserved slot for one buyer's picture, and the short-lived target to send the
        bytes to.
      required: [object, id, status, expires_at, upload]
      properties:
        object:
          const: greeting_image_upload_intent
          description: Always `greeting_image_upload_intent`.
        id:
          type: string
          pattern: '^gimg_[a-f0-9]{32}$'
          description: |
            The slot's id. After finalize it is also the picture's id, which an order
            names as `greeting.image.upload_id`.
          example: gimg_5a3c81e0d74b426f90ac17b5e2d8630f
        status:
          type: string
          enum: [pending, finalized, consumed]
          description: |
            `pending` while the slot is waiting for its bytes, `finalized` once they
            passed every check, `consumed` once an order took the picture.
          example: pending
        expires_at:
          type: string
          format: date-time
          description: |
            When the slot closes, 15 minutes after it was reserved. Upload and finalize
            before then.
          example: '2026-09-03T09:15:00Z'
        upload:
          type: object
          description: |
            Where to send the bytes. Use it exactly as given: in a deployed environment
            the URL is signed and the headers carry the signature.
          required: [url, method, headers]
          properties:
            url:
              type: string
              description: The target to PUT the bytes to.
              example: /v2/greeting-images/upload-intents/gimg_5a3c81e0d74b426f90ac17b5e2d8630f/content
            method:
              type: string
              const: PUT
              description: Always `PUT`.
              example: PUT
            headers:
              type: object
              additionalProperties:
                type: string
                description: One header value to send with the upload.
              description: |
                The headers to send with the upload, exactly as given. Dropping or
                altering one makes a signed upload fail.
              example:
                Content-Type: image/jpeg
                Content-Length: '248130'

    GreetingImageEnvelope:
      type: object
      required: [greeting_image]
      description: The stored picture.
      properties:
        greeting_image:
          $ref: '#/components/schemas/UploadedGreetingImage'

    UploadedGreetingImage:
      type: object
      description: |
        A buyer's own picture, checked and stored, ready to be named on exactly one
        order.
      required: [object, id, url, thumbnail_url]
      properties:
        object:
          const: greeting_image
          description: Always `greeting_image`.
        id:
          type: string
          pattern: '^gimg_[a-f0-9]{32}$'
          description: |
            The picture's id, which one order names as `greeting.image.upload_id`. No
            second order may use it.
          example: gimg_5a3c81e0d74b426f90ac17b5e2d8630f
        url:
          type: string
          format: uri
          description: The stored picture, which is what prints on the greeting page.
          example: https://cdn.lifepeaks.dk/dev/image/1756900000-123.jpg
        thumbnail_url:
          type: string
          format: uri
          description: A small version, for showing the buyer what they chose.
          example: https://cdn.lifepeaks.dk/dev/image/thumb/330x200/1756900000-123.jpg

    MarketingConsentRequest:
      type: object
      additionalProperties: false
      description: |
        What the buyer answered when you asked to send them marketing, recorded with the
        exact wording they were shown. Omit the object entirely and nothing is recorded,
        which is what an unticked box means. Send it with `granted: false` to record that
        they were asked and declined.

        A consent becomes a subscriber on `GET /v2/subscribers` only once the order's
        payment is captured, so a buyer who consents and then abandons checkout never
        appears.
      required: [granted, source]
      properties:
        granted:
          type: boolean
          description: |
            `true` when the buyer opted in, `false` when they were asked and declined.
          example: true
        source:
          type: string
          enum: [storefront_checkout, partner_checkout, partner_import]
          description: |
            Where the consent was taken, so a later dispute can be traced to the page
            that carried the sentence.
          example: partner_checkout
        consent_text:
          type: string
          minLength: 1
          maxLength: 500
          description: |
            The exact wording the buyer was shown, copied verbatim. Required whenever
            `granted` is `true`: this is the evidence, so it must be the sentence that
            was actually on the page and never a paraphrase. At most 500 characters.
          example: Ja tak, send mig nyheder og tilbud på e-mail. Du kan afmelde når som helst.
        consent_text_version:
          type: string
          minLength: 1
          maxLength: 40
          description: |
            Optional. Your own label for that wording, so you can tell which version of
            your consent sentence a buyer agreed to. At most 40 characters.
          example: '2026-09-01'
      example:
        granted: true
        source: partner_checkout
        consent_text: Ja tak, send mig nyheder og tilbud på e-mail. Du kan afmelde når som helst.
        consent_text_version: '2026-09-01'

    GreetingRequest:
      type: object
      additionalProperties: false
      description: |
        The greeting printed on its own page of the gift card and shown in the delivery
        e-mail. Omit it entirely for a card with no greeting.
      properties:
        message:
          type: string
          maxLength: 2000
          minLength: 1
          description: |
            The buyer's message, at most 2000 characters. It is trimmed first, and one
            that is empty or only whitespace is refused rather than stored. It is printed on the greeting
            page and shown in the e-mail.
          example: Tillykke med dagen!
        image:
          $ref: '#/components/schemas/GreetingImageRequest'
      example:
        message: Tillykke med dagen!
        image:
          source: design
          design_id: gid_4711

    GreetingImageRequest:
      type: object
      additionalProperties: false
      description: |
        The picture that goes on the greeting page with the message. A greeting can
        carry a message alone, a picture alone, or both.
      required: [source]
      properties:
        source:
          type: string
          enum: [design, upload, none]
          description: |
            `design` picks one of the company's own pictures from
            the product's `personalization.designs`. `upload` names a buyer's own, already finalized
            through the upload flow. `none` means no picture.
          example: design
        design_id:
          type: string
          pattern: '^gid_[0-9]+$'
          description: |
            Required when `source` is `design`, and rejected otherwise. An id from
            the product's `personalization.designs`. One that resolves to nothing
            answers `422` with `greeting_image_not_found` on this field, the way
            every id sent in a body is refused. Every miss reads alike, so the answer
            confirms nothing about a picture you do not own.
          example: gid_4711
        upload_id:
          type: string
          pattern: '^gimg_[a-f0-9]{32}$'
          description: |
            Required when `source` is `upload`, and rejected otherwise. The id a
            finalized upload returned. It works exactly once: a second order naming it
            is refused.
          example: gimg_5a3c81e0d74b426f90ac17b5e2d8630f

    DeliveryRequest:
      type: object
      additionalProperties: false
      description: |
        How and when the gift card reaches its recipient. Omit it and Lifepeaks sends
        the card as soon as the payment is confirmed, to the recipient's own e-mail when
        it differs from the buyer's and to the buyer otherwise.
      properties:
        method:
          type: string
          enum: [sender_email, recipient_email, postal]
          description: |
            How the card is delivered. It must be a method
            the product's `delivery_methods` reports as available for this company; one it
            does not offer is refused by name.
          example: recipient_email
        send_at:
          type: string
          format: date-time
          description: |
            When to send the card, as an RFC 3339 timestamp **with an offset**, for
            example `2026-12-24T09:00:00+01:00`. A timestamp without an offset is
            refused rather than guessed at, because a card that arrives nine hours late
            is a real failure. It must be in the future and at most 24 months ahead, and
            only a method whose `supports_send_at` is `true` accepts it.

            A scheduled card is not fulfilled at payment. The order stays paid and
            unfulfilled until the moment arrives, and `order.fulfilled` fires then.

            **`send_at` on its own is enough.** An order carrying it is scheduled to the
            recipient whether or not you name a `method`, so a body a quote accepted is
            accepted by the order.
          example: '2026-12-24T09:00:00+01:00'
        address:
          allOf:
            - $ref: '#/components/schemas/PostalAddress'
          description: |
            Required when `method` is `postal`, and rejected on any other method. Where
            the printed card is sent.
        pickup_point_id:
          type: string
          maxLength: 50
          description: |
            Postal only. The `id` of a point from
            `GET /v2/pickup-points`. Sending one makes this a collection
            delivery and the address becomes the buyer's own details for the label;
            leaving it out makes it a delivery to the address itself. The two are priced
            differently, and the product's `delivery_methods` publishes both figures per
            country.
          example: gls-DK-1234
        shipping_priority:
          type: string
          pattern: '^spri_[0-9]+$'
          description: |
            Postal only, and only for a company that posts its own cards: how fast the
            card travels. It must be an id from the `priorities` array on the product's
            `postal` method.

            **That array is absent whenever there is no real choice to publish** — fewer
            than two priorities, two that price the same, or a default that is not the
            cheapest — so read the product before offering the control. A company whose
            cards travel with a courier publishes no `priorities` at all, and a priority
            sent to one is rejected with `shipping_priority_unsupported` rather than
            ignored. An id that company does not publish is rejected with
            `shipping_priority_unavailable`.

            Omitting it charges the default, which is always the cheapest entry and the
            price `destinations[]` already publishes, so an order that says nothing about
            a priority prices exactly as it did before the field existed.
          example: spri_2
      example:
        method: recipient_email
        send_at: '2026-12-24T09:00:00+01:00'

    PostalAddress:
      type: object
      additionalProperties: false
      description: |
        Where a printed gift card is sent, and who to. The country is checked against
        what the courier actually serves for this company, so an unshippable order is
        refused before any money moves rather than failing days later at the courier.
      required: [name, street, postcode, city, country, email]
      properties:
        kind:
          type: string
          enum: [private, company]
          default: private
          description: |
            `private` for a person, `company` for a business. A business address costs
            a different amount and needs `company_name`.
          example: private
        name:
          type: string
          maxLength: 255
          description: Who the card is addressed to.
          example: Bo Hansen
        company_name:
          type: string
          maxLength: 255
          description: |
            The business name. Required when `kind` is `company`, and optional otherwise.
          example: Acme Hotels A/S
        street:
          type: string
          maxLength: 255
          description: Street and number.
          example: Gothersgade 12
        postcode:
          type: string
          maxLength: 10
          description: Postcode.
          example: "1123"
        city:
          type: string
          maxLength: 255
          description: City or town.
          example: København K
        country:
          type: string
          pattern: '^[A-Za-z]{2}$'
          description: |
            Destination as a two-letter ISO 3166-1 code. It must be one
            the product's `delivery_methods` lists as a destination for the shape of delivery
            you asked for; one the courier does not serve is refused.
          example: DK
        email:
          type: string
          format: email
          maxLength: 50
          description: |
            Where the courier sends its track-and-trace notice. Not necessarily the
            recipient's e-mail on the order.
          example: bo@example.com
        phone:
          type: string
          maxLength: 50
          description: |
            A phone number for the courier. Required when the company's
            `phone_required` is `true` on the product's postal method, and optional
            otherwise.
          example: "+4512345678"

    OrderRefund:
      type: object
      description: |
        What a refund actually did, read back off the payment rather than echoed from the
        request. Every amount is a whole number in the smallest unit of the currency.
      required: [object, order_id, amount, refunded, refundable, currency, payment]
      properties:
        object:
          const: refund
          description: Always `refund`.
        order_id:
          type: string
          description: The order the money came back from.
          example: po_2f8c1d4e6a7b8c9d0e1f2a3b4c5d6e7f
        amount:
          type: integer
          description: |
            What this call returned. It can be less than you asked for: a gift card used
            as payment absorbs part of a refund before the card is touched, and the
            gateway clamps.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 12500
        refunded:
          type: integer
          description: |
            The running total returned on this order, including this call.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 12500
        refundable:
          type: integer
          description: |
            What is still refundable after this call. `0` means the order is now fully
            refunded.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 37500
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: ISO 4217 currency the refund was made in.
          example: DKK
        payment:
          type: object
          description: The order's payment state after the refund.
          required: [status]
          properties:
            status:
              type: string
              enum: [pending, authorized, captured, failed, cancelled, refunded, partially_refunded]
              description: |
                `partially_refunded` while money remains, `refunded` once nothing is left.
              example: partially_refunded

    Amounts:
      type: object
      description: |
        The authoritative money breakdown, calculated by Lifepeaks. Every field here is a
        whole number in the smallest unit of the currency (øre for DKK), and `total` is
        what the buyer is actually charged.

        **`total` = `subtotal` − `discount` + `fees` + `shipping`.** `tax` is NOT a term
        in that sum: it is the VAT already contained in `fees` and `shipping`, reported
        so you can show it, not so you can add it. Adding it charges the buyer the VAT
        twice.

        `discount` is `0` on every order that carried no code, so without one the sum is
        `total` = `subtotal` + `fees` + `shipping`, exactly as it always was. Code that
        recomputes a total from the parts must subtract `discount`, or it will be wrong
        by that amount on any order that used a code and right on every order that did
        not — which is what makes the omission easy to miss.
      required: [currency, subtotal, discount, fees, shipping, tax, total]
      properties:
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: |
            ISO 4217 currency code, in upper case. It comes from the company's country
            and cannot be chosen per order.
          example: DKK
        subtotal:
          type: integer
          minimum: 0
          description: |
            Combined face value of the line items, before fees. It stays GROSS when a
            discount code applies: a partner needs the list price it sold at as well as
            the money it took.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 50000
        discount:
          type: integer
          minimum: 0
          description: |
            What a discount code took off the items. **A positive magnitude that is
            SUBTRACTED**, not a negative number: `total` = `subtotal` − `discount` +
            `fees` + `shipping`.

            Always present, and `0` both for an order that carried no code and for one
            written before the field existed. Fees are never discounted.

            A `Shipping` campaign is NOT counted here. It comes off `shipping` itself,
            exactly as the Lifepeaks order page reduces the postage line, so a
            shipping-only code leaves this at `0` while `shipping` and `total` both fall.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 0
        fees:
          type: integer
          minimum: 0
          description: |
            Order fee Lifepeaks adds, taken from the company's configuration and reported
            in advance as `fees.order_fee` in the catalog.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 500
        shipping:
          type: integer
          minimum: 0
          description: |
            What it costs to post the printed card, including the surcharge for each card
            after the first. Always present, and always `0` on an e-mail order.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 0
        tax:
          type: integer
          minimum: 0
          description: |
            The VAT on this order.

            It covers the **order fee** and the **shipping fee**. The face value of a
            gift card is not taxed at purchase: a card is exchanged for goods or
            services later, and the VAT on those belongs to that sale rather than this
            one. So an order with no order fee and no shipping carries `0`, and that is
            correct rather than missing.

            **It is already inside `total`. Do not add it on top.** `subtotal`, `fees`,
            `shipping` and `tax` describe how `total` was reached; `total` is what the
            buyer pays.

            The rates are the ones configured for the selling company, and the order fee
            and the shipping can carry different ones — which is why no single rate is
            published. A quote and the order it produces always carry the same figure.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 0
        total:
          type: integer
          minimum: 0
          description: |
            The payable amount — what the buyer is actually charged. Render this figure,
            never a total your own frontend computed.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 50500
      example:
        currency: DKK
        subtotal: 50000
        discount: 0
        fees: 500
        shipping: 0
        tax: 0
        total: 50500

    DiscountApplied:
      type: [object, "null"]
      description: |
        What caused the money in `amounts.discount`, or `null` when no code was used.
        Always present, so a storefront renders the row from one key rather than from its
        absence.

        Two keys rather than one because `amounts.discount` is the money and this is its
        cause; collapsing them would read `discount.discount`.
      required: [code, percent]
      properties:
        code:
          type: string
          description: |
            The code as the partner sent it, kept on the order rather than looked up, so
            renaming or deleting a finished campaign cannot rewrite what the order says.
          example: BLACKFRIDAY
        percent:
          type: integer
          description: The campaign's percentage, as it was applied.
          example: 20
      example:
        code: BLACKFRIDAY
        percent: 20

    SenderPartyRead:
      type: object
      description: |
        The buyer as an order reads back. It is the write shape echoed, with `company`
        always present so a reader never has to branch on a missing key.
      required: [name, email, company]
      properties:
        name:
          type: string
          description: The buyer's name, as printed on the gift card.
          example: Anna Jensen
        email:
          type: string
          format: email
          description: The buyer's email address.
          example: anna@example.com
        company:
          type: [object, "null"]
          description: |
            The buyer's own company, or `null` when a private person bought the order.
            `country` comes back as the alpha-2 code the order was placed with, reversed
            from the English name the column stores; a name the ISO table does not know
            reads as `null` rather than as a guess. The four address members are `null`
            together when the buyer sent no address.
          required: [name, vat_number, street, postcode, city, country]
          properties:
            name:
              type: [string, "null"]
              description: The company's name, as the order recorded it.
              example: Acme ApS
            vat_number:
              type: [string, "null"]
              description: Its VAT registration number, as the order recorded it.
              example: DK12345678
            street:
              type: [string, "null"]
              description: Street and house number, or `null` when no address was sent.
              example: Gothersgade 12
            postcode:
              type: [string, "null"]
              description: Postal code, or `null` when no address was sent.
              example: "1123"
            city:
              type: [string, "null"]
              description: City, or `null` when no address was sent.
              example: København K
            country:
              type: [string, "null"]
              description: ISO 3166-1 alpha-2 code.
              example: DK
      example:
        name: Anna Jensen
        email: anna@example.com
        company: null

    Product:
      type: object
      description: |
        The acting company's variable-value gift card, with the limits and fee that
        apply to that company. Read these values rather than hardcoding them; they are
        per-company configuration.

        This is one of the four families `GET /v2/products` returns. The others are
        `VariantProduct` for an experience, `EventProduct` for an event, and
        `SpecialOfferProduct` for a discounted offer; `type` tells them apart.
      required: [id, object, type, name, currency, company_required, pricing, quantity, delivery_methods, personalization, terms, fees, fee_basis, promotions, active]
      properties:
        id:
          type: string
          const: gift_card_value
          description: Stable product identifier. Send it as `product_id` on quotes and orders.
          example: gift_card_value
        object:
          const: product
          description: Always `product`.
        type:
          const: gift_card
          description: |
            Product family. Always `gift_card` on the value gift card; an experience
            carries `gift_card_variant`.
        name:
          type: string
          description: Display name, derived from the company name. Safe to show to buyers.
          example: Acme Hotels Gift Card
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: |
            ISO 4217 currency this product is sold in, derived from the company's
            country. Every amount below is a whole number in the smallest unit of this
            currency.
          example: DKK
        pricing:
          type: object
          description: How the buyer chooses an amount.
          required: [type, minimum_amount, maximum_amount]
          properties:
            type:
              const: variable
              description: |
                Always `variable` — the buyer picks any value inside the bounds below.
                There are no fixed price points.
            minimum_amount:
              type: integer
              description: |
                Smallest permitted face value. A lower `amount` is rejected with `422`.
                Whole number in the smallest unit of the currency (øre for DKK).
                50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding
                errors.
              example: 10000
            maximum_amount:
              type: integer
              description: |
                Largest permitted face value. A higher `amount` is rejected with `422`.
                Whole number in the smallest unit of the currency (øre for DKK).
                50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding
                errors.
              example: 500000
        company_required:
          type: boolean
          const: false
          description: |
            Whether the buyer has to BE a company. Always `false` on the value gift
            card: the flag is configuration on an experience or a special offer, never
            on the company's own value card. Published anyway so every product family
            answers the same question in the same field.
          example: false
        quantity:
          type: object
          description: How many cards one order may contain.
          required: [minimum, maximum]
          properties:
            minimum:
              type: integer
              description: Smallest permitted `quantity`. Always `1`.
              example: 1
            maximum:
              type: integer
              description: |
                Largest permitted `quantity` for this company. Use it to bound your
                quantity control rather than assuming a value.
              example: 10
        delivery_methods:
          $ref: '#/components/schemas/DeliveryMethods'
        personalization:
          $ref: '#/components/schemas/Personalization'
        terms:
          $ref: '#/components/schemas/Terms'
        fees:
          type: object
          description: Fees Lifepeaks adds on top of the face value.
          required: [order_fee]
          properties:
            order_fee:
              type: integer
              description: |
                Fee added once per order. It reappears as `fees` on the quote and the
                order. `0` when the company charges no order fee.
                Whole number in the smallest unit of the currency (øre for DKK).
                50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding
                errors.
              example: 500
        fee_basis:
          allOf:
            - $ref: '#/components/schemas/FeeBasis'
          description: |
            Always `per_line` for the value gift card: one order fee per line, whatever
            the quantity.
        promotions:
          type: array
          description: |
            The gift-card campaigns a buyer may pick right now, embedded so a storefront
            can render the product and its offers in one request. Only public campaigns
            inside their date window appear: a campaign left switched on after it ended
            is not published, and a secret campaign never is. Empty when the company
            runs none.
          items: { $ref: '#/components/schemas/EmbeddedPromotion' }
        active:
          type: boolean
          description: |
            Whether the product can currently be sold. Present only active products to
            buyers.
          example: true
      example:
        id: gift_card_value
        object: product
        type: gift_card
        name: Acme Hotels Gift Card
        currency: DKK
        pricing:
          type: variable
          minimum_amount: 10000
          maximum_amount: 500000
        quantity:
          minimum: 1
          maximum: 10
        delivery_methods:
          - id: sender_email
            label: Your E-mail
            available: true
            price: 0
            price_varies: false
            supports_send_at: false
          - id: recipient_email
            label: Receivers E-mail
            available: true
            price: 0
            price_varies: false
            supports_send_at: true
          - id: postal
            label: with GLS
            available: true
            price: 6000
            price_varies: true
            supports_send_at: false
            carrier: gls
            pickup_point_required: true
            extra_item_price: 1000
            phone_required: false
            destinations:
              - country: DK
                name: Denmark
                pickup_price: 6000
                private_address_price: null
                company_address_price: 7000
        personalization:
          greeting:
            available: true
            max_length: 2000
            image:
              accepted_mime: [image/jpeg, image/png, image/webp]
              max_bytes: 10485760
          designs:
            - id: gid_4711
              label: Image1
              url: https://cdn.lifepeaks.dk/dev/images/birthday.jpg
              thumbnail_url: https://cdn.lifepeaks.dk/dev/images/thumb/330x200/birthday.jpg
          amount_images:
            - https://cdn.lifepeaks.dk/dev/order_form_amount_images/thumb/330x200/strip-a.jpg
        terms:
          url: https://acme-hotels.example/terms
          html: null
        fees:
          order_fee: 500
        promotions:
          - id: 499
            name: Autumn discount
            kind: discount
            value: 20
            amount_from: 20000
            amount_to: 100000
        active: true

    VariantProduct:
      type: object
      description: |
        One experience the acting company sells — a spa day, a dinner, a course. Unlike
        the variable-value gift card it is not priced by a buyer-chosen amount: the
        money sits on the `options` array, and the buyer picks one.

        Only sellable experiences are published. An experience appears while it is
        active, public, of the orderable kind, inside its publication window, and
        holding at least one active option.
      required: [id, object, type, name, currency, categories, images, validity, company_required, pricing, fee_basis, options, delivery_methods, personalization, terms, active]
      properties:
        id:
          type: string
          pattern: '^gcv_[1-9][0-9]*$'
          description: |
            Stable product identifier, always `gcv_` followed by the experience's
            number. Read one back at `GET /v2/products/{product_id}`.
          example: gcv_4821
        object:
          const: product
          description: Always `product`.
        type:
          const: gift_card_variant
          description: Product family. Always `gift_card_variant` on an experience.
        name:
          type: string
          description: Display name of the experience, in the response language.
          example: Spa day for two
        description:
          type: string
          description: |
            The company's own description of the experience, as HTML, in the response
            language. Empty when none was entered.
          example: "<p>Two hours in the spa, robes included.</p>"
        formalities:
          type: string
          description: |
            The company's terms for redeeming the experience, as HTML — booking rules
            and weekday restrictions, typically. Empty when none were entered.
          example: "<p>Booking required. Valid Monday to Thursday.</p>"
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: |
            ISO 4217 currency this experience is sold in, derived from the company's
            country. Every amount on its options is a whole number in the smallest unit
            of this currency.
          example: DKK
        categories:
          type: array
          description: |
            The categories this experience belongs to. Pass one as the `category` filter
            on `GET /v2/products`. Empty when the experience is uncategorised.
          items:
            type: string
            pattern: '^cat_[1-9][0-9]*$'
          example: [cat_18, cat_22]
        images:
          type: array
          description: Artwork for the experience. Empty when none was uploaded.
          items: { $ref: '#/components/schemas/ProductImage' }
        validity:
          $ref: '#/components/schemas/ProductValidity'
        company_required:
          type: boolean
          description: |
            Whether the buyer has to name the company the experience will be redeemed
            at. Collect it in your own form when this is `true`.
          example: true
        pricing:
          type: object
          description: How the buyer chooses an amount.
          required: [type]
          properties:
            type:
              const: fixed
              description: |
                Always `fixed` — an experience is priced by the option the buyer picks,
                so no amount is ever sent for one.
        fee_basis:
          allOf:
            - $ref: '#/components/schemas/FeeBasis'
          description: |
            Always `per_line` for an experience: one order fee per line, whatever the
            quantity.
        options:
          type: array
          description: |
            The bookable variants of this experience, in the order the Lifepeaks order
            form shows them. Only active options are listed.
          items: { $ref: '#/components/schemas/ProductOption' }
        delivery_methods:
          $ref: '#/components/schemas/DeliveryMethods'
        personalization:
          $ref: '#/components/schemas/Personalization'
        terms:
          $ref: '#/components/schemas/Terms'
        active:
          type: boolean
          description: |
            Whether the experience can currently be sold. Only sellable experiences are
            published, so a listed one always reads `true`.
          example: true
      example:
        id: gcv_4821
        object: product
        type: gift_card_variant
        name: Spa day for two
        description: "<p>Two hours in the spa, robes included.</p>"
        formalities: "<p>Booking required. Valid Monday to Thursday.</p>"
        currency: DKK
        categories: [cat_18, cat_22]
        images:
          - url: https://assets.lifepeaks.dk/spa.webp
            role: product
        validity:
          type: months
          months: 24
          valid_from: null
          valid_until: null
        company_required: true
        pricing:
          type: fixed
        options:
          - id: gcvo_9107
            object: product_option
            name: Weekday
            description: "<p>Monday to Thursday.</p>"
            amount: 100050
            fees:
              order_fee: 2500
            quantity:
              minimum: 1
              maximum: 20
            availability:
              limited: true
              remaining: 7
              in_stock: true
            images:
              - url: https://assets.lifepeaks.dk/weekday.webp
                role: option
            active: true
        delivery_methods:
          - id: sender_email
            label: Your E-mail
            available: true
            price: 0
            price_varies: false
            supports_send_at: false
        personalization:
          greeting:
            available: true
            max_length: 2000
            image:
              accepted_mime: [image/jpeg, image/png, image/webp]
              max_bytes: 10485760
          designs: []
          amount_images: []
        terms:
          url: null
          html: "<p>Gift cards are valid for 36 months.</p>"
        active: true

    FeeBasis:
      type: string
      enum: [per_line, per_ticket]
      description: |
        How often this product's fee is charged, published so a basket can be totalled
        without knowing which family it holds.

        `per_line` — one fee for the whole line, whatever the quantity. Gift cards,
        experiences and special offers.

        `per_ticket` — one fee for every seat. Events only, so a line of two seats is
        charged its ticket fee twice.

        `POST /v2/quotes` applies the same rule, so the quote and the charge always
        agree. A basket totalled without reading this is short by a ticket fee on every
        seat after the first.
      example: per_line

    EventProduct:
      type: object
      description: |
        One event the acting company sells tickets to. Everything about the occasion sits
        in the `event` block; the price of a seat sits on the options.

        **The catalog lists exactly what it sells.** An event is published only while it
        is active, public, fully dated and inside its open window, and the quote and the
        order resolve it through the same read — so an event missing from this list
        answers `Product does not exist.` on quote and order alike. The Lifepeaks order
        form hides an undated event and still lets a direct link order it; this API does
        neither.

        An event cannot be posted: `delivery.method` of `postal` on such an order is
        rejected and `amounts.shipping` is always `0`.
      required: [id, object, type, name, currency, categories, images, event, validity, fee_basis, pricing, options, delivery_methods, personalization, terms, active]
      properties:
        id:
          type: string
          pattern: '^evt_[1-9][0-9]*$'
          description: |
            Stable product identifier, always `evt_` followed by the event's number.
          example: evt_412
        object:
          const: product
          description: Always `product`.
        type:
          const: event_ticket
          description: Product family. Always `event_ticket` on an event.
        name:
          type: string
          description: Display name of the event, in the response language.
          example: New Year's dinner
        description:
          type: string
          description: |
            The company's own description, as HTML, in the response language. Any extra
            content the organiser configured is appended to it.
          example: "<p>Six courses and a view.</p>"
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: ISO 4217 currency this event is sold in.
          example: DKK
        categories:
          type: array
          description: Category ids this event is filed under, as `cat_<id>`.
          items:
            type: string
            pattern: '^cat_[1-9][0-9]*$'
          example: [cat_18]
        images:
          type: array
          description: Artwork for the event. Empty when none was uploaded.
          items: { $ref: '#/components/schemas/ProductImage' }
        event:
          $ref: '#/components/schemas/EventDetails'
        validity:
          allOf:
            - $ref: '#/components/schemas/ProductValidity'
          description: |
            Always `fixed_date` on an event, repeating the occasion's own start and end,
            so a ticket is valid for the occasion and nothing else.
        fee_basis:
          allOf:
            - $ref: '#/components/schemas/FeeBasis'
          description: |
            Always `per_ticket` on an event. This is the one family that disagrees with
            the rest about money, and the disagreement is invisible at quantity 1.
        pricing:
          type: object
          description: How the buyer chooses an amount.
          required: [type]
          properties:
            type:
              const: fixed
              description: |
                Always `fixed` — a ticket is priced by the option the buyer picks, so no
                amount is ever sent for one.
        options:
          type: array
          description: |
            The ticket types of this event, in the order the Lifepeaks order form shows
            them. Only active types are listed.
          items: { $ref: '#/components/schemas/EventTicketOption' }
        delivery_methods:
          $ref: '#/components/schemas/DeliveryMethods'
        personalization:
          $ref: '#/components/schemas/Personalization'
        terms:
          $ref: '#/components/schemas/Terms'
        active:
          type: boolean
          description: |
            Whether the event can currently be sold. Only sellable events are published,
            so a listed one always reads `true`.
          example: true

    EventDetails:
      type: object
      description: The occasion itself — when, where, and what the organiser asks for.
      required: [starts_at, ends_at, date_precision, tagline, venue, grouped_tickets, comment, requires, capacity_mode, tickets_per_event, add_ons_sold_alone, collection_points]
      properties:
        starts_at:
          type: [string, "null"]
          format: date-time
          description: When the event starts, as an RFC 3339 timestamp.
          example: '2026-12-31T18:00:00+01:00'
        ends_at:
          type: [string, "null"]
          format: date-time
          description: |
            When it ends, or `null`. It is `null` on the two single-moment precisions,
            where the organiser set no end.
          example: '2027-01-01T02:00:00+01:00'
        date_precision:
          type: string
          enum: [date, date_time, date_time_range, date_range]
          description: |
            How much of the date the organiser actually set: `date` for a single day,
            `date_time` for a day and a start time, `date_time_range` for a day with a
            start and an end time, `date_range` for a span of days. Render a `date` event
            as a day rather than as midnight.
          example: date_time_range
        tagline:
          type: [string, "null"]
          description: A short line the organiser configured, or `null`.
          example: Six courses and a view
        venue:
          type: object
          description: Where the event is held. Each member is `null` when unset.
          required: [name, address, postcode]
          properties:
            name:
              type: [string, "null"]
              description: Name of the venue, or `null` when the organiser set none.
              example: Hotel Nord
            address:
              type: [string, "null"]
              description: Street address of the venue, or `null`.
              example: Havnegade 4
            postcode:
              type: [string, "null"]
              description: Postal code of the venue, or `null`.
              example: "1058"
        grouped_tickets:
          type: boolean
          description: |
            Whether a line is issued as ONE ticket carrying every seat rather than one
            ticket per seat. It is what the order line reports back as `issued_count`.
          example: false
        comment:
          type: object
          description: Whether the organiser accepts a note from the buyer.
          required: [available, label, max_length]
          properties:
            available:
              type: boolean
              description: |
                Whether a note is accepted. Sending `items[].ticket.comment` when this is
                `false` is rejected.
              example: true
            label:
              type: [string, "null"]
              description: The question to put to the buyer, or `null`.
              example: Allergies?
            max_length:
              type: integer
              description: Longest note the order path accepts.
              example: 500
        requires:
          type: object
          description: What the event demands of the buyer.
          required: [phone, company]
          properties:
            phone:
              type: boolean
              description: Whether a phone number is required.
              example: false
            company:
              type: boolean
              description: |
                Whether the buyer must BE a company. When `true`, an order without
                `sender.company` — or with one lacking its address — is rejected with
                `sender_company_required`.
              example: false
        capacity_mode:
          type: string
          enum: [per_ticket, per_order]
          description: |
            What the event counts. `per_ticket` counts seats. `per_order` counts ORDERS:
            it takes one slot however many seats were bought, and caps the seats of that
            event across every line of the order at `tickets_per_event`. The Lifepeaks
            order form checks each line on its own, which lets two lines of one event
            pass together and exceed the cap; this API sums them.
          example: per_ticket
        tickets_per_event:
          type: [integer, "null"]
          description: |
            The order-wide seat cap of a `per_order` event, or `null` — always `null` on
            a `per_ticket` event.
          example: null
        add_ons_sold_alone:
          type: boolean
          description: |
            Whether an `add_on` option may be bought without a normal ticket of the same
            event. When `false`, an order of add-ons alone is rejected.
          example: false
        collection_points:
          type: array
          description: |
            Where a ticket is handed over at the event. **Not the courier collection
            points** `GET /v2/pickup-points` answers with, which are for posting a gift
            card.
          items: { $ref: '#/components/schemas/EventCollectionPoint' }

    EventCollectionPoint:
      type: object
      description: One place at an event where a ticket is collected.
      required: [id, object, name, address, city]
      properties:
        id:
          type: string
          pattern: '^epp_[1-9][0-9]*$'
          description: |
            The id an event line names as `items[].ticket.collection_point_id`.
          example: epp_31
        object:
          const: collection_point
          description: Always `collection_point`.
        name:
          type: string
          description: What the organiser calls this collection point.
          example: Main entrance
        address:
          type: string
          description: Where at the venue the ticket is handed over.
          example: Havnegade 4
        city:
          type: string
          description: Postcode and town, as the organiser entered them.
          example: 1058 København K

    EventTicketOption:
      type: object
      description: |
        One ticket type of an event — the thing that carries the price of a seat.
      required: [id, object, name, amount, fees, quantity, availability, requires, add_on, images, active]
      properties:
        id:
          type: string
          pattern: '^evto_[1-9][0-9]*$'
          description: Stable option identifier, always `evto_` followed by its number.
          example: evto_9310
        object:
          const: product_option
          description: Always `product_option`.
        name:
          type: string
          description: Display name of the ticket type, in the response language.
          example: Dinner seat
        description:
          type: string
          description: The organiser's own description of this ticket type, as HTML.
          example: "<p>Six courses.</p>"
        amount:
          type: integer
          description: |
            Price of one seat. Whole number in the smallest unit of the currency (øre for
            DKK). 50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding
            errors.
          example: 145000
        fees:
          type: object
          description: What Lifepeaks adds on top of the seat price.
          required: [ticket_fee]
          properties:
            ticket_fee:
              type: integer
              description: |
                Fee added **per seat**, not per line — see the product's `fee_basis`. It
                is `0` on an add-on option and on an event whose shop absorbs the fee, and
                in both cases the buyer really pays nothing for it. This is the figure the
                quote charges and the figure the order stores.
                Whole number in the smallest unit of the currency (øre for DKK).
                50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding
                errors.
              example: 2500
        quantity:
          type: object
          description: How many seats of this type one line may buy.
          required: [minimum, maximum]
          properties:
            minimum:
              type: integer
              description: Fewest seats of this type one line may buy.
              example: 1
            maximum:
              type: integer
              description: |
                The number the quote and the order enforce, not a display hint. It is
                already lowered to the seats that remain.
              example: 6
        availability:
          type: object
          description: |
            Whether this ticket type can be bought right now. A sold-out type stays in the
            list with `in_stock` of `false` rather than disappearing, so a storefront can
            grey it out.
          required: [limited, remaining, in_stock]
          properties:
            limited:
              type: boolean
              description: |
                Always `true` for a seat — a seat sold is a seat gone.
              example: true
            remaining:
              type: [integer, "null"]
              description: |
                Seats left, or `null` when the event is configured not to publish its
                count. `limited` still says the stock is finite.
              example: 42
            in_stock:
              type: boolean
              description: Whether any seats remain. `false` on a sold-out ticket type.
              example: true
        requires:
          type: object
          description: |
            What this ticket type demands on the order line, in `items[].ticket`.
          required: [code, address, collection_point]
          properties:
            code:
              type: boolean
              description: Whether `items[].ticket.code` is required.
              example: false
            address:
              type: boolean
              description: |
                Whether `items[].ticket.address` is required, unless a collection point
                is given instead.
              example: false
            collection_point:
              type: boolean
              description: Whether `items[].ticket.collection_point_id` is required.
              example: true
        add_on:
          type: boolean
          description: |
            Whether this type cannot be bought on its own — a drinks package beside a
            seat. An order of add-ons alone is rejected unless the event's
            `add_ons_sold_alone` is `true`.
          example: false
        images:
          type: array
          description: Artwork for this ticket type. Empty when none was uploaded.
          items: { $ref: '#/components/schemas/ProductImage' }
        active:
          type: boolean
          description: |
            Whether the ticket type can currently be sold. Only active types are listed,
            so a listed one always reads `true`.
          example: true

    SpecialOfferProduct:
      type: object
      description: |
        One discounted offer the acting company sells as a voucher. It is priced by the
        option the buyer picks, and each option publishes what it costs now, what it cost
        before, and what the issued voucher is worth when it is spent.

        A special offer cannot be posted: `delivery.method` of `postal` on such an order
        is rejected and `amounts.shipping` is always `0`.

        **Benefit deals are not published here.** A benefit deal shares this family's
        storage but is issued to a company rather than bought, so it has no catalog,
        quote or order surface. It is read at `GET /v2/items/{code}`, printed at
        `GET /v2/items/{code}/document.pdf` and redeemed at
        `POST /v2/items/{code}/claim`.
      required: [id, object, type, name, currency, images, validity, company_required, available_until, fee_basis, pricing, options, delivery_methods, personalization, terms, active]
      properties:
        id:
          type: string
          pattern: '^so_[1-9][0-9]*$'
          description: |
            Stable product identifier, always `so_` followed by the offer's number.
          example: so_884
        object:
          const: product
          description: Always `product`.
        type:
          const: special_offer
          description: Product family. Always `special_offer` on an offer.
        name:
          type: string
          description: Display name of the offer, in the response language.
          example: Two nights, half price
        description:
          type: string
          description: The company's own description of the offer, as HTML.
          example: "<p>Friday and Saturday.</p>"
        formalities:
          type: string
          description: The company's terms for redeeming the voucher, as HTML.
          example: "<p>Subject to availability.</p>"
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: ISO 4217 currency this offer is sold in.
          example: DKK
        images:
          type: array
          description: Artwork for the offer. Empty when none was uploaded.
          items: { $ref: '#/components/schemas/ProductImage' }
        validity:
          allOf:
            - $ref: '#/components/schemas/ProductValidity'
          description: |
            How long the issued voucher lasts. An offer that fixes only a start date
            reports `valid_until` of `null` rather than inventing an end.
        company_required:
          type: boolean
          description: |
            Whether the buyer must BE a company. When `true`, an order without
            `sender.company` — or with one lacking its address — is rejected with
            `sender_company_required`.
          example: false
        available_until:
          type: [string, "null"]
          format: date-time
          description: |
            The moment the offer stops being orderable, or `null` when it never does. The
            Lifepeaks order form prints it as "Offer is online until"; a storefront needs
            the same date to say the same thing.
          example: '2026-11-30T23:59:59+01:00'
        fee_basis:
          allOf:
            - $ref: '#/components/schemas/FeeBasis'
          description: |
            Always `per_line` for a special offer: one order fee per line, whatever the
            quantity.
        pricing:
          type: object
          description: How the buyer chooses an amount.
          required: [type]
          properties:
            type:
              const: fixed
              description: |
                Always `fixed` — an offer is priced by the option the buyer picks, so no
                amount is ever sent for one.
        options:
          type: array
          description: |
            The vouchers of this offer, in the order the Lifepeaks order form shows them.
            Only active options are listed.
          items: { $ref: '#/components/schemas/SpecialOfferOption' }
        delivery_methods:
          $ref: '#/components/schemas/DeliveryMethods'
        personalization:
          $ref: '#/components/schemas/Personalization'
        terms:
          $ref: '#/components/schemas/Terms'
        active:
          type: boolean
          description: |
            Whether the offer can currently be sold. Only sellable offers are published,
            so a listed one always reads `true`.
          example: true

    SpecialOfferOption:
      type: object
      description: |
        One voucher of a special offer — the thing that carries the price.
      required: [id, object, name, amount, regular_amount, discount_percent, redemption_value, fees, quantity, availability, active]
      properties:
        id:
          type: string
          pattern: '^sov_[1-9][0-9]*$'
          description: Stable option identifier, always `sov_` followed by its number.
          example: sov_5501
        object:
          const: product_option
          description: Always `product_option`.
        name:
          type: string
          description: Display name of the voucher, in the response language.
          example: Double room
        description:
          type: string
          description: The company's own description of this voucher, as HTML.
          example: "<p>Two nights for two.</p>"
        amount:
          type: integer
          description: |
            What the buyer pays for one voucher. Whole number in the smallest unit of the
            currency (øre for DKK). 50000 = DKK 500.00. Whole numbers of the smallest
            unit avoid rounding errors.
          example: 120000
        regular_amount:
          type: [integer, "null"]
          description: |
            What the voucher cost before the offer, for a storefront to strike through
            beside `amount`, or `null` when the operator recorded none.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 240000
        discount_percent:
          type: [integer, "null"]
          description: |
            The saving the operator advertised, or `null`. It is presentation, not a
            money field: `amount` is what is charged.
          example: 50
        redemption_value:
          type: integer
          description: |
            **What the issued voucher is WORTH when it is spent**, which is not always
            what the buyer paid. On most offers it equals `amount`; on an offer
            configured to redeem at the regular price it equals `regular_amount`, so the
            buyer pays DKK 1200,00 and holds a voucher worth DKK 2400,00.

            Show this figure wherever you tell a buyer what they are getting, or your
            receipt will disagree with the operator's redeem screen.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 240000
        fees:
          type: object
          description: Fees Lifepeaks adds on top of the voucher price.
          required: [order_fee]
          properties:
            order_fee:
              type: integer
              description: |
                Fee added once per line, whatever the quantity — see the product's
                `fee_basis`.
                Whole number in the smallest unit of the currency (øre for DKK).
                50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding
                errors.
              example: 2500
        quantity:
          type: object
          description: How many vouchers of this option one line may buy.
          required: [minimum, maximum]
          properties:
            minimum:
              type: integer
              description: Fewest vouchers of this option one line may buy.
              example: 1
            maximum:
              type: integer
              description: |
                The number the quote and the order enforce, not a display hint. A
                quantity outside these bounds is rejected under `below_minimum` or
                `above_maximum`.
              example: 4
        availability:
          type: object
          description: |
            Whether this voucher can be bought right now. A sold-out option stays in the
            list with `in_stock` of `false` rather than being hidden.
          required: [limited, remaining, in_stock]
          properties:
            limited:
              type: boolean
              description: Always `true` for this family — an offer sold is an offer gone.
              example: true
            remaining:
              type: [integer, "null"]
              description: |
                Vouchers left, or `null` when the option is configured not to publish its
                count. Losing the last one to another buyer between the quote and the
                order answers `409` with `insufficient_stock`.
              example: 12
            in_stock:
              type: boolean
              description: Whether any vouchers remain. `false` on a sold-out option.
              example: true
        active:
          type: boolean
          description: |
            Whether the voucher can currently be sold. Only active options are listed, so
            a listed one always reads `true`.
          example: true

    ProductOption:
      type: object
      description: |
        One bookable variant of an experience — the thing that carries the price. Its
        `id` is what identifies an experience selection.
      required: [id, object, name, amount, fees, quantity, availability, images, active]
      properties:
        id:
          type: string
          pattern: '^gcvo_[1-9][0-9]*$'
          description: |
            Stable option identifier, always `gcvo_` followed by the option's number.
          example: gcvo_9107
        object:
          const: product_option
          description: Always `product_option`.
        name:
          type: string
          description: Display name of the option, in the response language.
          example: Weekday
        description:
          type: string
          description: |
            The company's own description of this option, as HTML. Empty when none was
            entered.
          example: "<p>Monday to Thursday.</p>"
        amount:
          type: integer
          description: |
            Price of one piece of this option. Whole number in the smallest unit of the
            currency (øre for DKK). 50000 = DKK 500.00. Whole numbers of the smallest
            unit avoid rounding errors.
          example: 100050
        fees:
          type: object
          description: Fees Lifepeaks adds on top of the option price.
          required: [order_fee]
          properties:
            order_fee:
              type: integer
              description: |
                Fee added once per line, so every option repeats the same company-wide
                value rather than sharing one. Whole number in the smallest unit of the
                currency (øre for DKK). 50000 = DKK 500.00. Whole numbers of the
                smallest unit avoid rounding errors.
              example: 2500
        quantity:
          type: object
          description: How many pieces of this option one line may buy.
          required: [minimum, maximum]
          properties:
            minimum:
              type: integer
              description: Smallest permitted quantity for this option.
              example: 1
            maximum:
              type: integer
              description: |
                Largest permitted quantity for one line of this option. This is the
                number `POST /v2/quotes` and `POST /v2/orders` enforce, not a display
                hint, so a line the catalog allows is a line Lifepeaks accepts. An
                option that configures no maximum of its own is published and sold at
                `100`. Limited stock lowers it only on an experience that publishes its
                remaining count; hidden stock leaves the configured maximum in place and
                the real stock is checked when the order is written. Never above `1000`,
                the Lifepeaks order page's own per-line ceiling.
              example: 20
        availability:
          type: object
          description: |
            Whether this option can be bought right now. A sold-out option stays in the
            list and says so, exactly as the Lifepeaks order form shows it, so a
            storefront can grey it out instead of quietly changing its price range.
          required: [limited, remaining, in_stock]
          properties:
            limited:
              type: boolean
              description: Whether the stock of this option is finite.
              example: true
            remaining:
              type: [integer, "null"]
              description: |
                Pieces left. `null` when stock is unlimited, and also when the
                experience is configured not to publish its count — `limited` still says
                the stock is finite.
              example: 7
            in_stock:
              type: boolean
              description: |
                Whether pieces remain. `false` only on a limited option with nothing
                left.
              example: true
        images:
          type: array
          description: Artwork for this option. Empty when none was uploaded.
          items: { $ref: '#/components/schemas/ProductImage' }
        active:
          type: boolean
          description: |
            Whether the option can currently be sold. Only active options are listed, so
            a listed one always reads `true`.
          example: true

    ProductImage:
      type: object
      description: One image published with a product or one of its options.
      required: [url, role]
      properties:
        url:
          type: string
          format: uri
          description: Public URL of the image, safe to render in a storefront.
          example: https://assets.lifepeaks.dk/spa.webp
        role:
          type: string
          enum: [product, option]
          description: |
            Which resource the image belongs to: `product` for the experience itself,
            `option` for one of its options.
          example: product

    ProductValidity:
      type: object
      description: |
        How long a card issued for this experience stays valid — either a number of
        months counted from issue, or one fixed calendar window shared by every buyer.
      required: [type, months, valid_from, valid_until]
      properties:
        type:
          type: string
          enum: [months, fixed_date]
          description: |
            `months` counts from the day the card is issued. `fixed_date` expires every
            card on the same date, whenever it was bought.
          example: months
        months:
          type: [integer, "null"]
          description: |
            Months of validity from issue. `null` on a `fixed_date` experience, and when
            the company left the period unset.
          example: 24
        valid_from:
          type: [string, "null"]
          format: date-time
          description: |
            Start of the fixed validity window, as an ISO 8601 timestamp. `null` on a
            `months` experience.
          example: null
        valid_until:
          type: [string, "null"]
          format: date-time
          description: |
            End of the fixed validity window, as an ISO 8601 timestamp. `null` on a
            `months` experience.
          example: null

    EmbeddedPromotion:
      type: object
      description: |
        One open gift-card campaign, embedded on the value product so a storefront can
        render the product and its offers in one request.

        `GET /v2/promotions` lists the same campaigns standalone, in a different field
        shape: it spells the kind as `modification` with `reduced_price` / `added_value`
        percentages, and its `amount_from` / `amount_to` are decimals in the currency.
        Read both before using the two surfaces together.
      required: [id, name, kind, value, amount_from, amount_to]
      properties:
        id:
          type: integer
          description: |
            Campaign id. It is the same value `GET /v2/promotions` publishes and
            `POST /v2/items` takes as `promotion_id`, so a campaign read off the product
            can be ordered with unchanged.
          example: 499
        name:
          type: string
          description: The campaign's internal name, as configured in the admin UI.
          example: Autumn discount
        kind:
          type: string
          enum: [discount, added_value]
          description: |
            `discount` reduces what the buyer pays; `added_value` increases what the
            card is worth.
          example: discount
        value:
          type: integer
          description: |
            The campaign's percentage. On a `discount`, `20` means a DKK 100 card costs
            DKK 80. On `added_value`, `15` means DKK 100 buys a card worth DKK 115.
          example: 20
        amount_from:
          type: [integer, "null"]
          description: |
            Lowest card value the campaign accepts, or `null` when it has no lower
            bound. Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding
            errors. The same field on `GET /v2/promotions` is a decimal.
          example: 20000
        amount_to:
          type: [integer, "null"]
          description: |
            Highest card value the campaign accepts, or `null` when it has no upper
            bound. Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding
            errors. The same field on `GET /v2/promotions` is a decimal.
          example: 100000

    QuoteLineItem:
      type: object
      additionalProperties: false
      description: |
        One priced line of a quote. A value line echoes the amount you asked for; an
        experience line carries the price its option is configured with, plus the
        `product_option_id` that produced it.
      required: [product_id, amount, quantity, fees, subtotal, discount]
      properties:
        product_id:
          type: string
          description: |
            The product this line prices — `gift_card_value`, or the `gcv_<id>`
            experience, `evt_<id>` event or `so_<id>` offer the option belongs to.
          example: gcv_4821
        product_option_id:
          type: string
          pattern: '^(gcvo|evto|sov)_[0-9]+$'
          description: |
            The option this line prices. Present on every line but a value one, which
            omits the key entirely.
          example: gcvo_9107
        amount:
          type: integer
          minimum: 1
          description: |
            Price of one card — echoing what was requested on a value line, and the
            option's configured price on an experience line.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 100050
        quantity:
          type: integer
          minimum: 1
          description: Number of cards on this line.
          example: 2
        fees:
          type: integer
          minimum: 0
          description: |
            The fee this line carries. How often it is charged depends on the family, and
            the product's `fee_basis` says which: `per_line` for gift cards, experiences
            and special offers, so a two-line order pays it twice whatever the
            quantities; `per_ticket` for events, so one line of two seats pays it twice
            on its own.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 2500
        subtotal:
          type: integer
          minimum: 1
          description: |
            Line total before fees — `amount` multiplied by `quantity`. It stays GROSS
            when a discount code applies.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 200100
        discount:
          type: integer
          minimum: 0
          description: |
            This line's share of `amounts.discount`, as a positive magnitude that is
            subtracted from `subtotal`. Always present, and `0` on every line of an order
            that carried no code and on every line of an order whose code took its money
            off postage alone.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 0

    OrderLineItem:
      type: object
      description: |
        One product line of an order — the same shape a quote line has, plus the line's
        own id, which is what a per-line gift-card PDF is addressed by.
      required: [id, object, product_id, product_option_id, amount, quantity, fees, subtotal, discount, issued_count]
      properties:
        id:
          type: string
          pattern: '^poli_[a-f0-9]{32}$'
          description: |
            Line id — `poli_` followed by 32 hexadecimal characters. Stable for the life
            of the order, and the id the per-line PDF route takes.
          example: poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d
        object:
          const: line_item
          description: Always `line_item`.
        product_id:
          type: string
          description: |
            The product this line bought — `gift_card_value`, or the `gcv_<id>`
            experience, `evt_<id>` event or `so_<id>` offer the option belongs to.
          example: gcv_4821
        product_option_id:
          type: [string, "null"]
          description: |
            The option this line bought, or `null` on a value line. Unlike a quote line,
            an order line always carries the key, so every family reads alike.
          example: gcvo_9107
        amount:
          type: integer
          minimum: 1
          description: |
            Price of one card on this line.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 100050
        quantity:
          type: integer
          minimum: 1
          description: Number of cards bought on this line.
          example: 2
        fees:
          type: integer
          minimum: 0
          description: |
            The fee this line carries. Charged once per line for a gift card, an
            experience or a special offer, and once per seat for an event ticket — the
            product's `fee_basis` says which.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 2500
        subtotal:
          type: integer
          minimum: 1
          description: |
            Line total before fees — `amount` multiplied by `quantity`. It stays GROSS
            when a discount code applies.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 200100
        discount:
          type: integer
          minimum: 0
          description: |
            This line's share of `amounts.discount`, as a positive magnitude subtracted
            from `subtotal`. Always present, and `0` on every line of an order that
            carried no code.
            Whole number in the smallest unit of the currency (øre for DKK).
            50000 = DKK 500.00. Whole numbers of the smallest unit avoid rounding errors.
          example: 0
        issued_count:
          type: integer
          minimum: 0
          description: |
            How many durable items this line actually issued, which is **not always its
            `quantity`**. A gift-card line issues one card per unit. An event whose
            `grouped_tickets` is `true` issues ONE ticket carrying every seat, so a
            four-seat line reports `1`.

            Use it to know how many codes to expect. The codes themselves are not listed
            here: read them from `GET /v2/items?order_id=<reference>`, and each one's own
            document from `GET /v2/items/{code}/document.pdf`.
          example: 2
        pdf:
          type: object
          description: |
            Where to download this line's document, whatever family it sold. **Present
            only once `fulfillment.status` is `fulfilled`**, so test for the key rather
            than for an empty value.

            This and `gift_card_pdf` are the same link under two names. Read `pdf` in new
            code: it is on every fulfilled line, while `gift_card_pdf` is on gift-card and
            experience lines only.

            One line is one document: a line bought in quantity downloads all of its
            items together, so there is never a link per item.
          required: [url, mime]
          properties:
            url:
              type: string
              description: |
                Path to fetch this line's document from, relative to the API base URL.
                It is an authenticated Lifepeaks endpoint, not a public or storage link,
                so request it with your API key from your server.
              example: /v2/orders/po_3f81a04d7c2e496b8a05f1c73de29b64/lines/poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d/gift-card.pdf
            mime:
              type: string
              description: Content type the download returns. Always `application/pdf`.
              const: application/pdf
              example: application/pdf
        gift_card_pdf:
          type: object
          description: |
            The same link as `pdf`, kept under the name integrations already read.
            **Present only once `fulfillment.status` is `fulfilled`, and only on a
            gift-card or experience line** — it is deliberately absent from event and
            special-offer lines, where the name would be a lie.
          required: [url, mime]
          properties:
            url:
              type: string
              description: |
                Path to fetch this line's document from, relative to the API base URL.
              example: /v2/orders/po_3f81a04d7c2e496b8a05f1c73de29b64/lines/poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d/gift-card.pdf
            mime:
              type: string
              description: Content type the download returns. Always `application/pdf`.
              const: application/pdf
              example: application/pdf
      example:
        id: poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d
        object: line_item
        product_id: gcv_4821
        product_option_id: gcvo_9107
        amount: 100050
        quantity: 2
        fees: 2500
        subtotal: 200100
        discount: 0
        issued_count: 2

    Quote:
      type: object
      description: |
        Lifepeaks' authoritative price for a selection. It reserves nothing and expires;
        re-quote when `expires_at` passes or the selection changes.
      required: [id, object, line_items, discount, amounts, expires_at]
      properties:
        id:
          type: string
          pattern: '^quo_[a-f0-9]{32}$'
          description: |
            Quote id — `quo_` followed by 32 hexadecimal characters. Useful for logging
            and support; order creation does not take it as an input.
          example: quo_5b29e740a1c34df8062be95c71d84f30
        object:
          const: quote
          description: Always `quote`.
        line_items:
          type: array
          description: |
            The priced lines, in the order the request listed them, each with its own
            fee and subtotal added.
          items: { $ref: '#/components/schemas/QuoteLineItem' }
        discount:
          allOf:
            - $ref: '#/components/schemas/DiscountApplied'
          description: |
            The code that produced `amounts.discount`, or `null` when the quote carried
            none. Always present.
        amounts:
          allOf:
            - $ref: '#/components/schemas/Amounts'
          description: The money breakdown to display. Render these figures, not your own.
        expires_at:
          type: string
          format: date-time
          description: |
            ISO 8601 moment after which this quote is stale. Refresh it before sending
            the buyer to checkout if it has passed.
          example: "2026-08-27T10:45:00+00:00"
      example:
        id: quo_5b29e740a1c34df8062be95c71d84f30
        object: quote
        line_items:
          - product_id: gcv_4821
            product_option_id: gcvo_9107
            amount: 100050
            quantity: 2
            fees: 2500
            subtotal: 200100
            discount: 0
          - product_id: gcv_4821
            product_option_id: gcvo_9108
            amount: 149595
            quantity: 1
            fees: 2500
            subtotal: 149595
            discount: 0
        discount: null
        amounts:
          currency: DKK
          subtotal: 349695
          discount: 0
          fees: 5000
          shipping: 0
          tax: 0
          total: 354695
        expires_at: "2026-08-27T10:45:00+00:00"

    Order:
      type: object
      description: |
        The canonical state of one headless order. This is the only trustworthy evidence
        of payment and fulfilment — never infer either from a browser redirect.
      required: [id, object, status, payment, fulfillment, amounts, created_at, updated_at]
      properties:
        id:
          type: string
          pattern: '^po_[a-f0-9]{32}$'
          description: |
            Order id — `po_` followed by 32 hexadecimal characters. Store it against your
            own checkout reference; every later call about this order needs it.
          example: po_3f81a04d7c2e496b8a05f1c73de29b64
        object:
          const: order
          description: Always `order`.
        reference:
          type: string
          description: |
            The short order number, for people. Print it on a thank-you page and quote
            it to support: it is the number Lifepeaks operators search orders by. `id`
            stays the only thing an API call addresses the order by.
          example: A7K2M9XQ4B
        client_reference:
          type: [string, "null"]
          description: |
            The reference you supplied at creation, or `null` if you supplied none.
            Echoed unchanged so you can reconcile against your own records.
          example: cart_018f4f37b949
        status:
          description: |
            The order's lifecycle state.
            `pending_payment` — created, not yet paid; keep polling.
            `paid` — payment confirmed, fulfilment may still be running.
            `fulfilled` — the gift card has been issued and delivered.
            `cancelled` — the order was cancelled and nothing was issued.
            `expired` — the order was never paid within its window.
          enum: [pending_payment, paid, fulfilled, cancelled, expired]
          example: paid
        payment:
          type: object
          description: Payment state, as reported by the payment provider.
          required: [status]
          properties:
            status:
              description: |
                `pending` — no result yet.
                `authorized` — funds reserved; payment is confirmed.
                `captured` — funds taken.
                `failed` — the payment did not succeed; offer a safe retry.
                `cancelled` — the buyer abandoned or the payment was voided.
                `refunded` / `partially_refunded` — money has been returned.
                Treat `authorized` and `captured` as paid.
              enum: [pending, authorized, captured, failed, cancelled, refunded, partially_refunded]
              example: authorized
        fulfillment:
          type: object
          description: Whether the gift card has been produced and delivered.
          required: [status]
          properties:
            status:
              description: |
                `not_ready` — fulfilment has not begun, normally because payment is not
                confirmed.
                `processing` — under way.
                `fulfilled` — the card has been issued and emailed; only now show the
                order as complete.
                `failed` — fulfilment could not complete. The order stays paid; escalate
                rather than asking the buyer to pay again.
              enum: [not_ready, processing, fulfilled, failed]
              example: fulfilled
        amounts:
          allOf:
            - $ref: '#/components/schemas/Amounts'
          description: |
            What Lifepeaks charged. Recalculated at creation, so compare it with the
            quote you displayed before redirecting the buyer.
        line_items:
          type: array
          description: |
            What the order bought, one entry per product line, in the order the request
            listed them. A value order has exactly one line whose `product_option_id` is
            `null`; every other family has one to twenty. Each line says how many items
            it issued in `issued_count`, and once the order is fulfilled each also
            carries its own `pdf`.
          items: { $ref: '#/components/schemas/OrderLineItem' }
        discount:
          allOf:
            - $ref: '#/components/schemas/DiscountApplied'
          description: |
            The code that produced `amounts.discount`, or `null` when the order carried
            none. Always present.
        gift_card_pdf:
          type: object
          description: |
            Where to download the gift-card document. **Present only once
            `fulfillment.status` is `fulfilled`, and only on a single-line order** — an
            unpaid or still-processing order carries no such key at all, and neither does
            an order with several lines, whose cards are downloaded per line from
            `line_items[].gift_card_pdf`. Test for the key rather than for an empty
            value. Use it to attach the card to your own confirmation email or to offer a
            download from your account area.
          required: [url, mime]
          properties:
            url:
              type: string
              description: |
                Path to fetch the document from, relative to the API base URL. It is an
                authenticated Lifepeaks endpoint, not a public or storage link, so
                request it with your API key from your server and never hand it to a
                browser.
              example: /v2/orders/po_3f81a04d7c2e496b8a05f1c73de29b64/gift-card.pdf
            mime:
              type: string
              description: Content type the download returns. Always `application/pdf`.
              const: application/pdf
              example: application/pdf
        sender:
          allOf:
            - $ref: '#/components/schemas/SenderPartyRead'
          description: |
            The buyer, as the order was created with. Read off the card itself, so it is
            what was actually printed. It comes back wherever the order object reaches an
            AUTHENTICATED caller — the create response, `GET /v2/orders/{order_id}`,
            `GET /v2/orders`, and the order embedded in a checkout session. A webhook
            payload names nobody, because it is delivered to a URL you configured rather
            than to an authenticated caller. Its `company` is `null` when a private
            person bought the order.
        recipient:
          allOf:
            - $ref: '#/components/schemas/Party'
          description: |
            Who the card was sent to, as the order was created with. It comes back on the
            same four authenticated surfaces as `sender`, and is absent from every webhook
            payload.
        greeting:
          type: object
          description: |
            What the buyer chose for the greeting page, including when they chose nothing.

            It appears wherever the order object reaches an AUTHENTICATED caller — the
            create response, `GET /v2/orders/{order_id}`, `GET /v2/orders`, and the order
            embedded in a checkout session. It is absent from every webhook payload,
            which carries no buyer detail at all. That boundary is the one that protects
            the buyer, and both sides of it are pinned by tests.
          required: [message, image]
          properties:
            message:
              type: [string, "null"]
              description: |
                The greeting the buyer wrote, or `null` when the order carries none.

                The caller is the company whose own storefront collected the note, and
                the same response already echoes both parties' e-mail addresses, so the
                text is not a new category of buyer detail on this surface. It is still
                absent from webhook payloads.
              example: Tillykke med dagen!
            image:
              type: object
              description: The picture on the greeting page, in the shape it was ordered with.
              required: [source, design_id]
              properties:
                source:
                  type: string
                  enum: [design, upload, none]
                  description: |
                    Where the picture came from, or `none` when the greeting has no
                    picture.
                  example: design
                design_id:
                  type: [string, "null"]
                  description: |
                    The company picture that was chosen, or `null`. A picture the buyer
                    uploaded has no public id a partner can name again, so it is `null`
                    there too.
                  example: gid_4711
        delivery:
          type: object
          description: |
            How and when the card was sent. `method` and `send_at` come back everywhere
            the order does, webhook payloads included, because neither names anybody and
            a subscriber needs the method to tell a posted card from an e-mailed one.
            The postal `address` and `pickup_point_id` say where a person lives, so they
            come back only on the four authenticated surfaces `sender` lists — the create
            response, `GET /v2/orders/{order_id}`, `GET /v2/orders`, and the order
            embedded in a checkout session.
          required: [method, send_at]
          properties:
            method:
              type: string
              enum: [sender_email, recipient_email, postal]
              description: How the card was delivered.
              example: recipient_email
            send_at:
              type: [string, "null"]
              format: date-time
              description: |
                When the card is due to go, or `null` when it was not scheduled. An
                unscheduled order reads `null` here rather than the moment it happened
                to be sent.
              example: '2026-12-24T09:00:00+01:00'
            pickup_point_id:
              type: [string, "null"]
              description: |
                Postal only. The collection point the buyer chose, or `null` when the
                card went to the address itself.
              example: gls-DK-1234
            shipping_priority:
              type: [string, "null"]
              description: |
                Postal only. The `spri_<id>` priority the card was posted at, or `null`.
                It is `null` for a courier parcel, which offers one speed, and for an
                own-post order written before the field existed.
              example: spri_2
            address:
              allOf:
                - $ref: '#/components/schemas/PostalAddressRead'
              description: Postal only. Where the printed card was sent.
        seller:
          type: object
          description: |
            The selling company's invoicing identity. With `tax` and `total` it is
            everything a compliant receipt or invoice needs, without a second lookup.
          required: [name, vat_number]
          properties:
            name:
              type: string
              description: The company's registered name.
              example: Mad & Kaffe
            vat_number:
              type: string
              description: |
                The seller's VAT registration number — the CVR number for a Danish
                company.
              example: "12345678"
        settlement:
          type: object
          description: |
            Present only on an order settled through a negotiated contract rather than
            hosted checkout. Written once and immutable thereafter.
          properties:
            mode:
              description: The negotiated mode this order was settled under.
              enum: [invoice_settlement, pay_on_location, external_processor]
              example: invoice_settlement
            external_reference:
              type: string
              description: The partner's own settlement reference, exactly as submitted.
              example: INV-2026-004417
            contract_reference:
              type: string
              description: Identifier of the Lifepeaks contract that authorised the settlement.
              example: LP-CONTRACT-2026-014
            contract_version:
              type: string
              description: Version of that contract in force when the order was settled.
              example: "1"
            status:
              type: string
              description: State of the settlement record.
              example: settled
        created_at:
          type: string
          format: date-time
          description: ISO 8601 moment the order was created.
          example: "2026-08-27T10:31:04+00:00"
        updated_at:
          type: string
          format: date-time
          description: |
            ISO 8601 moment the order last changed. Useful for spotting movement while
            polling.
          example: "2026-08-27T10:33:57+00:00"
      example:
        id: po_3f81a04d7c2e496b8a05f1c73de29b64
        object: order
        reference: A7K2M9XQ4B
        client_reference: cart_018f4f37b949
        status: paid
        payment:
          status: authorized
        fulfillment:
          status: processing
        amounts:
          currency: DKK
          subtotal: 349695
          discount: 0
          fees: 5000
          shipping: 0
          tax: 0
          total: 354695
        discount: null
        sender:
          name: Anna Jensen
          email: anna@example.com
          company: null
        recipient:
          name: Mette Sørensen
          email: mette@example.com
        greeting:
          message: Tillykke med dagen!
          image:
            source: design
            design_id: gid_4711
        delivery:
          method: recipient_email
          send_at: null
        line_items:
          - id: poli_9d41b0c7a5e34f2b8c6d0e1f2a3b4c5d
            object: line_item
            product_id: gcv_4821
            product_option_id: gcvo_9107
            amount: 100050
            quantity: 2
            fees: 2500
            subtotal: 200100
            discount: 0
            issued_count: 2
          - id: poli_1c7e5a94b30d42f6ae8b25d70c419f83
            object: line_item
            product_id: gcv_4821
            product_option_id: gcvo_9108
            amount: 149595
            quantity: 1
            fees: 2500
            subtotal: 149595
            discount: 0
            issued_count: 1
        created_at: "2026-08-27T10:31:04+00:00"
        updated_at: "2026-08-27T10:33:57+00:00"

    CheckoutSession:
      type: object
      description: |
        A short-lived Lifepeaks-hosted QuickPay checkout for one unpaid order. Redirect
        the buyer to `url`; if it expires, create a new session for the same order rather
        than a second order.
      required: [id, object, order_id, provider, status, url, expires_at, order]
      properties:
        id:
          type: string
          pattern: '^cs_[a-f0-9]{32}$'
          description: Checkout session id — `cs_` followed by 32 hexadecimal characters.
          example: cs_08d4b6f1e93a457c2fb0159de7c63a8b
        object:
          const: checkout_session
          description: Always `checkout_session`.
        order_id:
          type: string
          pattern: '^po_[a-f0-9]{32}$'
          description: The unpaid order this session collects payment for.
          example: po_3f81a04d7c2e496b8a05f1c73de29b64
        provider:
          type: string
          const: quickpay
          description: |
            The payment provider behind the hosted page. Always `quickpay`; a partner
            cannot substitute its own merchant.
          example: quickpay
        status:
          type: string
          description: Always `active` — an unusable session is not returned.
          enum: [active]
          example: active
        url:
          type: string
          format: uri
          description: |
            The hosted payment page. Redirect the buyer's browser here. It is
            single-purpose and short-lived, so do not store or email it.
          example: https://payment.quickpay.net/payments/8f2c41b9
        expires_at:
          type: string
          format: date-time
          description: |
            ISO 8601 moment the session stops accepting payment. After it, create a new
            session for the same order.
          example: "2026-08-27T11:01:04+00:00"
        order:
          allOf:
            - $ref: '#/components/schemas/Order'
          description: |
            The order as it stood when the session was created — a convenience snapshot,
            not canonical state. Re-read the order before acting on payment.
      example:
        id: cs_08d4b6f1e93a457c2fb0159de7c63a8b
        object: checkout_session
        order_id: po_3f81a04d7c2e496b8a05f1c73de29b64
        provider: quickpay
        status: active
        url: https://payment.quickpay.net/payments/8f2c41b9
        expires_at: "2026-08-27T11:01:04+00:00"
        order:
          id: po_3f81a04d7c2e496b8a05f1c73de29b64
          object: order
          client_reference: cart_018f4f37b949
          status: pending_payment
          payment:
            status: pending
          fulfillment:
            status: not_ready
          amounts:
            currency: DKK
            subtotal: 50000
            fees: 500
            shipping: 0
            tax: 0
            total: 50500
          created_at: "2026-08-27T10:31:04+00:00"
          updated_at: "2026-08-27T10:31:04+00:00"

    # ── Webhooks ──────────────────────────────────────────────────────────────

    WebhookEventType:
      type: string
      description: |
        One order event Lifepeaks can deliver.
        `order.payment_authorized` — payment confirmed; fulfilment may still be running.
        `order.fulfilled` — the gift card has been issued and delivered.
        `order.payment_failed` — payment did not succeed; present nothing as delivered.
        `order.refunded` — the payment was fully refunded.
        `order.partially_refunded` — part of the payment was refunded.
        `order.created` — an order was accepted, before any payment.
        `order.expired` — nobody paid for the order in time; it is written off and
        terminal. Subscribe to it if you keep your own basket state: it is how you learn
        to close a checkout the buyer walked away from instead of leaving it pending for
        ever.
      enum:
        - order.created
        - order.payment_authorized
        - order.fulfilled
        - order.payment_failed
        - order.refunded
        - order.partially_refunded
        - order.expired
      example: order.fulfilled

    WebhookEndpointRequest:
      type: object
      additionalProperties: false
      description: The endpoint to register and the events it should receive.
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          description: |
            Publicly reachable HTTPS URL that receives the signed POST. It must verify
            the signature against the raw request body before parsing anything.
          example: https://partner.example/webhooks/lifepeaks
        events:
          type: array
          minItems: 1
          uniqueItems: true
          description: |
            The event types to subscribe to. At least one, with no duplicates. Subscribe
            only to what you act on; unsubscribed events are never delivered.
          items:
            $ref: '#/components/schemas/WebhookEventType'
          example: [order.payment_authorized, order.fulfilled, order.payment_failed]
      example:
        url: https://partner.example/webhooks/lifepeaks
        events: [order.payment_authorized, order.fulfilled, order.payment_failed]

    WebhookEndpoint:
      type: object
      description: A registered webhook endpoint. Signing secrets are never included here.
      required:
        - id
        - url
        - events
        - status
        - consecutive_failures
        - secret_last4
        - secret_version
        - previous_secret_version
        - previous_secret_expires_at
        - disabled_at
        - created_at
        - updated_at
      properties:
        id:
          type: string
          pattern: '^wh_[a-f0-9]{32}$'
          description: Endpoint id — `wh_` followed by 32 hexadecimal characters.
          example: wh_9d41b7e0c8524fa63b1e70d5482ac9f6
        url:
          type: string
          format: uri
          description: The HTTPS URL deliveries are sent to.
          example: https://partner.example/webhooks/lifepeaks
        events:
          type: array
          description: The event types this endpoint is subscribed to.
          items:
            $ref: '#/components/schemas/WebhookEventType'
          example: [order.payment_authorized, order.fulfilled, order.payment_failed]
        status:
          description: |
            `active` — deliveries are being attempted. `disabled` — deliveries have
            stopped, either because you deleted the endpoint or because it failed
            repeatedly.
          enum: [active, disabled]
          example: active
        consecutive_failures:
          type: integer
          minimum: 0
          description: |
            How many delivery attempts have failed in a row. Resets to `0` on the next
            success. A climbing count is the early warning that a receiver is broken.
          example: 0
        secret_last4:
          type: string
          minLength: 4
          maxLength: 4
          description: |
            Last four characters of the current signing secret — enough to tell two
            secrets apart in a log, and useless to an attacker. The full secret is shown
            only once, at creation or rotation.
          example: t5E9
        secret_version:
          type: integer
          minimum: 1
          description: |
            Which generation of the signing secret is current, starting at `1` and
            incremented by each rotation. This is the version named in the
            `Lifepeaks-Webhook-Secret-Version` header of every delivery.
          example: 2
        previous_secret_version:
          type: [integer, "null"]
          minimum: 1
          description: |
            The secret generation that rotation replaced, while its overlap window is
            still open, and `null` when there is no active overlap — which is the case for
            an endpoint that has never been rotated and for one whose window has closed.

            Deliveries are signed with both secrets while this is non-null, so the
            previous secret still verifies.
          example: 1
        previous_secret_expires_at:
          type: [string, "null"]
          format: date-time
          description: |
            ISO 8601 moment the previous secret stops being accepted, or `null` when no
            overlap is active. Install the new secret before this instant; after it,
            deliveries are signed with the current secret alone.
          example: "2026-08-28T04:13:05+00:00"
        disabled_at:
          type: [string, "null"]
          format: date-time
          description: ISO 8601 moment the endpoint was disabled, or `null` while it is active.
          example: null
        created_at:
          type: string
          format: date-time
          description: ISO 8601 moment the endpoint was registered.
          example: "2026-08-20T08:12:33+00:00"
        updated_at:
          type: string
          format: date-time
          description: ISO 8601 moment the endpoint last changed.
          example: "2026-08-20T08:12:33+00:00"

    WebhookEndpointWithSecret:
      description: |
        A newly registered endpoint, including its signing secret. Only the `201` first
        execution carries `secret`; an idempotent replay returns the plain
        `WebhookEndpoint` without it.
      allOf:
        - $ref: '#/components/schemas/WebhookEndpoint'
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              pattern: '^whsec_[A-Za-z0-9_-]+$'
              readOnly: true
              description: |
                Plaintext signing secret, returned only by the first successful
                execution. Store it in a managed secret store immediately — Lifepeaks
                cannot show it again. If it is lost, rotate the secret rather than
                registering a second endpoint.
              example: whsec_Yb3nQ7xR2kLpV8sD4fH6jM0wZ1aC5tE9

    WebhookEndpointSecretResult:
      description: >-
        Secret rotation result. `secret` is present only on the first successful execution;
        an idempotent replay intentionally omits it.
      allOf:
        - $ref: '#/components/schemas/WebhookEndpoint'
        - type: object
          properties:
            secret:
              type: string
              pattern: '^whsec_[A-Za-z0-9_-]+$'
              readOnly: true
              description: |
                The new plaintext signing secret, shown once. Absent on a replay — treat
                that as "this rotation already happened". Keep accepting the previous
                secret until in-flight deliveries have drained.
              example: whsec_Kd8pR1zW6vN3yT5bG9hJ2mQ4sX7cF0aL

    WebhookDelivery:
      type: object
      additionalProperties: false
      description: One delivery attempt chain for a single event to a single endpoint.
      required:
        - id
        - endpoint_id
        - event_id
        - event_type
        - status
        - attempt_count
        - replay_count
        - next_attempt_at
        - last_attempt_at
        - delivered_at
        - response_status
        - failure_code
      properties:
        id:
          type: string
          pattern: '^wd_[a-f0-9]{32}$'
          description: Delivery id — `wd_` followed by 32 hexadecimal characters.
          example: wd_1a6f38c05b7d49e2ac83f10b7e526d94
        endpoint_id:
          type: string
          pattern: '^wh_[a-f0-9]{32}$'
          description: The endpoint this delivery targets.
          example: wh_9d41b7e0c8524fa63b1e70d5482ac9f6
        event_id:
          type: string
          pattern: '^evt_[a-f0-9]{32}$'
          description: |
            The event being delivered — `evt_` followed by 32 hexadecimal characters.
            This is the value your receiver sees in `Lifepeaks-Webhook-Id` and must
            deduplicate on. It survives a replay unchanged.
          example: evt_c72a5f9013e84bd6017c3ae5b48f290d
        event_type:
          allOf:
            - $ref: '#/components/schemas/WebhookEventType'
          description: Which event this delivery carries.
        status:
          description: |
            `pending` — queued, not yet attempted.
            `processing` — an attempt is in flight.
            `retry` — an attempt failed and another is scheduled.
            `delivered` — your endpoint acknowledged it.
            `dead` — retries were exhausted.
            `cancelled` — dropped because the endpoint was disabled.
            Only `delivered` and `dead` can be replayed.
          enum: [pending, processing, retry, delivered, dead, cancelled]
          example: delivered
        attempt_count:
          type: integer
          minimum: 0
          description: |
            Delivery attempts made in the current pass. A replay resets it to `0`, so it
            counts attempts since the last replay rather than for all time.
          example: 1
        replay_count:
          type: integer
          minimum: 0
          description: |
            How many times this delivery has been manually replayed. `0` for a delivery
            that has only ever run automatically.
          example: 0
        next_attempt_at:
          type: string
          format: date-time
          description: |
            ISO 8601 moment the next attempt is due. Meaningful while the delivery is
            still pending or retrying.
          example: "2026-08-27T10:34:12+00:00"
        last_attempt_at:
          type: [string, "null"]
          format: date-time
          description: ISO 8601 moment of the most recent attempt, or `null` before the first.
          example: "2026-08-27T10:34:12+00:00"
        delivered_at:
          type: [string, "null"]
          format: date-time
          description: |
            ISO 8601 moment your endpoint acknowledged the event, or `null` if it never
            has. Cleared by a replay.
          example: "2026-08-27T10:34:12+00:00"
        response_status:
          type: [integer, "null"]
          minimum: 100
          maximum: 599
          description: |
            HTTP status your endpoint returned on the last attempt, or `null` when it
            never answered — a timeout or connection failure. Cleared by a replay.
          example: 204
        failure_code:
          type: [string, "null"]
          maxLength: 64
          description: |
            Short machine-readable reason the last attempt failed, or `null` when it
            succeeded. Use it to tell a receiver bug from a network problem.
          example: null

    # ── Order-page revisions ──────────────────────────────────────────────────

    OrderPageRevisionSummary:
      type: object
      description: |
        One point in the hosted order page's history, without its field values — the
        shape used by the revision list.
      required:
        - id
        - language
        - revision
        - status
        - name
        - etag
        - base_revision_id
        - published_at
        - created_at
        - updated_at
      properties:
        id:
          type: string
          pattern: '^opr_[a-f0-9]{32}$'
          description: Revision id — `opr_` followed by 32 hexadecimal characters.
          example: opr_7c1e93a4b60d48f2a5e7c081b3d92f64
        language:
          type: string
          description: |
            BCP 47 locale this revision covers. A revision holds one locale; translating
            a page means drafting each locale separately.
          example: da-DK
        revision:
          type: integer
          minimum: 1
          description: Sequence number within the company's history for this locale, starting at `1`.
          example: 4
        status:
          description: |
            `draft` — editable, not visible to buyers.
            `published` — currently live.
            `archived` — previously live, superseded by a later publish. Archived
            revisions stay restorable.
          enum: [draft, published, archived]
          example: draft
        name:
          type: [string, "null"]
          description: |
            The label given when the draft was created, or `null` when it was created
            unnamed. At most 100 characters.
          example: Autumn campaign
        etag:
          type: string
          description: |
            Optimistic-concurrency tag for this revision. Send it as `If-Match` when
            editing or publishing; every successful write issues a new one.
          example: '"opr_7c1e93a4b60d48f2a5e7c081b3d92f64:v2"'
        base_revision_id:
          type: [string, "null"]
          description: |
            The revision this one was copied from, or `null` when it started from
            nothing. Set when a draft is seeded from a published revision or restored
            from an archived one.
          example: opr_2b8f04d7c19e4a6035fd82c7e1b940a3
        published_at:
          type: [string, "null"]
          description: |
            When this revision went live, as `YYYY-MM-DD HH:MM:SS`, or `null` for a
            revision that never has.
          example: null
        created_at:
          type: string
          description: When the revision was created, as `YYYY-MM-DD HH:MM:SS`.
          example: "2026-08-25 14:02:19"
        updated_at:
          type: string
          description: When the revision last changed, as `YYYY-MM-DD HH:MM:SS`.
          example: "2026-08-26 09:40:55"

    OrderPageRevision:
      description: A revision together with all of its stored field values.
      allOf:
        - $ref: '#/components/schemas/OrderPageRevisionSummary'
        - type: object
          required: [fields]
          properties:
            fields:
              type: object
              additionalProperties: true
              description: |
                Map of safe field name to stored value, using the names and types from
                `GET /v2/order-page/schema`.
              example:
                orderform_company_box_bg: '#2d3a8c'
                orderform_signup_text: Tilmeld dig eksklusive fordele

    OrderPagePreview:
      type: object
      additionalProperties: false
      description: |
        Exactly what the hosted order page would render from a draft, assembled fresh
        rather than served from a cache. Sent as `private, no-store`; it describes an
        unpublished draft, so do not cache or expose it.
      required: [revision, config, preview_mode]
      properties:
        revision:
          allOf:
            - $ref: '#/components/schemas/OrderPageRevision'
          description: The revision being previewed, with its metadata and field values.
        config:
          type: object
          additionalProperties: false
          description: The rendered configuration — draft copy combined with live imagery.
          required: [fields, images]
          properties:
            fields:
              type: object
              additionalProperties: true
              description: The draft's field values, as they would be applied to the page.
              example:
                orderform_company_box_bg: '#2d3a8c'
                orderform_signup_text: Tilmeld dig eksklusive fordele
            images:
              type: object
              description: |
                Map of image slot name to the URLs currently stored for it. Images are
                company-level rather than per-revision, so these are the **live** assets,
                not a draft copy — changing an image affects the published page
                immediately.
              additionalProperties:
                type: array
                items: { type: string, format: uri }
              example:
                logo: ["https://cdn.lifepeaks.dk/company/acme-hotels/logo.png"]
        preview_mode:
          type: string
          const: draft_payload
          description: |
            Always `draft_payload` — this response describes the draft's own payload
            rather than a rendered page.
          example: draft_payload

    Scope:
      type: string
      description: |
        One permission an API key can carry. Scopes are independent — none implies
        another, and `pdf_templates:write` does not grant `pdf_templates:read`. A key can
        never be issued with a scope its creator lacks, so mint narrow keys from a broad
        one. Every operation names the single scope it requires; `GET /v2/me` and
        `GET /v2/health` require none.
      enum:
        - companies:read
        - brand:read
        - brand:write
        - credentials:read
        - credentials:write
        - orderpage:read
        - orderpage:write
        - pdf_templates:read
        - pdf_templates:write
        - items:read
        - items:write
        - catalog:read
        - quotes:create
        - orders:create
        - orders:read
        - checkout:create
        - orders:settle
        - orders:refund
        - webhooks:read
        - webhooks:write
        - reports:read
      example: orders:read

    ErrorEnvelope:
      type: object
      description: |
        The body of every non-2xx response. Successful responses are never wrapped this
        way — the payload sits at the top level of the body.
      required: [error]
      properties:
        error:
          type: object
          description: What went wrong.
          required: [code, message]
          properties:
            code:
              type: string
              description: |
                Stable machine-readable reason. Branch on this, never on `message`.
                Common values are `unauthorized`, `insufficient_scope`, `not_found`,
                `validation_failed`, `idempotency_conflict`, and `request_in_progress`.
                `POST /v2/orders` adds `amount_precision_unsupported` for a total that
                needs more precision than a payment amount can hold. `rate_limited`
                accompanies every `429`. `test_key_not_accepted_in_production`
                accompanies a `409` when a test key tries to take or return a payment in
                production.

                The quote and order routes add these:

                `sender_company_required` (`422`) — the product may only be bought by a
                company and the order carried no `sender.company`, or carried one without
                its address.

                `discount_code_not_found` (`422`) — the code is unknown, switched off,
                not yet open, expired, or fully used. One answer for all five,
                deliberately, so the field cannot be used to discover a competitor's
                campaigns.

                `discount_code_not_applicable` (`422`) — the code is live, but nothing in
                this order answers to it.

                `shipping_priority_unsupported` (`422`) — `delivery.shipping_priority`
                was sent to a company whose cards travel with a courier, which offers one
                speed.

                `shipping_priority_unavailable` (`422`) — the priority named is not one
                the postal method publishes for this company.

                `insufficient_stock` (**`409`**, special offers only) — the last voucher
                went to another buyer between the quote and the order. Nothing in the body
                is wrong, so re-read the catalog rather than correcting a field.

                `below_minimum` and `above_maximum` (`422`, special offers only) — the
                line's `quantity` is outside the option's own bounds, named on
                `items.<n>.quantity`.
              example: validation_failed
            message:
              type: string
              description: |
                Human-readable explanation, intended for a developer or an operator. The
                wording can change without notice and is not a stable contract.
              example: Invalid request.
            fields:
              type: object
              description: |
                Per-field validation messages, keyed by the field name that failed.
                Present only on `422` responses; absent on every other status.
              additionalProperties:
                type: array
                items:
                  type: string
              example:
                label:
                  - Label is required.
      example:
        error:
          code: validation_failed
          message: Invalid request.
          fields:
            label:
              - Label is required.

    ApiKeyCreateRequest:
      type: object
      description: The key to mint.
      required: [label, scopes]
      properties:
        environment:
          type: string
          enum: [live, test]
          default: live
          description: |
            Which QuickPay merchant a checkout started with this key reaches. `test` is
            not a sandbox of the DATA — there is one Lifepeaks database, and a test key
            reads and writes exactly what a live one does. It is a sandbox of the MONEY.

            Omit it for a live key, which is what every existing caller gets.
          example: test
        label:
          type: string
          minLength: 1
          description: |
            Human-readable name for the key, shown in the admin UI and in
            `GET /v2/api-keys`. Name it after the integration that will hold it, so an
            audit can tell what each key is for. Must not be empty.
          example: Storefront production
        scopes:
          type: array
          description: |
            The permissions to grant. Every value must come from the `Scope` list, and
            the set cannot exceed the scopes the calling key itself carries — an
            over-broad request is rejected with `422` and nothing is minted. Grant only
            what the integration needs.
          items:
            $ref: "#/components/schemas/Scope"
          example: [catalog:read, quotes:create, orders:create, orders:read, checkout:create]
        expires_in_days:
          type: integer
          minimum: 1
          maximum: 365
          description: |
            Key lifetime in whole days, counted from creation, between `1` and `365`.
            Omit it to issue a key that never expires. Prefer a finite lifetime and plan
            the rotation — an expired key answers `401` with no grace period.
          example: 90
      example:
        label: Storefront production
        scopes: [catalog:read, quotes:create, orders:create, orders:read, checkout:create]
        expires_in_days: 90

    ApiKeyCreated:
      type: object
      description: |
        A newly minted key. This is the only response that ever contains the plaintext
        value.
      required: [id, label, scopes, key, expires_at]
      properties:
        id:
          type: integer
          minimum: 1
          description: |
            Numeric key id. Use it to revoke the key with
            `DELETE /v2/api-keys/{id}`.
          example: 42
        label:
          type: string
          description: The label the key was issued with.
          example: Storefront production
        scopes:
          type: array
          description: The scopes the key was granted.
          items:
            $ref: "#/components/schemas/Scope"
          example: [catalog:read, quotes:create, orders:create, orders:read, checkout:create]
        key:
          type: string
          description: |
            The plaintext API key — `lp_live_`, a 32-character hexadecimal key id, an
            underscore, and a 64-character hexadecimal secret. **Shown once.** Lifepeaks
            stores only a hash, so write it to a managed secret store before doing
            anything else; if it is lost, revoke this key and issue another.
          example: lp_live_4f2c9a71b83d4e6fa0c15d27e93b6a08_5d8e1c04b7a9f3620e4d81c7a5b93f0d2e6c48a1b309f75d84e2c6b19a03d7f5
        expires_at:
          type: [string, "null"]
          format: date-time
          description: |
            ISO 8601 expiry derived from `expires_in_days`, or `null` when the key does
            not expire. Note that `GET /v2/api-keys` reports the same instant in
            `YYYY-MM-DD HH:MM:SS` form instead.
          example: "2026-11-25T00:00:00+00:00"
      example:
        id: 42
        label: Storefront production
        scopes: [catalog:read, quotes:create, orders:create, orders:read, checkout:create]
        key: lp_live_4f2c9a71b83d4e6fa0c15d27e93b6a08_5d8e1c04b7a9f3620e4d81c7a5b93f0d2e6c48a1b309f75d84e2c6b19a03d7f5
        expires_at: "2026-11-25T00:00:00+00:00"

    ApiKey:
      type: object
      description: |
        One issued key, as it appears in a listing. The plaintext value is never included.
        A key is usable only while `revoked_at` is `null` and `expires_at` is either
        `null` or still in the future.
      required: [id, label, scopes, environment]
      properties:
        id:
          type: integer
          minimum: 1
          description: Numeric key id. Pass it to `DELETE /v2/api-keys/{id}` to revoke the key.
          example: 42
        label:
          type: string
          description: The label the key was issued with.
          example: Storefront production
        scopes:
          type: array
          description: The scopes this key carries.
          items:
            $ref: "#/components/schemas/Scope"
          example: [catalog:read, quotes:create, orders:create, orders:read, checkout:create]
        environment:
          type: string
          description: |
            The key's environment label. Send `environment` on `POST /v2/api-keys` to
            choose it; omit it and the key is `live`. The admin screen mints live keys
            only.
          enum: [live, test]
          example: live
        expires_at:
          type: [string, "null"]
          description: |
            When the key stops working, as `YYYY-MM-DD HH:MM:SS`, or `null` when it never
            expires. The create response reports the same instant in ISO 8601 form
            instead.
          example: "2026-11-25 00:00:00"
        revoked_at:
          type: [string, "null"]
          description: |
            When the key was revoked, as `YYYY-MM-DD HH:MM:SS`, or `null` while it is
            still valid. A revoked key is retained for audit and can never be reinstated.
          example: null
        last_used_at:
          type: [string, "null"]
          description: |
            When the key last authenticated a request, as `YYYY-MM-DD HH:MM:SS`, or
            `null` if it never has. Use it to find keys that can safely be retired.
          example: "2026-08-26 10:30:00"

    OrderPageConfig:
      type: object
      required: [fields, images]
      description: Current order-page configuration for the company
      properties:
        fields:
          type: object
          description: |
            Map of safe field names to their current values.
            Keys match the catalog from GET /v2/order-page/schema.
          additionalProperties: true
          example:
            orderform_company_box_bg: "#1a2b3c"
            orderform_signup_text: "Sign up for exclusive benefits"
            orderform_unsplash_enabled: 1
            gtm: "GTM-ABC1234"
        images:
          type: object
          description: Map of image slot names to arrays of CDN URLs
          additionalProperties:
            type: array
            items:
              type: string
          example:
            logo: ["https://cdn.lifepeaks.dk/company/42/logo.png"]

    OrderPagePatchRequest:
      type: object
      description: |
        Map of safe field names to new values. Only include fields you want to change.
        Optionally include a `lang` key (string) to target a specific locale for i18n fields.
        Unknown or sensitive fields are rejected with 422.
      additionalProperties: true
      example:
        orderform_company_box_bg: "#1a2b3c"
        orderform_signup_text: "Sign up for exclusive benefits"
        lang: "da-DK"

    OrderPageSchema:
      type: object
      description: |
        The allow-list of order-page fields that can be written through the API. It is
        dynamic — read it before writing rather than hardcoding field names.
      required: [fields]
      properties:
        fields:
          type: array
          description: |
            Every writable field, with its type and constraints. A field absent from this
            list cannot be written and is rejected with `422`.
          items:
            $ref: "#/components/schemas/FieldDescriptor"

    FieldDescriptor:
      type: object
      required: [field, type, max, i18n, group, table]
      description: One writable order-page field and the rules its value must satisfy.
      properties:
        field:
          type: string
          description: |
            The field's name — the key to use in `PATCH /v2/order-page` and in a draft's
            `fields` map.
          example: orderform_signup_text
        type:
          type: string
          description: |
            The value type this field accepts.
            `string` — plain text within `max`.
            `bool` — integer `0` or `1` (the strings `"0"` and `"1"` are also accepted).
            `color` — 7-character hex, `#rrggbb`, case-insensitive.
            `gtm` — Google Tag Manager container id matching `GTM-XXXXXXX`.
            `csv` — comma-separated string within `max`, for example `da-DK,en-GB`.
            `int` — whole number.
            A value of the wrong type is rejected with `422`.
          enum: [string, bool, color, gtm, csv, int]
          example: string
        max:
          type: [integer, "null"]
          description: |
            Maximum length in characters, or `null` when the field has no limit beyond
            what storage allows. An over-length value is rejected with `422`.
          example: 2000
        i18n:
          type: boolean
          description: |
            Whether the field holds a separate value per locale. When `true`, pass `lang`
            to read or write a specific locale; omitting it targets `en-GB`. When
            `false`, the field has one value shared by every locale and `lang` is ignored.
          example: true
        group:
          type: string
          description: |
            Which part of the page the field belongs to, for grouping in an editing UI.
            `theme` — colors. `copy` — buyer-facing text. `links` — URLs and link labels.
            `flags` — on/off toggles. `other` — everything else.
          enum: [theme, copy, links, flags, other]
          example: copy
        table:
          type: string
          description: |
            Where Lifepeaks stores the field. Exposed so an editing UI can group related
            settings consistently; it carries no meaning for the request itself.
          example: company_orderform

    # ── Items, orders, gift cards ─────────────────────────────────────────────

    ItemStatusId:
      type: integer
      description: |
        The item's status, as a numeric id.
        `3` Used — fully redeemed.
        `4` Cancelled.
        `5` Expired — past its validity date.
        `6` Paid — live and redeemable.
        `10` Awaiting Payment — created but not yet paid.
      enum: [3, 4, 5, 6, 10]
      example: 6

    ItemTypeFilter:
      type: string
      description: |
        Which kind of item to return. Each value selects a single type, except the long
        concatenated one, which is the catch-all meaning "every kind of gift card". That
        value looks like a mistake but is not — it is the literal string the filter
        expects, so send it verbatim.
      enum:
        - GIFTCARD
        - GIFTCARD VARIATION
        - DISCOUNTED GC
        - GIFT CARD WITH ADDED VALUE
        - GIFTCARDGIFTCARD VARIATIONDISCOUNTED GCGIFT CARD WITH ADDED VALUEADD-ON VOUCHER
        - TICKET
        - SPECIALOFFER
        - BENEFITDEAL
      example: GIFTCARD

    ListFilter:
      type: object
      description: |
        Echo of the filter values actually applied, after the `offset`, `limit`, and
        `order` coercion. Returned only on the development server
        (`api-dev.lifepeaks.dk`) as a debugging aid — production responses omit it, so
        never depend on it in integration code.
      additionalProperties: true
      example:
        offset: 0
        limit: 10
        order: desc

    ItemList:
      type: object
      description: A page of item rows.
      required: [object, data, has_more, next_cursor]
      properties:
        object:
          const: list
          description: Always `list`.
        data:
          type: array
          description: |
            The matching items. An empty array is a successful "nothing matched", not an
            error.
          items:
            $ref: "#/components/schemas/Item"
        has_more:
          type: boolean
          description: |
            Whether another page follows. Stop when it is `false`; never infer the end of
            a list from a short page.
          example: true
        next_cursor:
          type: [string, "null"]
          description: |
            Send this back as `starting_after` for the next page, unchanged. It is
            opaque: do not build one, parse one, or do arithmetic on one. `null` when
            this is the last page.
          example: bzI1

    Item:
      type: object
      description: |
        One item row. Any field can be `null` when it does not apply to the item type.
      properties:
        code:
          type: string
          description: The item's code.
          example: 77xrB3e44T
        order_id:
          type: string
          description: Id of the order the item belongs to.
          example: devOEVLB1EW33
        group_code:
          type: [string, "null"]
          description: |
            Code shared by every item bought together as a group, or `null` when the item
            is not part of one.
          example: null
        item_type:
          type: string
          description: Item type, translated into the response language.
          example: Gift Card
        item_type_identification:
          type: string
          description: Item type, always in English — use this one for logic.
          enum:
            - Gift Card
            - Gift Card Variation
            - Discounted Gift Card for Employees
            - Discounted Gift Card for Customers
            - Private Discounted Gift Card for Customers
            - Ticket Coupon
            - Add-on Voucher
            - Special Offer
            - Ticket
            - Order
            - Credit
            - Inactive Item
        item_title:
          type: string
          description: Product title — the gift-card, variation, or ticket name.
          example: Gift Card
        event_title:
          type: [string, "null"]
          description: |
            Title of the event a ticket belongs to, or `null` for items that are not
            tickets.
          example: null
        ticket_title:
          type: [string, "null"]
          description: Title of the ticket type, or `null` for items that are not tickets.
          example: null
        is_token:
          type: boolean
          description: |
            Whether the item is an add-on token rather than a ticket in its own right.
          example: false
        item_info:
          type: [string, "null"]
          description: |
            Extra human-readable detail about the item, or `null` when there is none.
          example: null
        status:
          allOf:
            - $ref: "#/components/schemas/ItemStatusId"
          description: The item's current status id.
        status_name:
          type: string
          description: |
            Status name, translated into the response language. For display only — branch
            on `status`.
          example: Paid
        amount:
          type: number
          description: |
            Face value of the item, as a decimal in `currency` — `500` means DKK 500.00.
            The item endpoints report decimals, not the minor units used by headless
            commerce.
          example: 500
        vat:
          type: number
          description: VAT rate applied to the item, as a percentage.
          example: 25
        vat_value:
          type: number
          description: VAT included in `amount`, as a decimal in `currency`.
          example: 100
        currency:
          type: string
          description: |
            ISO 4217 currency of `amount`, `vat_value`, and `remaining_amount`, in upper
            case.
          example: DKK
        remaining_amount:
          type: number
          description: |
            Value still available to redeem, as a decimal in `currency`. Equal to
            `amount` on an untouched item and `0` once fully redeemed.
          example: 400
        remaining_pieces:
          type: [integer, "null"]
          description: |
            Pieces still available to redeem on a multi-piece product, or `null` for an
            item that is not counted in pieces.
          example: null
        available_claim:
          type: boolean
          description: |
            Whether the item can be redeemed right now. Check it before offering a
            redeem action; when it is `false`, `claim_error` says why.
          example: true
        available_piece_claim:
          type: boolean
          description: Whether a piece-based redemption is available for this item.
          example: false
        available_amount_claim:
          type: boolean
          description: Whether an amount-based redemption is available for this item.
          example: true
        available_group_claim:
          type: boolean
          description: |
            Whether the whole order can be redeemed in one call through
            `POST /v2/orders/{order_id}/claim`.
          example: false
        available_cancel:
          type: boolean
          description: |
            Whether `POST /v2/items/{code}/cancel` would cancel this item. Cancelling is
            for an item that has not been used.
          example: true
        available_discard:
          type: boolean
          description: |
            Whether the same cancel call would discard the item instead. Discarding
            applies in states where cancelling does not; the response message says which
            action was taken.
          example: false
        speed_claim:
          type: boolean
          description: |
            Whether the item qualifies for one-tap redemption in the Lifepeaks staff app,
            where no amount needs to be chosen.
          example: false
        available_refund:
          type: boolean
          description: |
            Whether a previous redemption can be reversed. When `false`, `refund_error`
            says why, and that same text is returned as the `422` message if you try.
          example: true
        available_piece_refund:
          type: boolean
          description: Whether a piece-based refund is available for this item.
          example: false
        available_amount_refund:
          type: boolean
          description: Whether an amount-based refund is available for this item.
          example: true
        company_name:
          type: string
          description: Name of the company that issued the item.
          example: Acme Hotels
        validity:
          type: [string, "null"]
          description: |
            When the item expires, as `YYYY-MM-DD HH:MM:SS`, or `null` when it does not
            expire.
          example: "2029-05-10 00:00:00"
        sender_name:
          type: string
          description: Name of the buyer, shown on the card as the giver.
          example: Alex Sender
        sender_email:
          type: string
          description: Buyer's email address.
          example: alex@example.com
        sender_phone:
          type: [string, "null"]
          description: Buyer's phone number, or `null` when none was collected.
          example: null
        sender_company_use:
          type: boolean
          description: |
            Whether the buyer ordered on behalf of a company. When `true`, the
            `sender_company_*` fields below are populated.
          example: false
        sender_company_name:
          type: [string, "null"]
          description: Buyer's company name, or `null` for a private purchase.
          example: null
        sender_company_address:
          type: [string, "null"]
          description: Buyer's company street address, or `null`.
          example: null
        sender_company_city:
          type: [string, "null"]
          description: Buyer's company city, or `null`.
          example: null
        sender_company_postcode:
          type: [string, "null"]
          description: Buyer's company postcode, or `null`.
          example: null
        sender_company_country:
          type: [string, "null"]
          description: Buyer's company country, or `null`.
          example: null
        reciever_name:
          type: string
          description: |
            Name of the person the item was bought for. Spelled `reciever_` in the
            payload — the misspelling is part of the wire format and is kept for
            compatibility. Request fields use the correct `receiver_` spelling.
          example: Sam Recipient
        reciever_email:
          type: string
          description: |
            Email address the item was delivered to. Spelled `reciever_` in the payload,
            for the same compatibility reason.
          example: sam@example.com
        reciever_phone:
          type: [string, "null"]
          description: Recipient's phone number, or `null` when none was collected.
          example: null
        created_at:
          type: string
          description: When the item was created, as `YYYY-MM-DD HH:MM:SS`.
          example: "2026-05-12 15:31:57"
        language:
          type: string
          description: |
            Locale the item was bought in. It determines the language of the delivery
            email and the PDF.
          example: en-GB
        delayed_deliver:
          type: boolean
          description: |
            Whether delivery was scheduled for a later date rather than sent immediately.
            When `true`, `deliver_at` holds the scheduled moment.
          example: false
        deliver_at:
          type: [string, "null"]
          description: |
            Scheduled delivery moment as `YYYY-MM-DD HH:MM:SS`, or `null` when delivery
            was not delayed.
          example: null
        delivered_at:
          type: [string, "null"]
          description: |
            When the item was emailed, as `YYYY-MM-DD HH:MM:SS`, or `null` if it has not
            been sent.
          example: "2026-05-12 15:32:04"
        delivered_to_post_at:
          type: [string, "null"]
          description: |
            When a physical copy was handed to postal delivery, as
            `YYYY-MM-DD HH:MM:SS`, or `null` when the item was not posted.
          example: null
        greetings_text:
          type: [string, "null"]
          description: |
            Personal message the buyer wrote for the recipient, or `null` when none was
            entered.
          example: null
        image:
          type: [string, "null"]
          description: |
            URL of the greeting or product image shown on the item, or `null` when it has
            none.
          example: null
        variant_description:
          type: [string, "null"]
          description: |
            Description of the gift-card variation, as HTML. `null` for items without a
            variation. Sanitise it before rendering.
          example: null
        channel:
          type: [string, "null"]
          description: |
            Sales channel the item was sold through, or `null` when none was recorded.
          example: null
        api_receipt_text:
          type: [string, "null"]
          description: |
            Receipt text supplied when the item was created through the API, or `null`.
          example: null
        claim_error:
          type: [string, "null"]
          description: |
            Why the item cannot currently be redeemed — expired, already used, cancelled,
            wrong company — in the response language, or `null` when it can. Safe to show
            to staff.
          example: null
        refund_error:
          type: [string, "null"]
          description: |
            Why a redemption cannot currently be reversed, in the response language, or
            `null` when it can. This same text comes back as the `422` message from a
            rejected refund.
          example: null
        claim_url:
          type: string
          description: |
            Staff redemption deep link for this item. It is what the item's QR code
            encodes, and it leads to an operator login rather than to the card itself.
          example: https://app.lifepeaks.dk/l/77xrB3e44T
        pdf_url:
          type: string
          description: |
            Public link to the item's PDF, in the form `/pdf/{order_id}/{code}` on the
            app domain. No login is required, so treat it as sensitive. Append `?g` to
            include the personal greeting, `?r` to show the remaining value, or `?d=D` to
            force a download.
          example: https://app.lifepeaks.dk/pdf/devOEVLB1EW33/77xrB3e44T
        pdf_short_url:
          type: string
          description: |
            Shortened public link to the same PDF — the form recipients receive by email.
            Equally unauthenticated.
          example: https://app.lpeaks.dk/p/W3377xrB3e44T

    ItemSummary:
      type: object
      description: |
        The reduced item row returned by `GET /v2/reports/analytics`: the analytic
        columns only. It deliberately omits the availability flags, buyer identity, and
        redemption and PDF links that `GET /v2/items` returns, so a reporting integration
        never handles personal data it does not need.
      properties:
        item_type:
          type: string
          description: Item type, translated into the response language. For display only.
          example: Gift Card
        item_type_identification:
          type: string
          description: Item type, always in English. Use this one for logic and grouping.
          example: Gift Card
        item_title:
          type: string
          description: Product title — the gift-card, variation, or ticket name.
          example: Gift Card
        event_title:
          type: [string, "null"]
          description: Title of the event a ticket belongs to, or `null` for non-ticket items.
          example: null
        ticket_title:
          type: [string, "null"]
          description: Title of the ticket type, or `null` for non-ticket items.
          example: null
        is_token:
          type: boolean
          description: Whether the row is an add-on token rather than a ticket in its own right.
          example: false
        item_info:
          type: [string, "null"]
          description: Extra human-readable detail about the item, or `null` when there is none.
          example: null
        status:
          allOf:
            - $ref: "#/components/schemas/ItemStatusId"
          description: The item's current status id.
        status_name:
          type: string
          description: Status name, translated into the response language. For display only.
          example: Paid
        amount:
          type: number
          description: |
            Face value of the item, as a decimal in `currency`. This endpoint reports
            decimals, not minor units.
          example: 500
        vat:
          type: number
          description: VAT rate applied, as a percentage.
          example: 25
        vat_value:
          type: number
          description: VAT included in `amount`, as a decimal in `currency`.
          example: 100
        currency:
          type: string
          description: ISO 4217 currency of the amounts on this row, in upper case.
          example: DKK
        remaining_amount:
          type: number
          description: Value still available to redeem, as a decimal in `currency`.
          example: 400
        remaining_pieces:
          type: [integer, "null"]
          description: |
            Pieces still available to redeem, or `null` for an item that is not counted
            in pieces.
          example: null
        company_name:
          type: string
          description: Name of the company that issued the item.
          example: Acme Hotels
        validity:
          type: [string, "null"]
          description: |
            When the item expires, as `YYYY-MM-DD HH:MM:SS`, or `null` when it does not
            expire.
          example: "2029-05-10 00:00:00"
        created_at:
          type: string
          description: When the item was created, as `YYYY-MM-DD HH:MM:SS`.
          example: "2026-01-12 15:31:57"
        language:
          type: string
          description: Locale the item was bought in.
          example: en-GB

    ClaimRequest:
      type: object
      description: How much of the item to redeem.
      required: [type]
      properties:
        type:
          type: string
          enum: [piece, amount, all]
          description: |
            `all` redeems everything that is left. `piece` redeems `number` pieces.
            `amount` redeems `amount` of value. Any other value fails with `422`.
          example: amount
        number:
          type: number
          description: |
            Pieces to redeem. Required when `type` is `piece`; must be greater than `0`
            and at most the item's `remaining_pieces`.
          example: 1
        amount:
          type: number
          description: |
            Value to redeem, as a decimal in the item's currency — not minor units.
            Required when `type` is `amount`; must be greater than `0` and at most the
            item's `remaining_amount`. Asking for the entire remaining value is treated
            as redeeming the whole item.
          example: 100
        claim_note:
          type: string
          description: |
            Free-text note stored with the transaction and returned by
            `GET /v2/reports/claimed-items` — typically a booking or reservation number,
            which is what makes the ledger reconcilable against your own system.
          example: Booking 55123
      example:
        type: amount
        amount: 100
        claim_note: Booking 55123

    ClaimResult:
      type: object
      description: What the redemption did and what is left afterwards.
      required: [info, message, remaining_value, remaining_number, partly_claimed]
      properties:
        info:
          type: string
          description: Short summary of what happened, in the response language.
          example: Gift Card partly claimed
        message:
          type: string
          description: |
            Human-readable detail of how much was redeemed, in the response language.
            Safe to show to staff; the wording is not a stable contract.
          example: Successfully claimed 100 DKK.
        remaining_value:
          type: [number, "null"]
          description: |
            Value left after this redemption, as a decimal in the item's currency, or
            `null` for an item not tracked by value.
          example: 400
        remaining_number:
          type: [integer, "null"]
          description: |
            Pieces left after this redemption, or `null` for an item not counted in
            pieces.
          example: null
        partly_claimed:
          type: boolean
          description: |
            `true` when value or pieces remain, `false` once the item is fully redeemed.
          example: true
        claimed_amount:
          type: number
          description: |
            How much was redeemed, as a decimal in the item's currency. Omitted entirely
            when the call redeemed the whole item.
          example: 100
      example:
        info: Gift Card partly claimed
        message: Successfully claimed 100 DKK.
        remaining_value: 400
        remaining_number: null
        partly_claimed: true
        claimed_amount: 100

    RefundRequest:
      type: object
      description: How much of a previous redemption to reverse.
      required: [type]
      properties:
        type:
          type: string
          enum: [piece, amount, all]
          description: |
            Mirrors the claim types. `all` reverses everything redeemed. `piece` returns
            `number` pieces. `amount` returns `amount` of value.
          example: all
        number:
          type: number
          description: |
            Pieces to return. Required when `type` is `piece`; must be greater than `0`
            and at most what was redeemed.
          example: 1
        amount:
          type: number
          description: |
            Value to return, as a decimal in the item's currency. Required when `type` is
            `amount`; must be greater than `0` and at most what was redeemed.
          example: 100
      example:
        type: all

    RefundResult:
      type: object
      description: What the refund reversed and what is left afterwards.
      required: [info, message, remaining_value, remaining_number, partly_refunded]
      properties:
        info:
          type: string
          description: Short summary of what happened, in the response language.
          example: Gift Card refunded
        message:
          type: string
          description: |
            Human-readable detail of the reversal, in the response language. The wording
            is not a stable contract.
          example: Gift Card rVwvAH4abb has been fully refunded!
        remaining_value:
          type: [number, "null"]
          description: |
            Value available to redeem after the reversal, as a decimal in the item's
            currency, or `null` for an item not tracked by value.
          example: 500
        remaining_number:
          type: [integer, "null"]
          description: |
            Pieces available to redeem after the reversal, or `null` for an item not
            counted in pieces.
          example: null
        partly_refunded:
          type: boolean
          description: |
            `true` when only part of what was redeemed has been returned, `false` when
            the redemption was reversed in full.
          example: false

    ActivateRequest:
      type: object
      description: The value to bring a dormant campaign code to life with.
      properties:
        amount:
          type: number
          description: |
            Value to activate the code with, as a decimal in the company's currency.
            Required — and must be numeric — when the campaign was created without a
            fixed value; ignored when the campaign carries its own value.
          example: 500
      example:
        amount: 500

    ActivateResult:
      type: object
      description: Confirmation that a dormant code is now a live item.
      required: [message, amount, validity]
      properties:
        message:
          type: string
          description: |
            Human-readable confirmation naming the code, its value, and its expiry, in
            the response language. The wording is not a stable contract — read `amount`
            and `validity` instead of parsing it.
          example: "Item zF92Emxn_c has been activated! Value: 500. Validity: 06.05.2029."
        amount:
          type: [number, "null"]
          description: |
            The value the code was activated with, as a decimal in the company's
            currency, or `null` when the campaign sets no value.
          example: 500
        validity:
          type: [string, "null"]
          description: |
            When the new item expires, as `DD.MM.YYYY` — note this endpoint uses day-first
            format, unlike the `YYYY-MM-DD` used elsewhere. `null` when it does not
            expire.
          example: "06.05.2029"

    ResendRequest:
      type: object
      description: Where to send the item again.
      required: [email]
      properties:
        email:
          type: string
          format: email
          description: |
            Address to email the item to. It does not have to be the original recipient,
            so this is how a lost gift card is re-delivered. The resend is recorded in
            the item's action log.
          example: sam@example.com
      example:
        email: sam@example.com

    MessageResult:
      type: object
      description: A plain confirmation that an action completed.
      required: [message]
      properties:
        message:
          type: string
          description: |
            Human-readable confirmation of what happened, in the response language — for
            the cancel endpoint, which of cancelling or discarding was applied. The
            wording is not a stable contract.
          example: Item 77xrB3e44T has been cancelled!

    GiftCardCreateRequest:
      type: object
      description: |
        The gift cards to issue directly under an invoice-settlement contract.

        **Amounts here are decimals in the company's currency**, not the smallest unit
        the headless commerce endpoints use. This route keeps v1's convention so an
        existing integration migrates without rescaling.

        The body is closed: a field that is not listed here is rejected with `422`
        rather than ignored, so a misspelling cannot quietly issue a card at the wrong
        price. `company` is one of the listed fields, because this operation reads the
        acting-company selector from the body as well as from the query string.
      additionalProperties: false
      required: [amount]
      properties:
        amount:
          type: number
          description: |
            Face value of each card, as a **decimal in the company's currency** — `500`
            means DKK 500.00. Must be greater than `0`.

            It is also bounded: a campaign's `amount_from` / `amount_to` apply when
            `promotion_id` is set, and otherwise the company's own configured
            gift-card range applies. A value outside whichever range is in force is
            rejected with `422`. A company that has configured no range keeps working
            unbounded.

            **Not the same unit as `amount` on `POST /v2/quotes` and `POST /v2/orders`**,
            which take whole numbers of øre — sending `50000` here asks for a
            DKK 50,000.00 card, which the company range will usually reject.
          example: 500
        piece:
          type: integer
          default: 1
          minimum: 1
          description: |
            How many identical cards to create in this one order. Must be greater than
            `0`. Every card shares the returned `order_id`.
          example: 1
        receiver_name:
          type: string
          description: |
            Name of the person the cards are for. Defaults to the company's configured
            default sender name.
          example: Sam Recipient
        receiver_email:
          type: string
          format: email
          description: |
            Address to email the cards to. Defaults to `no-email@lifepeaks.dk`, which is
            also the `sender_email` default — and when the two addresses match Lifepeaks
            sends **no email at all**, leaving delivery to you. Set a distinct address to
            have Lifepeaks deliver the card.
          default: no-email@lifepeaks.dk
          example: sam@example.com
        sender_name:
          type: string
          description: |
            Name shown on the card as the giver. Defaults to the company's configured
            default sender name.
          example: Acme Hotels
        sender_email:
          type: string
          format: email
          description: |
            Buyer's email address. Defaults to `no-email@lifepeaks.dk`; leaving it equal
            to `receiver_email` is what suppresses delivery.
          default: no-email@lifepeaks.dk
          example: no-email@lifepeaks.dk
        promotion_id:
          type: [integer, "null"]
          description: |
            `id` of an active discounted or added-value campaign from
            `GET /v2/promotions`. The campaign's `amount_from` and `amount_to`
            bounds are enforced against `amount`, and a value outside them fails with
            `422`. Omit or send `null` for no campaign.
          example: 499
        validity:
          type: string
          format: date
          description: |
            Expiry date as `YYYY-MM-DD`. Anything that does not parse in exactly that
            format is silently ignored and the company's default validity applies — so a
            malformed date produces a card, not an error.
          example: "2029-05-10"
        sdh_product_id:
          type: string
          description: |
            Stays product id. Relevant only to Stays partners; the company's default
            product applies when it is omitted.
          example: STAYS-STD
        capture:
          type: boolean
          default: true
          description: |
            Whether to activate the cards immediately. This is a compatibility switch for
            a negotiated invoice-settlement order and never represents a card payment.
            `true` (the default) makes the cards live at once; `false` creates them
            dormant for later activation through `POST /v2/orders/{order_id}/capture`.
          example: false
        company:
          type: string
          description: |
            Slug of the company to act as, for a key assigned to more than one. This
            operation accepts it in the body as well as in the `company` query
            parameter; the query string wins when both carry it. Omit it and the
            request acts as the key's own company. A slug that is unknown, inactive or
            not assigned to the key fails with `404 not_found`.
          example: acme-hotels
      example:
        amount: 500
        piece: 1
        receiver_name: Sam Recipient
        receiver_email: sam@example.com
        sender_name: Acme Hotels
        capture: false

    GiftCardCreated:
      type: object
      description: The cards that were issued, and the order they belong to.
      required: [items, itemsCount, order_id]
      properties:
        items:
          type: array
          description: |
            The created cards, one entry per `piece`, in the same row shape
            `GET /v2/items` returns.
          items:
            $ref: "#/components/schemas/Item"
        itemsCount:
          type: integer
          description: Number of cards created — matches the `piece` that was requested.
          example: 1
        order_id:
          type: string
          description: |
            Id of the new compatibility order. Pass it to
            `POST /v2/orders/{order_id}/capture` and
            `POST /v2/orders/{order_id}/claim`. It is not a `po_…` headless order id and
            the two are not interchangeable.
          example: devOEVLB1EW33
        filter:
          allOf:
            - $ref: "#/components/schemas/ListFilter"
          description: Development-only echo of the applied list settings. Do not depend on it.

    Promotion:
      type: object
      description: |
        One active gift-card campaign — either a discount on the purchase price or extra
        value on the card. Exactly one of `reduced_price` and `added_value` is set.
      required: [id, name, modification, amount_from, amount_to, reduced_price, added_value]
      properties:
        id:
          type: integer
          description: |
            Campaign id. Pass it as `promotion_id` on `POST /v2/items` to apply the
            campaign.
          example: 499
        name:
          type: string
          description: The campaign's internal name, as configured in the admin UI.
          example: API Campaign Added 10%
        modification:
          type: string
          description: |
            Which kind of campaign this is. `discount` reduces what the buyer pays;
            `added-value` increases what the card is worth.
          enum: [discount, added-value]
          example: added-value
        amount_from:
          type: [number, "null"]
          description: |
            Lowest gift-card value the campaign accepts, as a decimal in the company's
            currency. `null` means no lower bound.
          example: 10
        amount_to:
          type: [number, "null"]
          description: |
            Highest gift-card value the campaign accepts, as a decimal in the company's
            currency. `null` means no upper bound.
          example: 100
        reduced_price:
          type: [number, "null"]
          description: |
            Discount as a percentage off the purchase price, or `null` on an added-value
            campaign. `20` means a DKK 100 gift card costs DKK 80.
          example: null
        added_value:
          type: [number, "null"]
          description: |
            Extra value as a percentage of the amount paid, or `null` on a discount
            campaign. `20` means DKK 100 buys a card worth DKK 120.
          example: 10

    # ── Events and reports ────────────────────────────────────────────────────

    Event:
      type: object
      description: One event of the acting company, with its ticket counts.
      properties:
        name:
          type: string
          description: The event's display name.
          example: Hotel Party nr.1
        slug:
          type: string
          description: |
            URL-safe identifier for the event. Pass it as `{slug}` to
            `GET /v2/events/{slug}/participants`.
          example: hotel-party-nr1
        private:
          type: boolean
          description: |
            Whether the event is private — reachable only by direct link rather than
            listed publicly.
          example: false
        online:
          type: boolean
          description: |
            Whether the event is currently visible in the order form and selling tickets.
          example: true
        venue:
          type: string
          description: Venue name. Empty when none was entered.
          example: London
        venue_address:
          type: string
          description: Venue street address. Empty when none was entered.
          example: 12 Bridge Street
        venue_postcode:
          type: string
          description: Venue postcode. Empty when none was entered.
          example: SE1 9BG
        date_activate:
          type: [string, "null"]
          description: |
            When the event goes online, as `YYYY-MM-DD HH:MM:SS`, or `null` when it is not
            scheduled to activate automatically.
          example: null
        date_deactivate:
          type: [string, "null"]
          description: |
            When the event goes offline, as `YYYY-MM-DD HH:MM:SS`, or `null` when it is
            not scheduled to deactivate automatically.
          example: null
        sold:
          type: integer
          description: Tickets sold and paid for.
          example: 435
        blocked:
          type: integer
          description: |
            Tickets currently held in baskets awaiting payment. They are unavailable to
            other buyers but not yet sold, so `sold + blocked` is what is actually taken.
          example: 0
        all:
          type: integer
          description: Total tickets the event was set up with, sold or not.
          example: 1773

    Participant:
      type: object
      description: |
        One buyer on an event's guest list. These rows carry personal data, which is why
        the endpoint requires `reports:read` rather than `items:read`.
      properties:
        sender_name:
          type: string
          description: Name of the person who bought the tickets.
          example: Jane Doe
        sender_email:
          type: string
          description: Buyer's email address.
          example: jane@example.com
        sender_phone:
          type: [string, "null"]
          description: Buyer's phone number, or `null` when none was collected.
          example: null
        sender_comment:
          type: [string, "null"]
          description: |
            Free-text note the buyer left at checkout — dietary requirements and the like
            — or `null` when none was given.
          example: null
        sender_code:
          type: [string, "null"]
          description: Buyer's reference number, or `null` when none was recorded.
          example: null
        ticket_variation:
          type: string
          description: |
            Ticket type, prefixed by the quantity when the buyer took more than one.
          example: 3 x Standard
        ticket_price:
          type: [number, "null"]
          description: |
            Price of one ticket, as a decimal in the company's currency, or `null` for a
            free ticket.
          example: 255
        created_at:
          type: string
          description: When the tickets were bought, as `YYYY-MM-DD HH:MM:SS`.
          example: "2026-01-03 20:15:10"
        payment_type:
          type: string
          description: |
            How the tickets were paid for, in the response language. For display only —
            the wording is not a stable contract.
          example: API created - Payment with invoice
        payment_OTH:
          type: [string, "null"]
          description: |
            Payment comment recorded for an invoice or on-the-house order, or `null` when
            there is none.
          example: null
        claimed_at:
          type: [string, "null"]
          description: |
            When the ticket was fully redeemed, as `YYYY-MM-DD HH:MM:SS`, or `null` if
            the guest has not been checked in.
          example: null
        remaining_vouchers:
          type: integer
          description: |
            How many of this row's tickets are still unredeemed. `ticket_variation`
            already carries the count as a prefix, so this is the same figure as a
            number. Always `1` for a ticket claimed against a credit.
          example: 1
        registered:
          type: integer
          description: |
            Whether the guest has been registered at the door, as `0` or `1`. Always `1`
            for a ticket claimed against a credit.
          example: 0

    ClaimedItem:
      type: object
      description: |
        One redemption, refund, or status transaction from the ledger. There is a row per
        transaction, not per item, so a partly redeemed card appears more than once.
      properties:
        transaction_id:
          type: integer
          description: |
            Unique id of this transaction. Use it to deduplicate when re-fetching an
            overlapping date range.
          example: 123465
        status:
          allOf:
            - $ref: "#/components/schemas/ItemStatusId"
          description: The status the item moved to in this transaction.
        status_name:
          type: string
          description: Status name, translated into the response language. For display only.
          example: Used
        date:
          type: string
          format: date
          description: Date of the transaction, as `YYYY-MM-DD`.
          example: "2026-01-12"
        time:
          type: string
          description: Time of the transaction, as `HH:MM:SS`, in the platform's timezone.
          example: "01:36:48"
        item_type:
          type: string
          description: Item type, translated into the response language. For display only.
          example: Gift Card Variation
        item_type_identification:
          type: string
          description: Item type, always in English. Use this one for logic and grouping.
          example: Gift Card Variation
        code:
          type: string
          description: Code of the item this transaction applies to.
          example: 77xrB3e44T
        amount:
          type: [number, "null"]
          description: |
            Value moved by this transaction, as a decimal in `currency`, or `null` for a
            transaction that carries no value.
          example: 100
        claim_note:
          type: [string, "null"]
          description: |
            The note supplied as `claim_note` when the item was redeemed, or `null` when
            none was given. This is the field to reconcile against your own bookings.
          example: Booking 55123
        claimed_by_username:
          type: [string, "null"]
          description: |
            Username of the person who performed the transaction, or `null` when it was
            done with an API key — a key is not a user. Redemptions made by staff in the
            admin UI carry their username.
          example: null
        claimed_by_company_name:
          type: [string, "null"]
          description: |
            Name of the company that performed the transaction, or `null` when it was not
            attributed to one.
          example: null
        currency:
          type: string
          description: ISO 4217 currency of `amount`, in upper case.
          example: DKK
        company_name:
          type: string
          description: Name of the company that issued the item.
          example: Acme Hotels

    Subscriber:
      type: object
      description: One confirmed newsletter signup collected through an order page.
      properties:
        id:
          type: string
          description: |
            Subscriber id. Always a string, and not always numeric: a signup collected
            through a v2 order carries an `m_` prefix (`m_412`), while a v1-era row is
            bare digits. Treat it as an opaque string and never parse it as a number.
          example: "m_412"
        company:
          type: string
          description: |
            Slug of the company whose order page the person signed up on. A root company
            sees its child companies' subscribers too, so this identifies which page each
            row came from.
          example: acme-hotels
        email:
          type: string
          description: The subscriber's email address.
          example: jane@example.com
        name:
          type: string
          description: The name the subscriber supplied. Empty when they gave none.
          example: Jane Doe

    PdfUploadIntentRequest:
      type: object
      additionalProperties: false
      description: |
        The file you are about to upload, declared up front so the bytes that arrive can
        be verified against it.
      required: [sha256, byte_size, mime]
      properties:
        language:
          type: string
          default: da-DK
          description: |
            BCP 47 locale this artwork belongs to, for example `da-DK` or `en-GB`.
            Templates are stored per locale. Defaults to `da-DK`.
          example: da-DK
        sha256:
          type: string
          pattern: '^[a-f0-9]{64}$'
          description: |
            SHA-256 of the exact bytes you will upload, as 64 lowercase hexadecimal
            characters. Checked at finalize; a mismatch is rejected and no version is
            created.
          example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
        byte_size:
          type: integer
          minimum: 1
          maximum: 8000000
          description: |
            Exact size of the file in bytes, at most 8,000,000. A larger declared size is
            rejected outright, and the size is checked again at finalize so a truncated
            upload is caught rather than published.
          example: 284915
        mime:
          type: string
          description: Content type of the file. Must be `application/pdf`.
          const: application/pdf
          example: application/pdf
      example:
        language: da-DK
        sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
        byte_size: 284915
        mime: application/pdf

    PdfUploadIntentEnvelope:
      type: object
      description: A short-lived upload target for one declared file.
      required: [upload_intent]
      properties:
        upload_intent:
          type: object
          description: The intent, and where to send the bytes.
          required: [id, expires_at, upload]
          properties:
            id:
              type: string
              pattern: '^pi_[a-f0-9]{32}$'
              description: |
                Upload intent id. Pass it to
                `POST /v2/pdf-templates/{template_id}/finalize` once the bytes are
                uploaded — finalize takes this id, not a template id.
              example: pi_5a3c81e0d74b426f90ac17b5e2d8630f
            expires_at:
              type: string
              format: date-time
              description: |
                ISO 8601 moment the intent stops accepting an upload, 15 minutes after
                creation. Past it, create a new intent rather than retrying this one.
              example: "2026-08-27T10:46:04+00:00"
            upload:
              type: object
              description: |
                Where and how to send the bytes. Use these values verbatim — the URL is a
                short-lived signed storage URL in a deployed environment and a route on
                this API only in local development, so never hardcode a path.
              required: [url, method, headers]
              properties:
                url:
                  type: string
                  description: |
                    The upload target. Absolute in a deployed environment; a path relative
                    to the API base URL in local development.
                  example: /v2/pdf-templates/upload-intents/pi_5a3c81e0d74b426f90ac17b5e2d8630f/content
                method:
                  type: string
                  description: HTTP method to use for the upload. Always `PUT`.
                  const: PUT
                  example: PUT
                headers:
                  type: object
                  description: |
                    Headers the upload must carry, **sent verbatim**. Copy the whole map
                    onto the request rather than picking fields out of it: against storage
                    these include `Content-Type`, `Content-Length`, and
                    `x-amz-checksum-sha256`, all of which are covered by the URL's
                    signature, so omitting or changing any one makes the upload fail the
                    signature check. The exact set differs between environments, which is
                    another reason not to hardcode it.
                  additionalProperties:
                    type: string
                  example:
                    Content-Type: application/pdf
                    Content-Length: "284915"
                    x-amz-checksum-sha256: n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=

    PdfTemplateEnvelope:
      type: object
      description: A single template version, wrapped in its object envelope.
      required: [template]
      properties:
        template:
          $ref: '#/components/schemas/PdfTemplate'

    PdfTemplate:
      type: object
      description: |
        One version of a company's gift-card PDF template, for one locale. Versions
        accumulate; publishing selects which one new orders render from.
      required: [id, language, source, status, version, bytes, sha256]
      properties:
        id:
          type: string
          pattern: '^pt_[a-f0-9]{32}$'
          description: |
            Template version id — `pt_` followed by 32 hexadecimal characters. Each
            upload produces a new id.
          example: pt_63c07e19a4b85d2f701ea9c6b3d4820f
        language:
          type: string
          description: BCP 47 locale this version applies to.
          example: en-GB
        source:
          type: string
          description: |
            `default` — generated by Lifepeaks from the company's brand profile.
            `partner` — uploaded by the partner.
          enum: [default, partner]
          example: partner
        status:
          type: string
          description: |
            `validated` — accepted by the upload checks but not live.
            `published` — currently used for new orders.
            `superseded` — replaced by a later publish, kept in the history.
            `uploading` — a finalize crashed part-way. Such a version can never be
            published and is collected automatically; it is omitted from
            `GET /v2/pdf-templates` and appears only when read by id.
          enum: [validated, published, superseded, uploading]
          example: published
        version:
          type: integer
          minimum: 1
          description: Sequence number within this company and locale, starting at `1`.
          example: 3
        bytes:
          type: integer
          minimum: 1
          description: Size of the stored PDF in bytes.
          example: 284915
        sha256:
          type: string
          pattern: '^[a-f0-9]{64}$'
          description: |
            SHA-256 checksum of the stored PDF, 64 lowercase hexadecimal characters.
            Compare it against your own copy to confirm which artwork is live.
          example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
        page_count:
          type: integer
          minimum: 1
          description: |
            Pages the validator counted when the version was finalized. At most 10; a
            longer document is rejected rather than stored.
          example: 1
        published_at:
          type: [string, "null"]
          format: date-time
          description: |
            ISO 8601 moment this version was published, or `null` for a version that
            never has been.
          example: "2026-08-21T13:47:02+00:00"
