openapi: 3.1.0
info:
  title: Grona Public API
  version: 1.0.0
  summary: Create, launch and manage Grona campaigns from your own systems.
  description: |
    The Grona Public API v1 lets your backend create website campaigns, open them in the
    Grona visual editor, set them live, check that the Grona snippet is installed, and fetch
    grants for the mobile SDKs.

    The API is available on request. Email [support@grona.ai](mailto:support@grona.ai) to get access.

    - **Base URL:** `https://api.grona.ai`. Every path starts with `/v1`.
    - **Authentication:** a workspace secret key, sent as `Authorization: Bearer grona_sk_<keyId>_<secret>`
      or as `X-API-Key: grona_sk_<keyId>_<secret>`. Call the API from your servers only.
      See [Authentication](/api/authentication/).
    - **Workspace:** resolved from the key. It never appears in a URL or a request body.
    - **Envelope:** success is `{ "data": ..., "meta": { "workspaceId": ... } }` and failure is
      `{ "error": { "code": ..., "message": ... } }`. A `429` or `503` body may not use the
      envelope. See [Errors](/api/errors/).
    - **Compatibility:** v1 can gain fields, endpoints, optional parameters and enum values,
      so ignore anything you do not recognise. See [Versioning](/api/versioning/).

    The endpoints under **Mobile SDK endpoints** are called by the Grona Android and
    iOS SDKs, not by your code. They are listed so you can allow the traffic and understand
    what the SDKs send.
  contact:
    name: Grona support
    email: support@grona.ai
    url: https://grona.ai
  license:
    name: Proprietary
    url: https://www.grona.ai/terms
servers:
  - url: https://api.grona.ai
    description: Production
security:
  - secretKeyBearer: []
  - secretKeyHeader: []
tags:
  - name: Campaigns
    description: |
      Create, read, update, set live, pause and delete website campaigns.
      For the end-to-end flow, see [Create campaigns from your platform](/api/create-campaigns/).
  - name: Editor links
    description: |
      Editor links open a variation in the Grona visual editor. Each link carries a token that
      expires 15 minutes after it is issued. See [Visual editor links](/api/visual-editor-links/).
  - name: Websites
    description: |
      Check from your backend whether the Grona snippet is on a page.
      See [Verify installations](/api/verify-installations/).
  - name: Mobile grants
    description: |
      Your backend fetches signed grants for an identified shopper and hands them to the
      app. See [Mobile grants API](/api/mobile-grants/).
  - name: Mobile SDK endpoints
    description: |
      The Grona Android and iOS SDKs call these endpoints themselves. Your code does not
      call them, and they take no secret key.
paths:
  /v1/campaigns:
    post:
      tags: [Campaigns]
      operationId: create-campaign
      summary: Create a campaign
      description: |
        Creates a campaign and its variations in one call. A `general` campaign gets one
        variation. An `abTest` campaign gets two, Control and Test, in that order.

        - New campaigns start `paused`. Nothing reaches visitors until you set the campaign `live`.
        - The response is the only place that returns an editor link for every variation at once.
          Each link expires 15 minutes after `generatedAt`.
        - The snippet check on the campaign's website runs in the background, so `script.status`
          starts as `checking`.
        - Send `externalId` to make retries safe: a repeated create returns `409 conflict`
          instead of a second campaign.

        `trafficSplit` is keyed by `Control` and `Test` here, because the variation ids do not
        exist until this call returns. Everywhere else it is keyed by variation id.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCampaignRequest'
            examples:
              generalOnePage:
                summary: General campaign on one page
                value:
                  name: Free delivery bar on the home page
                  url: https://shop.example.com/
                  externalId: CMP-1024
              withPageVariable:
                summary: General campaign for one page variable value
                value:
                  name: Free delivery bar for returning buyers
                  url: https://shop.example.com/
                  segmentId: audience_id
                  segmentValue: returning_buyer
              abTestManyPages:
                summary: A/B test across dairy and bakery pages (multi-page targeting)
                value:
                  name: Breakfast banner on dairy and bakery pages
                  url: https://shop.example.com/dairy/whole-milk
                  type: abTest
                  pageTargeting: multiple
                  slug: dairy
                  condition: included
                  list: [dairy, bakery]
                  trafficSplit:
                    Control: 60
                    Test: 40
      responses:
        '201':
          description: Created. The campaign, with an editor link for each variation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedCampaignEnvelope'
              examples:
                generalOnePage:
                  summary: General campaign on one page
                  value:
                    data:
                      id: 671a3c90e4b0a1f2c3d40011
                      externalId: CMP-1024
                      name: Free delivery bar on the home page
                      status: paused
                      domain: https://shop.example.com
                      script:
                        status: checking
                        checkedAt: null
                      createdAt: '2026-10-20T09:30:12.418Z'
                      updatedAt: '2026-10-20T09:30:12.702Z'
                      type: general
                      pageTargeting: specific
                      segmentId: null
                      segmentValue: null
                      variations:
                        - id: 671a3c90e4b0a1f2c3d40012
                          name: Free delivery bar on the home page - Variation
                          type: variant
                          previewUrl: https://shop.example.com/?gronaPreview=671a3c90e4b0a1f2c3d40012
                          editUrl: https://api.grona.ai/v1/editor/launch?token=eyJhbGciOi...
                          generatedAt: '2026-10-20T09:30:12.731Z'
                          expiresAt: '2026-10-20T09:45:12.731Z'
                    meta:
                      workspaceId: 65e8a1c47d2f9b03e6a15c42
                abTestManyPages:
                  summary: A/B test across dairy and bakery pages (multi-page targeting)
                  value:
                    data:
                      id: 671a3d52e4b0a1f2c3d40021
                      externalId: null
                      name: Breakfast banner on dairy and bakery pages
                      status: paused
                      domain: https://shop.example.com
                      script:
                        status: checking
                        checkedAt: null
                      createdAt: '2026-10-20T10:02:44.105Z'
                      updatedAt: '2026-10-20T10:02:44.391Z'
                      type: abTest
                      pageTargeting: multiple
                      segmentId: null
                      segmentValue: null
                      url: https://shop.example.com/dairy/whole-milk
                      slug: dairy
                      condition: included
                      list: [dairy, bakery]
                      trafficSplit:
                        671a3d52e4b0a1f2c3d40022: 60
                        671a3d52e4b0a1f2c3d40023: 40
                      variations:
                        - id: 671a3d52e4b0a1f2c3d40022
                          name: Control
                          type: control
                          previewUrl: https://shop.example.com/dairy/whole-milk?gronaPreview=671a3d52e4b0a1f2c3d40022
                          editUrl: https://api.grona.ai/v1/editor/launch?token=eyJhbGciOi...
                          generatedAt: '2026-10-20T10:02:44.420Z'
                          expiresAt: '2026-10-20T10:17:44.420Z'
                        - id: 671a3d52e4b0a1f2c3d40023
                          name: Test
                          type: variant
                          previewUrl: https://shop.example.com/dairy/whole-milk?gronaPreview=671a3d52e4b0a1f2c3d40023
                          editUrl: https://api.grona.ai/v1/editor/launch?token=eyJhbGciOi...
                          generatedAt: '2026-10-20T10:02:44.426Z'
                          expiresAt: '2026-10-20T10:17:44.426Z'
                    meta:
                      workspaceId: 65e8a1c47d2f9b03e6a15c42
        '400':
          $ref: '#/components/responses/BadRequestCreate'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
    get:
      tags: [Campaigns]
      operationId: list-campaigns
      summary: List campaigns
      description: |
        Returns every campaign in the workspace. v1 has no pagination: the whole list comes back
        in one response, and `meta.count` is the number of campaigns returned. The field is
        reserved so pagination can be added later without breaking clients.

        The order of the list is not specified. Sort on your side if you need an order.
        Variations are listed without editor links; request one with `create-edit-link`.
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignListEnvelope'
              example:
                data:
                  - id: 671a3c90e4b0a1f2c3d40011
                    externalId: CMP-1024
                    name: Free delivery bar on the home page
                    status: live
                    domain: https://shop.example.com
                    script:
                      status: present
                      checkedAt: '2026-10-20T09:30:19.884Z'
                    createdAt: '2026-10-20T09:30:12.418Z'
                    updatedAt: '2026-10-20T11:05:37.290Z'
                    type: general
                    pageTargeting: specific
                    segmentId: audience_id
                    segmentValue: returning_buyer
                    variations:
                      - id: 671a3c90e4b0a1f2c3d40012
                        name: Free delivery bar on the home page - Variation
                        type: variant
                        previewUrl: https://shop.example.com/?gronaPreview=671a3c90e4b0a1f2c3d40012
                  - id: 671a3d52e4b0a1f2c3d40021
                    externalId: null
                    name: Breakfast banner on dairy and bakery pages
                    status: paused
                    domain: https://shop.example.com
                    script:
                      status: present
                      checkedAt: '2026-10-20T10:02:51.016Z'
                    createdAt: '2026-10-20T10:02:44.105Z'
                    updatedAt: '2026-10-20T10:02:44.391Z'
                    type: abTest
                    pageTargeting: multiple
                    segmentId: null
                    segmentValue: null
                    url: https://shop.example.com/dairy/whole-milk
                    slug: dairy
                    condition: included
                    list: [dairy, bakery]
                    trafficSplit:
                      671a3d52e4b0a1f2c3d40022: 60
                      671a3d52e4b0a1f2c3d40023: 40
                    variations:
                      - id: 671a3d52e4b0a1f2c3d40022
                        name: Control
                        type: control
                        previewUrl: https://shop.example.com/dairy/whole-milk?gronaPreview=671a3d52e4b0a1f2c3d40022
                      - id: 671a3d52e4b0a1f2c3d40023
                        name: Test
                        type: variant
                        previewUrl: https://shop.example.com/dairy/whole-milk?gronaPreview=671a3d52e4b0a1f2c3d40023
                meta:
                  workspaceId: 65e8a1c47d2f9b03e6a15c42
                  count: 2
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/campaigns/{campaignId}:
    parameters:
      - $ref: '#/components/parameters/CampaignId'
    get:
      tags: [Campaigns]
      operationId: get-campaign
      summary: Get a campaign
      description: |
        Returns one campaign, looked up by Grona's id or by your `externalId`. The variations
        are listed without editor links.
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignEnvelope'
              example:
                data:
                  id: 671a3c90e4b0a1f2c3d40011
                  externalId: CMP-1024
                  name: Free delivery bar on the home page
                  status: paused
                  domain: https://shop.example.com
                  script:
                    status: present
                    checkedAt: '2026-10-20T09:30:19.884Z'
                  createdAt: '2026-10-20T09:30:12.418Z'
                  updatedAt: '2026-10-20T09:30:12.702Z'
                  type: general
                  pageTargeting: specific
                  segmentId: null
                  segmentValue: null
                  variations:
                    - id: 671a3c90e4b0a1f2c3d40012
                      name: Free delivery bar on the home page - Variation
                      type: variant
                      previewUrl: https://shop.example.com/?gronaPreview=671a3c90e4b0a1f2c3d40012
                meta:
                  workspaceId: 65e8a1c47d2f9b03e6a15c42
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
    patch:
      tags: [Campaigns]
      operationId: update-campaign
      summary: Update a campaign
      description: |
        Send only the fields you want to change. At least one field is required. The response
        is the whole campaign after the change, including its variations.

        A campaign's `url`, `type` and `pageTargeting` are fixed when it is created. The call
        returns `400 invalid_request` and applies nothing when it:

        - sends `url`, `type` or `pageTargeting`;
        - sends `slug`, `condition` or `list` for a `specific` campaign;
        - sends `trafficSplit` for a `general` campaign, or a split that leaves out a variation
          or does not total 100;
        - sends only one of `segmentId` and `segmentValue` for a campaign that has neither.

        For a campaign that already has a page variable, sending one of the pair keeps the other.
        v1 cannot remove page-variable targeting from a campaign; create a new campaign instead.

        If you retry an update, address the campaign by Grona's `id`, not by `externalId`. An
        update can change or clear the `externalId`, and another campaign can then take it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateCampaignRequest'
            examples:
              pageVariableAndStatus:
                summary: Add a page variable and set the campaign live
                value:
                  segmentId: audience_id
                  segmentValue: returning_buyer
                  status: live
              splitAndList:
                summary: Change the split and the page list of a multi-page A/B test
                value:
                  trafficSplit:
                    671a3d52e4b0a1f2c3d40022: 50
                    671a3d52e4b0a1f2c3d40023: 50
                  list: [dairy, bakery, cereal]
      responses:
        '200':
          description: The whole campaign after the update.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignEnvelope'
              example:
                data:
                  id: 671a3c90e4b0a1f2c3d40011
                  externalId: CMP-1024
                  name: Free delivery bar on the home page
                  status: live
                  domain: https://shop.example.com
                  script:
                    status: present
                    checkedAt: '2026-10-20T09:30:19.884Z'
                  createdAt: '2026-10-20T09:30:12.418Z'
                  updatedAt: '2026-10-20T11:05:37.290Z'
                  type: general
                  pageTargeting: specific
                  segmentId: audience_id
                  segmentValue: returning_buyer
                  variations:
                    - id: 671a3c90e4b0a1f2c3d40012
                      name: Free delivery bar on the home page - Variation
                      type: variant
                      previewUrl: https://shop.example.com/?gronaPreview=671a3c90e4b0a1f2c3d40012
                meta:
                  workspaceId: 65e8a1c47d2f9b03e6a15c42
        '400':
          $ref: '#/components/responses/BadRequestUpdate'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
    delete:
      tags: [Campaigns]
      operationId: delete-campaign
      summary: Delete a campaign
      description: |
        Deletes the campaign and the variations created with it. Deletion is permanent and frees
        the campaign's `externalId` for reuse. `data.id` is always Grona's id, even when you call
        it with your `externalId`.

        If you retry a delete, address the campaign by Grona's `id`, never by `externalId`. Once
        the first attempt frees the `externalId`, a retry could delete a new campaign that took it.

        Grona then removes the campaign from your site script. Visitors can still see it until
        the refreshed script reaches them, so the change is not instant.
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeletedCampaignEnvelope'
              example:
                data:
                  id: 671a3c90e4b0a1f2c3d40011
                  deletedVariationIds: [671a3c90e4b0a1f2c3d40012]
                meta:
                  workspaceId: 65e8a1c47d2f9b03e6a15c42
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/campaigns/{campaignId}/status:
    parameters:
      - $ref: '#/components/parameters/CampaignId'
    patch:
      tags: [Campaigns]
      operationId: set-campaign-status
      summary: Set a campaign live or paused
      description: |
        `live` runs the campaign and `paused` stops it. Any other value returns `400`.

        Grona rebuilds your site script after the change. Visitors see the new state once the
        refreshed script reaches them, so the change is not instant in either direction.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StatusRequest'
            example:
              status: live
      responses:
        '200':
          description: The status after the change.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatusEnvelope'
              example:
                data:
                  status: live
                meta:
                  workspaceId: 65e8a1c47d2f9b03e6a15c42
        '400':
          $ref: '#/components/responses/BadRequestStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/variations/edit-link:
    post:
      tags: [Editor links]
      operationId: create-edit-link
      summary: Create an editor link
      description: |
        Returns a new editor link for a variation. The token in the link expires 15 minutes after
        `generatedAt`. `generatedAt` and `expiresAt` describe `editUrl` only and are read from the
        token itself, so they always agree with the link.

        The token travels in the query string. Open the link right away and do not store, log or
        forward it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EditLinkRequest'
            example:
              variationId: 671a3c90e4b0a1f2c3d40012
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EditLinkEnvelope'
              example:
                data:
                  editUrl: https://api.grona.ai/v1/editor/launch?token=eyJhbGciOi...
                  variationId: 671a3c90e4b0a1f2c3d40012
                  previewUrl: https://shop.example.com/?gronaPreview=671a3c90e4b0a1f2c3d40012
                  generatedAt: '2026-10-20T11:12:05.614Z'
                  expiresAt: '2026-10-20T11:27:05.614Z'
                meta:
                  workspaceId: 65e8a1c47d2f9b03e6a15c42
        '400':
          $ref: '#/components/responses/BadRequestVariation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/VariationNotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/variations/edit-link/validate:
    post:
      tags: [Editor links]
      operationId: validate-edit-link
      summary: Check an editor link and refresh it if expired
      description: |
        Send an editor link you are holding. If it has not expired, it comes back unchanged with
        its original `generatedAt` and `expiresAt`. If it has expired, the response carries a new
        link with a full 15-minute lifetime. Either way the status is `200`, and `data.editUrl` is
        the link to open, so you do not need to branch on `valid`.

        A value that is not a Grona editor link returns `400`. A link for a variation that no
        longer exists returns `404`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EditLinkValidateRequest'
            example:
              editUrl: https://api.grona.ai/v1/editor/launch?token=eyJhbGciOi...
      responses:
        '200':
          description: The link to open, either the one you sent or a new one.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EditLinkValidateEnvelope'
              examples:
                stillValid:
                  summary: Not expired, returned unchanged
                  value:
                    data:
                      valid: true
                      refreshed: false
                      editUrl: https://api.grona.ai/v1/editor/launch?token=eyJhbGciOi...
                      variationId: 671a3c90e4b0a1f2c3d40012
                      generatedAt: '2026-10-20T11:12:05.614Z'
                      expiresAt: '2026-10-20T11:27:05.614Z'
                    meta:
                      workspaceId: 65e8a1c47d2f9b03e6a15c42
                expired:
                  summary: Expired, replaced with a new link
                  value:
                    data:
                      valid: false
                      refreshed: true
                      editUrl: https://api.grona.ai/v1/editor/launch?token=eyJhbGciOi...
                      variationId: 671a3c90e4b0a1f2c3d40012
                      generatedAt: '2026-10-20T13:41:09.327Z'
                      expiresAt: '2026-10-20T13:56:09.327Z'
                    meta:
                      workspaceId: 65e8a1c47d2f9b03e6a15c42
        '400':
          $ref: '#/components/responses/BadRequestEditUrl'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/VariationNotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/websites/verify-script:
    post:
      tags: [Websites]
      operationId: verify-script
      summary: Check whether the Grona snippet is on a page
      description: |
        Loads the page at `url` and looks for your workspace's Grona snippet on it. The call
        waits for the check, so the result is always `present` or `absent`, never `checking`
        or `unknown`. `absent` also covers a page Grona could not load.

        The check covers the one page you send, not the whole site. For how Grona matches a URL
        to your website, and for the cases that read `absent` even though the snippet is
        installed, see [Verify installations](/api/verify-installations/).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerifyScriptRequest'
            example:
              url: https://shop.example.com/
      responses:
        '200':
          description: The result of the check.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerifyScriptEnvelope'
              examples:
                present:
                  summary: Snippet found
                  value:
                    data:
                      url: https://shop.example.com/
                      script:
                        status: present
                        checkedAt: '2026-10-20T09:28:43.162Z'
                    meta:
                      workspaceId: 65e8a1c47d2f9b03e6a15c42
                absent:
                  summary: Snippet not found, or the page could not be loaded
                  value:
                    data:
                      url: https://shop.example.com/recipes/
                      script:
                        status: absent
                        checkedAt: '2026-10-20T09:29:05.471Z'
                    meta:
                      workspaceId: 65e8a1c47d2f9b03e6a15c42
        '400':
          $ref: '#/components/responses/BadRequestUrl'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/mobile/grants:
    post:
      tags: [Mobile grants]
      operationId: issue-grants
      summary: Fetch grants for a shopper
      description: |
        Returns the currently valid grants for one identified shopper. Call it from your
        backend, with your secret key, inside the shopper's authenticated session, then hand the
        grants to the app. Take `customerId` from that session, never from a value the app sends.

        Each grant is a compact JWS signed by Grona. It expires at most 24 hours after it is
        issued, so do not cache grants past their `exp` or share them between shoppers. An empty
        list means no grant applies to this shopper right now.

        Repeat calls for the same customer return that customer's currently valid grants. Grona
        does not mint a new grant for a campaign while an unexpired one exists for that customer,
        so retries and refetches cannot multiply the grants a shopper can redeem. The call is
        safe to retry.

        Grona has no endpoint to redeem a grant. Your order backend verifies each grant itself
        at order time and enforces single use per grant `jti`. If an offer must apply once per
        customer rather than once per grant, your order backend enforces that too. See
        [Mobile grants API](/api/mobile-grants/).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GrantRequest'
            example:
              customerId: cus_7Hq2Lm9x
      responses:
        '200':
          description: The shopper's grants. Empty when none apply.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GrantEnvelope'
              example:
                data:
                  grants:
                    - eyJhbGciOiJFUzI1NiIs...
                meta:
                  workspaceId: 65e8a1c47d2f9b03e6a15c42
        '400':
          $ref: '#/components/responses/BadRequestGrant'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/mobile/workspaces/{workspaceKey}/config:
    parameters:
      - $ref: '#/components/parameters/WorkspaceKey'
    get:
      tags: [Mobile SDK endpoints]
      operationId: get-app-config
      summary: Get the signed app config
      description: |
        Called by the Grona SDK, not by your code.

        The config is public: the workspace key ships inside the app, and the config carries no
        personal data and no identifiers. The body is a JWS in flattened JSON serialization,
        signed with ES256, with the protected header exactly
        `{"alg":"ES256","kid":"...","typ":"grona-config+jws"}`. The envelope is at most 768 KB
        and the decoded payload at most 512 KB.

        How the SDK handles the response:

        - `200`: verifies the signature and applies the config.
        - `304`: keeps its current copy. This does not extend the config's `expires_at`.
        - `429`, `5xx` and network errors: backs off with jitter and honours `Retry-After`, waiting
          at most one hour.
        - Redirects and any other `4xx`: keeps the last good config.
      security: []
      x-codeSamples:
        - lang: sh
          label: curl
          source: |
            # The Grona SDK makes this call. Your code does not.
            curl https://api.grona.ai/v1/mobile/workspaces/acme-market/config \
              -H 'Accept: application/jose+json'
      parameters:
        - name: If-None-Match
          in: header
          required: false
          description: The `ETag` of the copy the SDK holds. Not sent for the copy bundled with the app.
          schema:
            type: string
      responses:
        '200':
          description: The signed config.
          headers:
            ETag:
              description: Entity tag for the next conditional request.
              schema:
                type: string
          content:
            application/jose+json:
              schema:
                $ref: '#/components/schemas/SignedConfigEnvelope'
        '304':
          description: Not modified. The SDK keeps its copy, and its `expires_at` does not move.
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '4XX':
          description: The SDK keeps its last good config.
        '5XX':
          description: The SDK keeps its last good config and retries with backoff.
  /v1/mobile/events:
    post:
      tags: [Mobile SDK endpoints]
      operationId: send-events
      summary: Send an event batch
      description: |
        Called by the Grona SDK, not by your code.

        The body is gzip-compressed JSON (`Content-Encoding: gzip`) with at most 50 events and
        at most 64 KB before compression. The workspace key travels in the body. Grona
        deduplicates events on `event_id` and corrects device clock skew using the time it
        receives the batch minus `sent_at`.

        How the SDK handles the response:

        - Any `2xx`: removes the batch from its queue. The response body is ignored.
        - `429` and `503`: honours `Retry-After`.
        - Any other `4xx`: drops the batch.
        - Other failures: retries at most 3 times with exponential backoff and jitter.
      security: []
      x-codeSamples:
        - lang: sh
          label: curl
          source: |
            # The Grona SDK makes this call. Your code does not.
            # The body is gzip-compressed JSON, so compress it before you send it.
            gzip -c batch.json | curl https://api.grona.ai/v1/mobile/events \
              -H 'Content-Type: application/json' \
              -H 'Content-Encoding: gzip' \
              --data-binary @-
      parameters:
        - name: Content-Encoding
          in: header
          required: true
          schema:
            type: string
            const: gzip
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EventBatch'
            example:
              schema: 1
              workspace: acme-market
              sent_at: 1761681600123
              sdk_version: 0.1.0
              app_version: 4.2.0 (420)
              platform: android
              os_version: 34
              events:
                - event_id: 0f6b1c5e-2d8a-4b7e-9a51-3c2e7d9f4a10
                  seq: 41
                  type: eligible
                  trigger: decision
                  ts: 1761681598410
                  session_id: 8c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5
                  install_id: 5e4d3c2b-1a09-4f8e-8d7c-6b5a49382716
                  customer_id: cus_7Hq2Lm9x
                  config_version: 12
                  campaign_id: free-delivery-bar
                  variant_id: progress-bar
                  arm: treatment
                  unit: install
                  placement: home_top
      responses:
        '2XX':
          description: Accepted. The SDK removes the batch and ignores the body.
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
        '4XX':
          description: The SDK drops the batch.
        '5XX':
          description: The SDK retries at most 3 times with backoff.
  /v1/mobile/preview/pair:
    post:
      tags: [Mobile SDK endpoints]
      operationId: pair-preview
      summary: Exchange a preview code for a pin
      description: |
        Called by the Grona SDK, not by your code, when a tester opens a preview link on a
        device.

        The pairing code is a bearer secret: whoever opens the link gets a pin bound to their own
        install. Codes are single use, expire within minutes, and are rate limited per install id
        and per IP address.

        A `200` body is the pin itself: a compact JWS (ASCII, at most 4 KB) signed with a config
        key, with the protected header exactly `{"alg":"ES256","kid":"...","typ":"grona-pin+jwt"}`
        and the claims `ins`, `wks`, `cmp`, `var`, `iat` and `exp` (at most 24 hours after `iat`).
        Any other status leaves the device's pins unchanged.
      security: []
      x-codeSamples:
        - lang: sh
          label: curl
          source: |
            # The Grona SDK makes this call when a tester opens a preview link. Your code does not.
            curl https://api.grona.ai/v1/mobile/preview/pair \
              -H 'Content-Type: application/json' \
              -d '{"code": "K7Q2-M9XP-4RTW", "install_id": "5e4d3c2b-1a09-4f8e-8d7c-6b5a49382716", "workspace": "acme-market"}'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PairingRequest'
            example:
              code: K7Q2-M9XP-4RTW
              install_id: 5e4d3c2b-1a09-4f8e-8d7c-6b5a49382716
              workspace: acme-market
      responses:
        '200':
          description: The pin.
          content:
            application/jose:
              schema:
                type: string
                maxLength: 4096
                description: Compact JWS with `typ` `grona-pin+jwt`.
        '4XX':
          description: Unknown, used, expired or rate-limited code. The device's pins are unchanged.
        '5XX':
          description: The device's pins are unchanged.
components:
  securitySchemes:
    secretKeyBearer:
      type: http
      scheme: bearer
      bearerFormat: grona_sk_<keyId>_<secret>
      description: |
        Your workspace secret key in the `Authorization` header. Server to server only: never
        put it in a browser, a mobile app or a public repository. The key decides the workspace.
    secretKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: The same workspace secret key, sent in the `X-API-Key` header instead of `Authorization`.
  parameters:
    CampaignId:
      name: campaignId
      in: path
      required: true
      description: Grona's campaign id or your `externalId`. If a value matches both, Grona's id wins.
      schema:
        type: string
        pattern: '^[A-Za-z0-9_.:-]{1,128}$'
      example: CMP-1024
    WorkspaceKey:
      name: workspaceKey
      in: path
      required: true
      description: The public workspace key the app passes to the SDK.
      schema:
        $ref: '#/components/schemas/OpaqueId'
      example: acme-market
  headers:
    RetryAfter:
      description: How long to wait before retrying, in seconds or as an HTTP date.
      schema:
        type: string
  responses:
    BadRequest:
      description: |
        The request is invalid. `error.code` is `invalid_request` and `error.message` says what
        to change. Nothing in the request was applied.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: invalid_request
              message: The campaign id is not a valid id.
    BadRequestCreate:
      description: |
        The request is invalid, for example a missing `name` or `url`, or a field that does not
        apply to this kind of campaign. `error.code` is `invalid_request` and `error.message`
        says what to change. Nothing was created.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: invalid_request
              message: '`name` is required.'
    BadRequestUpdate:
      description: |
        The request is invalid, for example it sends a field that is fixed at creation.
        `error.code` is `invalid_request` and `error.message` says what to change. Nothing in
        the request was applied.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: invalid_request
              message: '`url` cannot be changed; create a new campaign for a different page.'
    BadRequestStatus:
      description: |
        The campaign id is not valid, or `status` is not `live` or `paused`. `error.code` is
        `invalid_request`. Nothing was changed.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: invalid_request
              message: '`status` must be `live` or `paused`.'
    BadRequestVariation:
      description: |
        `variationId` is missing or is not a valid id. `error.code` is `invalid_request`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: invalid_request
              message: '`variationId` is not a valid id.'
    BadRequestEditUrl:
      description: |
        `editUrl` is missing or is not a Grona editor link. `error.code` is `invalid_request`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: invalid_request
              message: '`editUrl` is not a Grona editor link.'
    BadRequestUrl:
      description: |
        `url` is missing or is not an `http` or `https` URL. `error.code` is `invalid_request`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: invalid_request
              message: '`url` must be an http or https URL.'
    BadRequestGrant:
      description: |
        `customerId` is missing, or is not 1 to 128 letters, digits, `.`, `_`, `:` or `-`.
        `error.code` is `invalid_request`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: invalid_request
              message: '`customerId` must be 1 to 128 letters, digits, `.`, `_`, `:` or `-`.'
    Unauthorized:
      description: |
        The secret key is missing, malformed, unknown or revoked. `error.code` is `unauthorized`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: unauthorized
              message: Missing or invalid API key.
    NotFound:
      description: |
        No campaign with this id or `externalId` exists in the key's workspace. `error.code` is
        `not_found`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: not_found
              message: Campaign not found.
    VariationNotFound:
      description: |
        No variation with this id exists in the key's workspace, for example because its
        campaign was deleted. `error.code` is `not_found`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: not_found
              message: Variation not found.
    Conflict:
      description: |
        The `externalId` is already used by another campaign in the workspace. `error.code` is
        `conflict`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: conflict
              message: externalId `CMP-1024` is already used by another campaign.
    TooManyRequests:
      description: |
        Too many requests. Retry with exponential backoff and jitter, and wait at least as long
        as `Retry-After` when it is present. Branch on the status code; the body is not
        guaranteed to use the error envelope.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
    ServiceUnavailable:
      description: |
        Temporarily unavailable. Retry with exponential backoff and jitter, and wait at least as
        long as `Retry-After` when it is present. Branch on the status code; the body is not
        guaranteed to use the error envelope.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
    InternalError:
      description: |
        Unexpected server error. `error.code` is `internal_error`. Retry with backoff; if it
        persists, contact support@grona.ai with the time of the request and the endpoint.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: internal_error
              message: Something went wrong on our side.
  schemas:
    OpaqueId:
      type: string
      pattern: '^[A-Za-z0-9._:-]{1,128}$'
    ExternalId:
      type: string
      pattern: '^[A-Za-z0-9_.:-]{1,128}$'
      description: |
        Your own id for a campaign: 1 to 128 letters, digits, `_`, `.`, `:` or `-`. Unique per
        workspace. A UUID, an ObjectId or a plain number are all valid.
      examples: [CMP-1024]
    Timestamp:
      type: string
      format: date-time
      description: ISO 8601 in UTC, with millisecond precision.
    CampaignStatus:
      type: string
      enum: [live, paused]
      description: '`live` runs the campaign. `paused` stops it. New campaigns start `paused`.'
    CampaignType:
      type: string
      enum: [general, abTest]
      description: '`general` creates one variation. `abTest` creates two, Control and Test.'
    PageTargeting:
      type: string
      enum: [specific, multiple]
      description: |
        `specific` runs on the one page in `url`. `multiple` runs on every page matched by
        `slug`, `condition` and `list`.
    PageCondition:
      type: string
      enum: [included, not_included]
      description: |
        Only for `multiple` campaigns. Grona takes the path segment of each page at the same
        position as `slug` in the sample `url`. `included` matches pages whose segment there is
        in `list`. `not_included` matches pages whose segment there is not in `list`, which can
        be far more pages than you intend, so preview the variation on some of them before you
        set the campaign live. A page with no segment at that position never matches.
    PageVariableName:
      type: string
      pattern: '^[A-Za-z_][A-Za-z0-9_]{0,127}$'
      description: |
        The name of a variable your page sets on `window`: letters, digits and `_`, not
        starting with a digit, at most 128 characters. Names the browser already defines, such
        as `name`, `location`, `status`, `top`, `parent` and `history`, are rejected.
      examples: [audience_id]
    PageVariableValue:
      type: string
      pattern: '^[A-Za-z0-9_.:-]{1,128}$'
      description: |
        The value the page variable must hold for a visitor to be eligible: 1 to 128
        letters, digits, `_`, `.`, `:` or `-`. Compared as an exact string match.
      examples: [returning_buyer]
    ScriptCheck:
      type: object
      description: Whether the Grona snippet was found on the campaign's website.
      required: [status, checkedAt]
      properties:
        status:
          type: string
          enum: [checking, present, absent, unknown]
          description: |
            `checking`: a check is running. `present`: the snippet was found. `absent`: the
            snippet was not found, or Grona could not load the page. `unknown`: no check is
            running and none has completed. New campaigns read `checking`, not `unknown`.
        checkedAt:
          description: When the last check completed. `null` until one has.
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
    VariationRole:
      type: string
      enum: [control, variant]
      description: |
        `control` when the variation's name contains the word "control" in any letter case,
        `variant` otherwise. An A/B test reads `control`, `variant`. A variation whose `name` is
        `null` reads `variant`.
    Variation:
      type: object
      required: [id, name, type, previewUrl]
      properties:
        id:
          type: string
          description: The variation id. Use it for editor links and in `trafficSplit` on update.
        name:
          type: [string, 'null']
          description: Set by Grona. `null` when the campaign refers to a variation that no longer exists.
        type:
          $ref: '#/components/schemas/VariationRole'
        previewUrl:
          type: [string, 'null']
          format: uri
          description: |
            The campaign's page with the variation's saved changes applied, through the
            `gronaPreview` parameter. It does not expire. `null` only when the variation no
            longer exists.
    VariationWithEditLink:
      allOf:
        - $ref: '#/components/schemas/Variation'
        - type: object
          required: [editUrl, generatedAt, expiresAt]
          properties:
            editUrl:
              type: string
              format: uri
              description: Editor link for this variation. Its token expires 15 minutes after `generatedAt`.
            generatedAt:
              $ref: '#/components/schemas/Timestamp'
            expiresAt:
              $ref: '#/components/schemas/Timestamp'
    TrafficSplitByRole:
      type: object
      description: |
        Create only. Whole numbers for `Control` and `Test` that total 100. Keyed by role
        because the variation ids do not exist yet.
      required: [Control, Test]
      properties:
        Control:
          type: integer
          minimum: 0
          maximum: 100
        Test:
          type: integer
          minimum: 0
          maximum: 100
      additionalProperties: false
    TrafficSplitById:
      type: object
      description: |
        Keyed by variation id. On update, every variation must appear and the values must total
        100.
      additionalProperties:
        type: integer
        minimum: 0
        maximum: 100
    CampaignFields:
      type: object
      required: [id, externalId, name, status, domain, script, createdAt, updatedAt, type, pageTargeting, segmentId, segmentValue]
      properties:
        id:
          type: string
          description: Grona's campaign id.
        externalId:
          description: Your own id, or `null` if none is set.
          oneOf:
            - $ref: '#/components/schemas/ExternalId'
            - type: 'null'
        name:
          type: string
        status:
          $ref: '#/components/schemas/CampaignStatus'
        domain:
          type: [string, 'null']
          description: The website the campaign runs on, resolved from its `url`.
        script:
          $ref: '#/components/schemas/ScriptCheck'
        createdAt:
          $ref: '#/components/schemas/Timestamp'
        updatedAt:
          $ref: '#/components/schemas/Timestamp'
        type:
          $ref: '#/components/schemas/CampaignType'
        pageTargeting:
          $ref: '#/components/schemas/PageTargeting'
        segmentId:
          description: The page variable's name, or `null` when every visitor on the targeted pages is eligible.
          oneOf:
            - $ref: '#/components/schemas/PageVariableName'
            - type: 'null'
        segmentValue:
          description: The value the page variable must hold, or `null` when `segmentId` is `null`.
          oneOf:
            - $ref: '#/components/schemas/PageVariableValue'
            - type: 'null'
        url:
          type: string
          format: uri
          description: Only for `multiple` campaigns. The sample page, stored without its query string or fragment.
        slug:
          type: string
          description: Only for `multiple` campaigns. The sample page's path segment whose position is compared.
        condition:
          $ref: '#/components/schemas/PageCondition'
        list:
          type: array
          description: Only for `multiple` campaigns. The segments compared against.
          minItems: 1
          maxItems: 50
          items:
            type: string
        trafficSplit:
          $ref: '#/components/schemas/TrafficSplitById'
      description: |
        `url`, `slug`, `condition` and `list` appear only on `multiple` campaigns. `trafficSplit`
        appears on every `abTest` campaign and never on a `general` one.
    Campaign:
      allOf:
        - $ref: '#/components/schemas/CampaignFields'
        - type: object
          required: [variations]
          properties:
            variations:
              type: array
              description: In creation order, so Control comes first in an A/B test. No editor links.
              items:
                $ref: '#/components/schemas/Variation'
    CreatedCampaign:
      allOf:
        - $ref: '#/components/schemas/CampaignFields'
        - type: object
          required: [variations]
          properties:
            variations:
              type: array
              description: One entry for `general`, two for `abTest` in the order Control, Test. Each has an editor link.
              items:
                $ref: '#/components/schemas/VariationWithEditLink'
    CreateCampaignRequest:
      type: object
      required: [name, url]
      description: |
        `segmentId` and `segmentValue` go together: send both or neither. With neither, every
        visitor on the targeted pages is eligible. `trafficSplit` is for `abTest` campaigns only.
        `slug`, `condition` and `list` are for `multiple` campaigns only. Sending any of these
        with the wrong kind of campaign returns `400`.
      properties:
        name:
          type: string
          minLength: 1
          description: Campaign name.
        url:
          type: string
          format: uri
          description: |
            The page the campaign runs on. With `pageTargeting` `multiple`, a sample page
            of the kind you want to cover; the editor opens this page. It cannot be changed later.
            Leave out any `#fragment`: preview reads `gronaPreview` from the query string only.
        externalId:
          $ref: '#/components/schemas/ExternalId'
        segmentId:
          $ref: '#/components/schemas/PageVariableName'
        segmentValue:
          $ref: '#/components/schemas/PageVariableValue'
        type:
          $ref: '#/components/schemas/CampaignType'
          default: general
        pageTargeting:
          $ref: '#/components/schemas/PageTargeting'
          default: specific
        trafficSplit:
          $ref: '#/components/schemas/TrafficSplitByRole'
          description: '`abTest` only. Optional; the default is 50 and 50. Sending it for a `general` campaign returns `400`.'
        slug:
          type: string
          description: |
            Required with `multiple`. One segment of the sample `url`'s path, spelled
            exactly as it appears there. On each page, Grona takes the path segment at the same
            position and compares it with each entry of `list`. The comparison is exact and
            case-sensitive. A page with no segment at that position never matches, whichever
            `condition` you use.
        condition:
          $ref: '#/components/schemas/PageCondition'
          default: included
        list:
          type: array
          description: Required with `multiple`. The path segments to compare against, 1 to 50 of them.
          minItems: 1
          maxItems: 50
          items:
            type: string
      dependentRequired:
        segmentId: [segmentValue]
        segmentValue: [segmentId]
    UpdateCampaignRequest:
      type: object
      minProperties: 1
      description: |
        At least one field. `url`, `type` and `pageTargeting` cannot be sent.
      properties:
        name:
          type: string
          minLength: 1
        status:
          $ref: '#/components/schemas/CampaignStatus'
        externalId:
          description: Your own id. `null` removes it and frees it for reuse. Sending the campaign's current id is fine.
          oneOf:
            - $ref: '#/components/schemas/ExternalId'
            - type: 'null'
        segmentId:
          $ref: '#/components/schemas/PageVariableName'
        segmentValue:
          $ref: '#/components/schemas/PageVariableValue'
        trafficSplit:
          $ref: '#/components/schemas/TrafficSplitById'
        slug:
          type: string
          description: Only for `multiple` campaigns. A new segment of the sample `url`'s path, spelled exactly as it appears there.
        condition:
          $ref: '#/components/schemas/PageCondition'
        list:
          type: array
          description: Only for `multiple` campaigns. The whole new list, 1 to 50 entries. It replaces the old one.
          minItems: 1
          maxItems: 50
          items:
            type: string
    StatusRequest:
      type: object
      required: [status]
      properties:
        status:
          $ref: '#/components/schemas/CampaignStatus'
    EditLinkRequest:
      type: object
      required: [variationId]
      properties:
        variationId:
          type: string
          description: A variation id from `variations[].id` on create, list or get.
    EditLinkValidateRequest:
      type: object
      required: [editUrl]
      properties:
        editUrl:
          type: string
          format: uri
          description: The whole link as Grona issued it, not the token inside it.
    VerifyScriptRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: An `http` or `https` page URL.
    Meta:
      type: object
      required: [workspaceId]
      properties:
        workspaceId:
          type: string
          description: The workspace resolved from the secret key.
    ListMeta:
      allOf:
        - $ref: '#/components/schemas/Meta'
        - type: object
          required: [count]
          properties:
            count:
              type: integer
              minimum: 0
              description: The number of campaigns returned. Reserved for pagination in a later release.
    CampaignEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          $ref: '#/components/schemas/Campaign'
        meta:
          $ref: '#/components/schemas/Meta'
    CreatedCampaignEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          $ref: '#/components/schemas/CreatedCampaign'
        meta:
          $ref: '#/components/schemas/Meta'
    CampaignListEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Campaign'
        meta:
          $ref: '#/components/schemas/ListMeta'
    DeletedCampaignEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          type: object
          required: [id, deletedVariationIds]
          properties:
            id:
              type: string
              description: Grona's id of the deleted campaign.
            deletedVariationIds:
              type: array
              description: The variations deleted with the campaign.
              items:
                type: string
        meta:
          $ref: '#/components/schemas/Meta'
    StatusEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          type: object
          required: [status]
          properties:
            status:
              $ref: '#/components/schemas/CampaignStatus'
        meta:
          $ref: '#/components/schemas/Meta'
    EditLinkEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          type: object
          required: [editUrl, variationId, previewUrl, generatedAt, expiresAt]
          properties:
            editUrl:
              type: string
              format: uri
              description: Editor link. Its token expires 15 minutes after `generatedAt`.
            variationId:
              type: string
            previewUrl:
              type: string
              format: uri
              description: The page with the variation's saved changes applied. It does not expire.
            generatedAt:
              $ref: '#/components/schemas/Timestamp'
            expiresAt:
              $ref: '#/components/schemas/Timestamp'
        meta:
          $ref: '#/components/schemas/Meta'
    EditLinkValidateEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          type: object
          required: [valid, refreshed, editUrl, variationId, generatedAt, expiresAt]
          properties:
            valid:
              type: boolean
              description: Whether the link you sent had not yet expired. Informational, for your logs.
            refreshed:
              type: boolean
              description: Whether Grona issued a new link.
            editUrl:
              type: string
              format: uri
              description: The link to open. The one you sent, byte for byte, or its replacement.
            variationId:
              type: string
              description: The variation the returned link opens.
            generatedAt:
              $ref: '#/components/schemas/Timestamp'
            expiresAt:
              $ref: '#/components/schemas/Timestamp'
        meta:
          $ref: '#/components/schemas/Meta'
    VerifyScriptEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          type: object
          required: [url, script]
          properties:
            url:
              type: string
              format: uri
              description: The page that was checked.
            script:
              type: object
              required: [status, checkedAt]
              properties:
                status:
                  type: string
                  enum: [present, absent]
                  description: '`absent` also covers a page Grona could not load.'
                checkedAt:
                  $ref: '#/components/schemas/Timestamp'
        meta:
          $ref: '#/components/schemas/Meta'
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [invalid_request, unauthorized, not_found, conflict, internal_error]
              description: Stable and machine-readable. Branch on this, and on the HTTP status for codes you do not know.
            message:
              type: string
              description: Human-readable. The wording can change, so do not parse it.
    GrantRequest:
      type: object
      required: [customerId]
      properties:
        customerId:
          type: string
          pattern: '^[A-Za-z0-9._:-]{1,128}$'
          description: |
            The opaque customer id your app passes to the SDK's `identify`, taken from the
            shopper's authenticated session: 1 to 128 letters, digits, `.`, `_`, `:` or `-`, the
            same rule the SDK applies to `identify`. Never an email, a phone number or a national
            id. It becomes the grant's `sub` claim.
    GrantEnvelope:
      type: object
      required: [data, meta]
      properties:
        data:
          type: object
          required: [grants]
          properties:
            grants:
              type: array
              maxItems: 50
              description: |
                The customer's currently valid grants, at most 50: compact JWS tokens with `typ`
                `grona-grant+jwt`, each at most 4 KB. Grona mints no new grant for a campaign while
                an unexpired one exists for the customer.
              items:
                type: string
                maxLength: 4096
        meta:
          $ref: '#/components/schemas/Meta'
    SignedConfigEnvelope:
      type: object
      description: JWS in flattened JSON serialization (RFC 7515, section 7.2.2). Base64url without padding.
      required: [protected, payload, signature]
      properties:
        protected:
          type: string
          description: Base64url of `{"alg":"ES256","kid":"...","typ":"grona-config+jws"}`. No other header fields.
        payload:
          type: string
          description: Base64url of the config JSON.
        signature:
          type: string
          description: Base64url of the 64-byte ES256 signature over `protected + "." + payload`.
    EventBatch:
      type: object
      required: [schema, workspace, sent_at, sdk_version, app_version, platform, os_version, events]
      properties:
        schema:
          type: integer
          const: 1
        workspace:
          $ref: '#/components/schemas/OpaqueId'
        sent_at:
          type: integer
          description: Unix milliseconds, device clock.
        sdk_version:
          type: string
        app_version:
          type: string
        platform:
          type: string
          examples: [android, ios]
        os_version:
          type: integer
          description: On Android, the API level, such as `34`.
        events:
          type: array
          maxItems: 50
          items:
            $ref: '#/components/schemas/MobileEvent'
    MobileEvent:
      type: object
      required: [event_id, seq, type, trigger, ts, session_id, install_id]
      properties:
        event_id:
          type: string
          format: uuid
          description: Random UUID. Grona deduplicates on it.
        seq:
          type: integer
          description: Per-install counter, so gaps reveal lost events.
        type:
          type: string
          enum: [eligible, served, anchor_visible, exposure, action, dismiss, view_item, add_to_cart, remove_from_cart, begin_checkout, purchase]
        trigger:
          type: string
          enum: [decision, render, anchor_1s, content_50pct_1s, tap, host_call, host]
        ts:
          type: integer
          description: Unix milliseconds, device clock.
        session_id:
          type: string
          format: uuid
        install_id:
          type: string
          format: uuid
          description: SDK-owned install id.
        customer_id:
          type: string
          description: Opaque customer id, only when the shopper is identified. Never an email or a phone number.
        config_version:
          type: integer
          description: The config the decision came from.
        preview:
          type: boolean
          description: '`true` while a preview pin is active. Excluded from analysis.'
        demo:
          type: boolean
          description: '`true` only in demo mode. Excluded from analysis.'
        campaign_id:
          type: string
        variant_id:
          type: string
        arm:
          type: string
          enum: [treatment, control, holdout, global_holdout]
        unit:
          type: string
          enum: [install, customer]
        placement:
          type: string
        key:
          type: string
        data:
          type: object
          description: |
            Type-specific fields, for example `delivery` and `grant_jti` on offer events, or
            `order_id`, `total_minor`, `currency`, `campaign_product_ids` and `treatments` on
            `purchase`.
          additionalProperties: true
    PairingRequest:
      type: object
      required: [code, install_id, workspace]
      properties:
        code:
          type: string
          pattern: '^[A-Za-z0-9-]{8,64}$'
          description: The pairing code from the preview link.
        install_id:
          type: string
          description: The SDK install id. The SDK never sends it while consent is denied.
        workspace:
          $ref: '#/components/schemas/OpaqueId'
