# Brizz Partner API v1 — canonical contract (PARTNER_CODES_SPEC.md §16).
# This file is the source of truth for partner-facing docs (§16.10 decision 5);
# never a PDF. Additive changes only within v1; breaking changes mint v2.
#
# Scope: campaign + code-status reads, redemption reports, code minting and
# CSV/batch import (codes:write), and signed redemption webhooks. Membership
# sync (Door B) is the only later phase and is ADDED here when it ships.
openapi: 3.0.3
info:
  title: Brizz Partner API
  version: "1.0"
  description: |
    Partner access to campaigns, redemption reports, code status, code minting
    and import, and signed redemption webhooks. This is Models A and B; Model C
    (real-time verification) is a separate provider contract, because there
    Brizz calls YOUR endpoint — see `partner-verification-provider-v1.yaml`.

    **Authentication** — every request sends your API key as a Bearer token:
    `Authorization: Bearer brz_partner_<credential_id>.<secret>`. The key is
    shown exactly once at issuance and cannot be retrieved again. All failures
    (missing/wrong/revoked/expired key, missing scope) return the same generic
    401 — treat it as "re-check the key and its scopes".

    **Scoping** — you only ever see resources belonging to your own partner
    account. A resource that does not exist and a resource belonging to another
    partner return the same 404.

    **No PII** — no endpoint returns buyer identity. Report rows carry a
    Brizz-internal booking reference and event metadata only.

    **Code strings are bearer assets** — they are accepted in POST bodies only,
    never in URLs, so they cannot leak into proxy or access logs.

    **Rate limits** — pre-auth 60/min per IP; authenticated reads 300/min per
    key (600/min per partner); redemption reports 30/min; code-status 300/min.
    A 429 carries standard RateLimit headers; back off and retry.

    **Redemption webhooks (receiver contract)** — if Brizz configures a webhook
    endpoint for you (HTTPS only), each redemption of one of your codes POSTs a
    JSON event, at-least-once with bounded retries:

    ```
    POST <your endpoint>
    X-Brizz-Timestamp: <unix seconds>
    X-Brizz-Event-Id:  <uuid>
    X-Brizz-Signature: v1=<hex>[,v1=<hex>]

    {"event":"code.redeemed","event_id":"…","occurred_at":"…",
     "data":{"code":"…","campaign_uuid":"…","redeemed_at":"…","discount_eur":"…"}}
    ```

    Verify by computing HMAC-SHA256 over the string
    `"{timestamp}.{event_id}.{raw request body}"` with your signing secret and
    comparing (constant-time) against ANY `v1=` value in the header — two are
    present during a secret rotation window. Then (1) enforce a replay window
    on the timestamp (reject if older than ~5 minutes), and (2) deduplicate on
    `event_id` — retries redeliver the same event with the same id. Respond
    with any 2xx within 10 seconds; anything else is retried with exponential
    backoff (≈1m/5m/15m/1h/3h/12h) before the event is marked failed. Payloads
    never contain buyer identity.
servers:
  - url: https://api.brizz.me/api
    description: Production
paths:
  /partner/v1/campaigns:
    get:
      summary: List your campaigns
      description: >
        All campaigns for the authenticated partner, newest first, with derived
        status (ACTIVE / PAUSED / EXPIRED / EXHAUSTED), the deal parameters,
        and code counters. Requires scope `campaigns:read`.
      security: [{ bearerKey: [] }]
      responses:
        "200":
          description: The caller's campaigns.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [success] }
                  campaigns:
                    type: array
                    items: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /partner/v1/campaigns/{uuid}/redemptions:
    get:
      summary: Redemption report rows (cursor-paginated)
      description: >
        JSON view over the same authoritative report the CSV/email settlement
        uses — REDEEMED rows are the invoice authority; REFUNDED rows are shown
        for transparency and excluded from totals. Requires scope
        `redemptions:read`.

        `from`/`to` are REQUIRED and interpreted in UTC; datetimes carrying an
        explicit offset are normalized to their UTC instant (the response's
        `window` echoes the normalized values). Date-only values cover the
        whole UTC day (from 00:00:00Z to 23:59:59Z). The window may span at
        most 92 days — iterate windows for longer histories. Rows are ordered
        by (redeemed_at, id); follow `next_cursor` until it is null. Totals
        always cover the WHOLE window, independent of pagination.
      security: [{ bearerKey: [] }]
      parameters:
        - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: from, in: query, required: true, schema: { type: string, example: "2026-07-01" }, description: "ISO date or datetime, UTC, inclusive." }
        - { name: to, in: query, required: true, schema: { type: string, example: "2026-07-31" }, description: "ISO date or datetime, UTC, inclusive. Max 92 days after `from`." }
        - { name: cursor, in: query, required: false, schema: { type: string }, description: "Opaque cursor from the previous page's `next_cursor`." }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
      responses:
        "200":
          description: One page of report rows plus whole-window totals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [success] }
                  campaign_uuid: { type: string, format: uuid }
                  window:
                    type: object
                    properties:
                      from: { type: string, format: date-time }
                      to: { type: string, format: date-time }
                  rows:
                    type: array
                    items: { $ref: "#/components/schemas/RedemptionRow" }
                  next_cursor:
                    type: string
                    nullable: true
                    description: Pass as `cursor` for the next page; null on the last page.
                  totals: { $ref: "#/components/schemas/ReportTotals" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /partner/v1/codes/status:
    post:
      summary: Status of one of your codes
      description: >
        Returns the lifecycle status of a single code you own. Deliberately a
        POST with the code in the body — an unredeemed code is a redeemable
        bearer asset and must never appear in a URL. Requires scope
        `codes:read`. A code that does not exist and a code owned by another
        partner return the same 404.
      security: [{ bearerKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, maxLength: 40, example: "SCOPEX-4HUA7Q" }
      responses:
        "200":
          description: The code's status. No buyer identity is ever included.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [success] }
                  code:
                    type: object
                    properties:
                      status: { type: string, enum: [AVAILABLE, RESERVED, REDEEMED, REFUNDED] }
                      redeemed_at: { type: string, format: date-time, nullable: true }
                      campaign_uuid: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /partner/v1/campaigns/{uuid}/codes:
    post:
      summary: Mint codes into a campaign
      description: >
        Adds codes to one of your existing campaigns (campaigns themselves are
        created by Brizz, not the API). Requires scope `codes:write`. Provide
        EXACTLY ONE of:
          • `quantity` (+ optional `prefix`) — Brizz generates that many unique
            opaque codes; or
          • `codes` — your own codes to import, validated and deduplicated the
            same way as the admin CSV import (cross-checked against all Brizz
            codes). Per-code outcomes are returned in `skipped`.

        At most 5000 codes per request. An `Idempotency-Key` header (max 255
        chars) is REQUIRED (§16.2): retrying with the same key AND the same
        body returns the ORIGINAL result — the same `codes` and counts, with
        `idempotent_replay: true` — and mints nothing more. So if a response is
        lost to a timeout, simply retry with the same key to recover the minted
        codes. The same key with a DIFFERENT body is a 409; two identical
        concurrent requests mint exactly once. Idempotency memory is retained
        for ~30 days.

        Responses are `no-store`. The per-code `skipped` detail (reasons) is
        only in the first response; replays echo `skipped_count`.
      security: [{ bearerKey: [] }]
      parameters:
        - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, maxLength: 255 }
          description: Client-chosen unique key for this mint. Reuse to safely retry.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [quantity]
                  properties:
                    quantity: { type: integer, minimum: 1, maximum: 5000 }
                    prefix: { type: string, minLength: 2, maxLength: 12, pattern: "^[A-Za-z0-9]+$", default: "CODE" }
                - type: object
                  required: [codes]
                  properties:
                    codes:
                      type: array
                      minItems: 1
                      maxItems: 5000
                      items: { type: string, maxLength: 64 }
      responses:
        "200":
          description: >
            Mint result. `codes` always holds the created strings — on the first
            call and on idempotent replays (recovered server-side, so a lost
            response never strands minted codes). `skipped` (per-code reasons)
            appears on the first call only; replays carry `skipped_count`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [success] }
                  message: { type: string, enum: [CODES_MINTED] }
                  imported: { type: integer }
                  skipped:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string }
                        reason: { type: string, enum: [INVALID_FORMAT, DUPLICATE_IN_FILE, ALREADY_EXISTS] }
                  codes:
                    type: array
                    items: { type: string }
                    description: The minted code strings — present on first responses AND replays.
                  skipped_count: { type: integer, description: Present on idempotent replays in place of the full skipped list. }
                  idempotent_replay: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The Idempotency-Key was reused with a different body, or an identical request is still processing.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
components:
  securitySchemes:
    bearerKey:
      type: http
      scheme: bearer
      description: "brz_partner_<credential_id>.<secret> — issued once by Brizz."
  schemas:
    Campaign:
      type: object
      properties:
        uuid: { type: string, format: uuid }
        name: { type: string }
        status:
          type: string
          enum: [ACTIVE, PAUSED, EXPIRED, EXHAUSTED]
          description: Derived at read time; EXPIRED/EXHAUSTED are computed.
        discount_type: { type: string, enum: [PERCENTAGE, FIXED] }
        discount_value: { type: string, example: "5.00" }
        starts_at: { type: string, format: date-time, nullable: true }
        ends_at: { type: string, format: date-time, nullable: true }
        budget_cap_eur: { type: string, nullable: true, example: "500.00" }
        discount_given_eur: { type: string, example: "125.00" }
        max_redemptions: { type: integer, nullable: true }
        per_user_limit: { type: integer, nullable: true }
        codes:
          type: object
          properties:
            total: { type: integer }
            available: { type: integer }
            reserved: { type: integer }
            redeemed: { type: integer }
            refunded: { type: integer }
    RedemptionRow:
      type: object
      description: Identical shape to the CSV settlement report rows.
      properties:
        code: { type: string }
        status: { type: string, enum: [REDEEMED, REFUNDED] }
        redeemed_at: { type: string, format: date-time }
        event_title: { type: string }
        event_uuid: { type: string }
        order_subtotal_eur: { type: string, example: "20.00" }
        discount_eur: { type: string, example: "5.00" }
        booking_ref: { type: string, description: Brizz-internal cross-reference — not buyer identity. }
        authorization_ref:
          type: string
          description: >
            The partner's own opaque approval reference for a Model C
            (real-time) redemption; empty string for Models A and B. ADDED to
            v1 as an additive change: it is ALWAYS present now (including as ""),
            and the CSV gains a trailing `authorization_ref` column — so a
            positional CSV parser must account for the extra column. Advisory
            reconciliation metadata only; never an identity or an auth input.
    ReportTotals:
      type: object
      properties:
        redeemed_count: { type: integer }
        refunded_count: { type: integer }
        invoice_total_eur: { type: number, description: REDEEMED rows only (refunds excluded). }
        settlement:
          type: object
          properties:
            delivered_brutto_eur: { type: number }
            net_eur: { type: number }
            vat_rate: { type: number, example: 19.0 }
            vat_eur: { type: number }
            invoiced_total_eur: { type: number }
    RedemptionWebhookEvent:
      type: object
      description: >
        Body of a signed `code.redeemed` webhook — the only event type. Sent
        at-least-once with bounded retries; verify `X-Brizz-Signature` (one or
        more `v1=` HMAC-SHA256 digests over `{timestamp}.{event_id}.{raw_body}`,
        two during a secret rotation) and dedupe on `X-Brizz-Event-Id`. Respond
        2xx; anything else is retried.
      properties:
        code: { type: string }
        campaign_uuid: { type: string, format: uuid }
        redeemed_at: { type: string, format: date-time }
        discount_eur: { type: string, example: "5.00" }
        authorization_ref:
          type: string
          description: >
            Present ONLY for a Model C redemption whose partner returned a
            reference; OMITTED entirely otherwise, so Model A/B bodies stay
            byte-identical to the pre-authorization_ref contract.
    Error:
      type: object
      properties:
        status: { type: string, enum: [error] }
        message: { type: string }
  responses:
    Unauthorized:
      description: Missing/invalid/revoked/expired key, or missing scope — always the same generic body.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Nonexistent resource OR a resource owned by another partner — indistinguishable by design.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    UnprocessableEntity:
      description: Invalid parameters (bad dates, range over 92 days, malformed cursor, missing code).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    TooManyRequests:
      description: Rate limit exceeded; respect RateLimit-* / Retry-After headers.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
