openapi: 3.1.0
info:
  title: Mercury Pulsar — Operator Integration API
  version: "1.0.0"
  description: >
    The product-agnostic API any gaming operator integrates with to bring traffic,
    attribute conversions, stream retention events, and read its own analytics.


    There are four seams plus a read API:
      1. ACQUIRE   — GET /go              (Mercury → operator: a tracked redirect link)
      2. ATTRIBUTE — POST /track/postback (operator → Mercury: HMAC-signed conversion)
      3. RETAIN    — POST /engage/events  (operator → Mercury: HMAC-signed lifecycle events)
      4. READ      — GET /operators/v1/*  (operator → Mercury: Bearer-key analytics reads)


    AUTH. Write seams (postback, engage) are authenticated with a per-operator
    HMAC secret: send your public key id in `x-mercury-key` and the HMAC-SHA256
    (hex) of the RAW request body in `x-signature`. Read endpoints use the API key
    as a Bearer token: `Authorization: Bearer <keyId>.<secret>`.


    SANDBOX vs LIVE. Keys are prefixed `mk_test_` (sandbox) or `mk_live_` (live).
    A sandbox key writes test rows that are isolated from real reporting, and read
    endpoints return ONLY the dataset matching the key's mode (test key → test
    data, live key → live data). Get a sandbox key self-serve in the Quickstart
    (/dashboard/quickstart); live keys are issued by Mercury.


    RATE LIMITS. Endpoints are rate-limited per source IP. On a `429 Too Many
    Requests` the response carries a `Retry-After` header with the number of
    whole seconds to wait before retrying — back off for that long rather than
    retrying immediately.


    IDEMPOTENCY. /track/postback is idempotent by `externalId` — retrying the same
    externalId is a safe no-op that returns the original conversion.
  contact:
    name: Mercury Pulsar
servers:
  - url: https://app.mercurypulsar.com
    description: Your Mercury base URL (replace with the host Mercury gives you)
  - url: http://localhost:3000
    description: Local dev
tags:
  - name: Acquire
  - name: Attribute
  - name: Retain
  - name: Read
  - name: Partner
    description: Partner (affiliate/agent/influencer) read API — a partner reads its own links/earnings/statements with its own Bearer key.
paths:
  /go:
    get:
      tags: [Acquire]
      summary: Tracked acquisition redirect (Seam 1)
      description: >
        Records a click and 302-redirects the visitor to the route's destination
        brand entry URL. State-gated per market. Public (no key) — the link itself
        is the credential. Persist the `click_id` you receive at signup so you can
        attribute the conversion back via /track/postback.
      parameters:
        - { name: r, in: query, required: true, schema: { type: string }, description: Route key }
        - { name: aff, in: query, required: false, schema: { type: string }, description: Affiliate/partner code }
        - { name: code, in: query, required: false, schema: { type: string }, description: Promo/influencer code }
        - { name: ref, in: query, required: false, schema: { type: string }, description: Player referral code (viral loop) }
      responses:
        "302": { description: Redirect to the destination brand }
        "403": { description: Blocked in the visitor's market/state }
        "404": { description: Unknown route key }
  /track/postback:
    post:
      tags: [Attribute]
      summary: Report a conversion (Seam 2)
      description: >
        HMAC-signed. Send `x-mercury-key` (your key id) and `x-signature`
        (HMAC-SHA256 hex of the raw body). Idempotent by `externalId`. Attribute by
        `clickId` (link path) and/or `promoCode` (influencer code path). A sandbox
        key tags the conversion as test data.
      security:
        - mercuryKey: []
          mercurySignature: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PostbackRequest" }
      responses:
        "200":
          description: Recorded (or duplicate no-op)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PostbackResponse" }
        "400": { description: Missing externalId and (clickId or promoCode), or invalid JSON }
        "401": { description: Invalid signature }
        "404": { description: Unattributable (unknown click / no resolvable code) }
        "429": { description: Rate limited }
  /engage/events:
    post:
      tags: [Retain]
      summary: Stream lifecycle events (Seam 3)
      description: >
        HMAC-signed (same headers as /track/postback). Accepts a single event or an
        array (batch). Folds each into the CDP and fires retention journeys. Events
        from a sandbox key never fire real messages.
      security:
        - mercuryKey: []
          mercurySignature: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/EngageEvent"
                - type: array
                  items: { $ref: "#/components/schemas/EngageEvent" }
      responses:
        "200":
          description: Processed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EngageResponse" }
        "400": { description: Invalid JSON }
        "401": { description: Invalid signature }
        "429": { description: Rate limited }
  /operators/v1/me:
    get:
      tags: [Read]
      summary: Identify the calling key
      description: Verifies the Bearer key and returns the resolved account + key mode. The "does my key work?" call.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { description: Missing/invalid Bearer key }
        "429": { description: Rate limited }
  /operators/v1/report:
    get:
      tags: [Read]
      summary: KPI summary for your account
      description: Clicks, conversions, value, conversion rate, and a by-type breakdown — scoped to your account, in the key's dataset (test vs live).
      security:
        - bearerAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Report" }
        "401": { description: Missing/invalid Bearer key }
        "429": { description: Rate limited }
  /operators/v1/conversions:
    get:
      tags: [Read]
      summary: Your conversions
      security:
        - bearerAuth: []
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: type, in: query, required: false, schema: { type: string, enum: [signup, ftd, deposit, lead] } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  mode: { type: string, enum: [sandbox, live] }
                  count: { type: integer }
                  conversions:
                    type: array
                    items: { $ref: "#/components/schemas/ConversionRow" }
        "401": { description: Missing/invalid Bearer key }
        "429": { description: Rate limited }
  /operators/v1/clicks:
    get:
      tags: [Read]
      summary: Your clicks
      security:
        - bearerAuth: []
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  mode: { type: string, enum: [sandbox, live] }
                  count: { type: integer }
                  clicks:
                    type: array
                    items: { $ref: "#/components/schemas/ClickRow" }
        "401": { description: Missing/invalid Bearer key }
        "429": { description: Rate limited }
  /partners/v1/me:
    get:
      tags: [Partner]
      summary: Identify the calling partner key
      description: Verifies a partner Bearer key and returns the partner's identity + tracked-link param. Partner (affiliate/agent/influencer) keys are issued by Mercury staff.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  partnerId: { oneOf: [{ type: integer }, { type: string }] }
                  code: { type: string }
                  name: { type: string }
                  kind: { type: string, enum: [affiliate, agent, influencer] }
                  mode: { type: string, enum: [sandbox, live] }
                  trackedLinkParam: { type: string, description: "Append to /go, e.g. aff=<code>" }
        "401": { description: Missing/invalid Bearer key }
        "429": { description: Rate limited }
  /partners/v1/summary:
    get:
      tags: [Partner]
      summary: The partner's own earnings summary
      description: Commission owed (affiliate/agent) or payout-by-model (influencer) — the same numbers the magic-link /partner/{code} page shows. `summary` may be null if nothing has accrued.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  kind: { type: string }
                  summary: { type: [object, "null"], additionalProperties: true }
        "401": { description: Missing/invalid Bearer key }
        "429": { description: Rate limited }
  /partners/v1/statements:
    get:
      tags: [Partner]
      summary: The partner's payout statements
      description: Frozen per-period payout statements (period / owed / status / paid), newest first.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  count: { type: integer }
                  statements:
                    type: array
                    items: { $ref: "#/components/schemas/StatementRow" }
        "401": { description: Missing/invalid Bearer key }
        "429": { description: Rate limited }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Authorization: Bearer <keyId>.<secret> — an operator key (operators/v1/*) or a partner key (partners/v1/*); the server resolves which from the key id."
    mercuryKey:
      type: apiKey
      in: header
      name: x-mercury-key
      description: Your public key id (the part before the dot in the API key).
    mercurySignature:
      type: apiKey
      in: header
      name: x-signature
      description: HMAC-SHA256 (hex) of the raw request body, keyed with your HMAC secret.
  schemas:
    PostbackRequest:
      type: object
      required: [externalId]
      properties:
        externalId: { type: string, maxLength: 128, description: Your unique id for this conversion (idempotency key). }
        clickId: { type: string, description: The click_id Mercury passed to your signup URL. }
        promoCode: { type: string, maxLength: 128, description: Promo/influencer code used at signup (alt. attribution path). }
        couponCode: { type: string, maxLength: 128, description: Alias for promoCode (promoCode is preferred); used if promoCode is absent. }
        type: { type: string, enum: [signup, ftd, deposit, lead], default: lead }
        value: { type: number, minimum: 0, description: Conversion value (clamped to >= 0). }
        currency: { type: string, default: USD }
        provider: { type: string }
        playerId: { type: string, maxLength: 128, description: Your player id (creates/updates a shadow player for attribution). }
        contact: { type: string, maxLength: 256, description: Email/handle for retention messaging (optional). }
    PostbackResponse:
      type: object
      properties:
        ok: { type: boolean }
        id: { oneOf: [{ type: integer }, { type: string }] }
        duplicate: { type: boolean, description: true if this externalId was already recorded. }
    EngageEvent:
      type: object
      required: [brandSiteId, contactId, type]
      properties:
        brandSiteId: { oneOf: [{ type: integer }, { type: string }], description: Your Mercury tenant (site) id. }
        contactId: { type: string, description: Your own player/lead/customer id. }
        type:
          type: string
          enum: [signup, login, session, identify, verify, pageview, lead, subscribe, unsubscribe, purchase, refund, cancel, bet, spin, win, redeem, deposit]
        at: { type: string, format: date-time, description: ISO timestamp (defaults to now). }
        email: { type: string }
        amount: { type: number, description: Value for purchase/refund/redeem/deposit (whole currency units). }
        currency: { type: string }
        attributes: { type: object, additionalProperties: true, description: Vertical-specific data merged into the contact. }
        meta: { type: object, additionalProperties: true }
    EngageResponse:
      type: object
      properties:
        ok: { type: boolean }
        processed: { type: integer }
        errors: { type: array, items: { type: string } }
    Me:
      type: object
      properties:
        ok: { type: boolean }
        accountId: { oneOf: [{ type: integer }, { type: string }] }
        mode: { type: string, enum: [sandbox, live] }
    Report:
      type: object
      properties:
        ok: { type: boolean }
        mode: { type: string, enum: [sandbox, live] }
        totals:
          type: object
          properties:
            clicks: { type: integer }
            conversions: { type: integer }
            value: { type: number }
            conversionRatePct: { type: number }
        byType:
          type: array
          items:
            type: object
            properties:
              type: { type: string }
              count: { type: integer }
              value: { type: number }
    ConversionRow:
      type: object
      properties:
        externalId: { type: string }
        type: { type: string }
        value: { type: number }
        currency: { type: string }
        source: { type: [string, "null"] }
        destination: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
    ClickRow:
      type: object
      properties:
        clickId: { type: string }
        source: { type: [string, "null"] }
        destination: { type: [string, "null"] }
        state: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
    StatementRow:
      type: object
      properties:
        statementKey: { type: string }
        periodStart: { type: string, format: date-time }
        periodEnd: { type: string, format: date-time }
        currency: { type: string }
        payable: { type: number }
        status: { type: string, enum: [draft, approved, paid, void] }
        heldBelowMin: { type: boolean }
        paidAt: { type: [string, "null"], format: date-time }
    Error:
      type: object
      properties:
        error: { type: string }
