{
  "openapi": "3.1.0",
  "info": {
    "title": "Stow Cards API",
    "version": "1.1.0",
    "x-last-updated": "2026-09-30T07:29:00Z",
    "summary": "REST API for integrating loyalty passes, members, points and webhooks.",
    "description": "## Overview\n\nThe Stow Cards API lets your backend drive the full loyalty loop\nserver-to-server, picking up after a merchant has published a pass program\nin the web console:\n\n1. **List programs:** `GET /merchants/me/programs`\n2. **Enroll members:** `POST /merchants/me/members`\n3. **Award / redeem points:** `PATCH /merchants/me/members/{id}/points`\n4. **Read member state & history:** `GET /merchants/me/members/{id}`, `…/events`, `…/transactions`\n5. **React to changes:** outbound [webhooks](webhooks.html) (`member.created`, `points.changed`, …)\n\nWhen you change a member's points or status, Stow Cards rebuilds the\nApple/Google wallet pass and pushes the update to the customer's phone\nautomatically. You just call the API.\n\n## Quickstart\n\nYou need a published program (web console → Pass Designer) and an API key\n(console → Administration → API Keys, or `POST /auth/api-keys`). Then the whole\nloyalty loop is four calls:\n\n```bash\nexport STOW_BASE_URL=\"http://localhost:6060/api/v1\"\nexport STOW_API_KEY=\"stow_replace_with_your_key\"\n\n# 1) Find a program to enroll into (capture its _id)\ncurl -s \"$STOW_BASE_URL/merchants/me/programs\" \\\n  -H \"X-API-Key: $STOW_API_KEY\"\n\n# 2) Enroll a member (capture data.id, the MEMBER_UUID below; it is not\n#    the Member ID printed on the pass)\ncurl -s -X POST \"$STOW_BASE_URL/merchants/me/members\" \\\n  -H \"X-API-Key: $STOW_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"programId\":\"PROGRAM_ID\",\"firstName\":\"Ada\",\"lastName\":\"Lovelace\",\"email\":\"ada@example.com\",\"initialPoints\":50}'\n\n# 3) Award 10 points (idempotent: reuse the key on retries)\ncurl -s -X PATCH \"$STOW_BASE_URL/merchants/me/members/MEMBER_UUID/points\" \\\n  -H \"X-API-Key: $STOW_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\"delta\":10,\"changeMessage\":\"Thanks for visiting!\"}'\n\n# 4) Read the member back\ncurl -s \"$STOW_BASE_URL/merchants/me/members/MEMBER_UUID\" \\\n  -H \"X-API-Key: $STOW_API_KEY\"\n```\n\nRunnable versions of this flow in Node.js, Python, PHP, Go, Ruby and cURL,\neach with error handling, live in the repo under\n`docs/developers/examples/`.\n\nYour dev environment likely has no real Apple/Google wallet credentials\nconfigured yet. That's **stub mode**: the loop above still works\nend-to-end, but wallet pushes are marked `mode: \"stub\"` instead of\nreaching a device. Check `apple.mode` / `google.mode` on the member\nobject; never infer real-vs-stub from the pass URL, which is identical in\nboth modes.\n\n## Base URL & environments\n\nAll traffic goes through the API gateway at the `/api/v1` prefix. Never\ncall individual service ports directly.\n\n| Environment | Base URL |\n|---|---|\n| Local dev | `http://localhost:6060/api/v1` |\n| Production | `https://<your-gateway-host>/api/v1` |\n\nRequests and responses are JSON (`Content-Type: application/json`), except\nCSV import (multipart) and CSV export (`text/csv`). Point values are\n**integers**: whole points, no decimals.\n\n## Authentication\n\nServer-to-server calls authenticate with an **API key**. A key:\n\n- is created by a signed-in merchant user (console → Administration → API Keys, or\n  `POST /auth/api-keys`),\n- is **bound to one merchant**: every `/merchants/me/*` call auto-scopes\n  to it, no tenant header needed,\n- carries the permissions of the **roles** assigned at creation (give\n  integrations the least-privilege role they need),\n- is shown **exactly once** at creation (stored only as a hash),\n- looks like `stow_` followed by a URL-safe random string. Treat the whole\n  string as the secret. A user may hold up to **10 active keys**.\n\nSend the key on every request. Either header works:\n\n```http\nX-API-Key: stow_8Kf2pQ9xN3...\n```\n\n```http\nAuthorization: ApiKey stow_8Kf2pQ9xN3...\n```\n\nAPI-key requests are **exempt from CSRF**. One precedence rule to know: if\na request carries both a JWT (Bearer/cookie) **and** an API key, the JWT\nwins. API-key auth only engages when no user token is present.\n\n**Rotation:** create the new key, deploy it, verify traffic, then revoke\nthe old one (`DELETE /auth/api-keys/{id}`). Set `expiresAt` to make a key\nlapse automatically.\n\n## Response envelope\n\nEvery JSON response uses one envelope:\n\n```json\n{ \"success\": true,  \"data\": { } }\n```\n\n```json\n{ \"success\": false, \"error\": { \"code\": \"INVALID_API_KEY\", \"message\": \"Invalid or expired API key\" } }\n```\n\nBranch on `success` (or the HTTP status), then read `error.code` for\nprogrammatic handling and `error.message` for display. One documented\nexception: the public enrollment submit returns `success: false` with\n`partial: true` when the member was created but a wallet pass failed to\nissue (see `POST /enroll/{slug}/submit`).\n\n## Pagination\n\nList endpoints take `page` (default 1) and `pageSize` (default 25,\nmax 100) and respond with:\n\n```json\n{ \"success\": true, \"data\": { \"items\": [ ], \"page\": 1, \"pageSize\": 25, \"total\": 137 } }\n```\n\n## Idempotency\n\nPoint adjustments and redemptions are money-like, so a retry must not apply\ntwice. Send an `Idempotency-Key` header on those calls:\n\n- Format `^[A-Za-z0-9_-]{8,128}$` (a UUID works well).\n- One key **per logical gesture**; reuse the same key when retrying that\n  gesture.\n- A retry of a completed request **replays the stored response verbatim**.\n- A concurrent request holding the same key gets `409 Conflict`.\n\n## Errors\n\n| Status | When |\n|---|---|\n| `400 Bad Request` | validation failed (bad body, both `delta`+`value`, malformed `Idempotency-Key`) |\n| `401 Unauthorized` | missing / invalid / expired / revoked API key |\n| `403 Forbidden` | key authenticated but lacks the required permission |\n| `404 Not Found` | resource not found within your merchant |\n| `409 Conflict` | an in-flight request already holds this `Idempotency-Key` |\n| `429 Too Many Requests` | rate limit exceeded |\n| `5xx` | upstream error; safe to retry idempotent calls with the same key |\n\n## Rate limits\n\nThe gateway rate-limits per caller (API-key identity for keyed calls, IP\notherwise). Defaults are operator-configurable via environment:\n\n- **Authenticated** (API key / Bearer): 1000 requests / 15-minute window\n  (production default).\n- **Unauthenticated** public routes (e.g. enrollment): 50 / 15 min.\n\nResponses carry standard `RateLimit-Limit` / `RateLimit-Remaining` /\n`RateLimit-Reset` headers. On `429`, back off until the window resets. For\nbulk onboarding use CSV import instead of a tight enroll loop.\n\n## Member identity\n\n`memberCode` (10 chars, e.g. `7K2P9QX4M8`) is the **only member identifier\nthat should leave your system**: it's what the pass barcode encodes, what\na till scanner reads, and what UIs must label **\"Member ID\"**. Use `id`\n(UUID) in API paths; treat `serialNumber` as opaque wallet plumbing and\nnever show it to staff or customers.\n",
    "contact": {
      "name": "Stow Cards",
      "url": "https://stow.cards/contact.html"
    },
    "license": {
      "name": "Proprietary, subject to your merchant/partner agreement",
      "url": "https://stow.cards/terms.html"
    },
    "x-logo": {
      "altText": "Stow Cards"
    }
  },
  "servers": [
    {
      "url": "http://localhost:6060/api/v1",
      "description": "Local development (api-gateway)"
    },
    {
      "url": "https://{gateway-host}/api/v1",
      "description": "Production (your operator provides the gateway host)",
      "variables": {
        "gateway-host": {
          "default": "api.stow.cards"
        }
      }
    }
  ],
  "security": [
    {
      "ApiKeyHeader": []
    },
    {
      "ApiKeyAuthorization": []
    }
  ],
  "tags": [
    {
      "name": "API Keys",
      "description": "Create, list and revoke the `stow_` API keys used for server-to-server\ncalls. These management endpoints require a **signed-in console\nsession** (cookie or Bearer JWT) with the `api-keys` permissions. You\ncannot mint new keys with an API key.\n"
    },
    {
      "name": "Programs",
      "description": "Read the merchant's published pass programs. Programs are **designed and\nmanaged in the web console** (Pass Designer); the integration surface is\nread-only. Capture the program's **`_id`** to enroll members into it.\n"
    },
    {
      "name": "Members",
      "description": "Enroll and manage the customers of a program. Enrollment issues Apple +\nGoogle wallet passes automatically (asynchronously, so poll the member if\nyou need `apple.passUrl` immediately). Enrollment dedupes by\n`(programId, email)`.\n"
    },
    {
      "name": "Points & Rewards",
      "description": "Balance adjustments, reward redemption, status changes and manual wallet\nre-push. Points and redeem calls take an `Idempotency-Key` header so\ntill retries can never double-apply.\n"
    },
    {
      "name": "Events & Transactions",
      "description": "Per-member history: the wallet pass lifecycle timeline (`pass_events`)\nand the balance-affecting transaction ledger.\n"
    },
    {
      "name": "Webhook Subscriptions",
      "description": "Manage outbound webhook subscriptions. Stow Cards POSTs signed JSON\nevents (`member.created`, `points.changed`, `reward.redeemed`, …) to\nyour HTTPS endpoint. See the [webhooks guide](webhooks.html) for the\nenvelope and signature verification. Note: webhook management currently\nrequires the merchant **campaigns** permissions\n(`merchants.campaigns:view/create/edit/delete`).\n"
    },
    {
      "name": "Enrollment (Public)",
      "description": "The unauthenticated endpoints behind the customer-facing enrollment\npage. Documented for context; backend integrations should prefer the\nauthenticated `POST /merchants/me/members`. These routes are IP\nrate-limited and dedupe by `(programId, email)`.\n"
    },
    {
      "name": "Webhook events",
      "description": "The events Stow Cards sends to your endpoint, with the full payload of\neach. These are outbound calls, so they carry no API key: check the\n`X-Stow-Webhook-Signature` header instead. The\n[webhooks guide](webhooks.html) covers delivery, retries and signature\nchecks, with code in Node.js and Python.\n"
    }
  ],
  "x-route-sources": [
    {
      "publicPrefix": "/auth/api-keys",
      "file": "stow-backend/services/auth-service/src/api/apikey-routes.ts",
      "undocumented": [
        "POST /validate"
      ]
    },
    {
      "publicPrefix": "/merchants/me/programs",
      "file": "stow-backend/services/merchant-service/src/api/programRoutes.ts",
      "undocumented": [
        "POST /",
        "PATCH /:id",
        "DELETE /:id",
        "POST /:id/status"
      ]
    },
    {
      "publicPrefix": "/merchants/me/members",
      "file": "stow-backend/services/merchant-service/src/api/memberRoutes.ts"
    },
    {
      "publicPrefix": "/merchants/me/webhooks",
      "file": "stow-backend/services/merchant-service/src/api/webhookRoutes.ts"
    },
    {
      "publicPrefix": "/enroll",
      "file": "stow-backend/services/enrollment-service/src/api/enrollRoutes.ts",
      "undocumented": [
        "GET /forms/:slug",
        "POST /forms/:slug/submit",
        "GET /download-pages/:slug",
        "POST /download-pages/:slug/submit"
      ]
    }
  ],
  "x-webhook-guide": "## Receiving webhooks\n\nCreate a subscription with `POST /merchants/me/webhooks`, pointing at an\n**HTTPS** endpoint you control. Stow Cards POSTs one JSON envelope per\nevent, with these headers:\n\n| Header | Value |\n|---|---|\n| `X-Stow-Webhook-Event` | the event type, e.g. `points.changed` |\n| `X-Stow-Webhook-Timestamp` | ISO-8601 timestamp of the delivery attempt |\n| `X-Stow-Webhook-Subscription` | the subscription id |\n| `X-Stow-Webhook-Signature` | `sha256=` + HMAC-SHA256 signature (present when the subscription has a secret) |\n| `User-Agent` | `stow-cards-webhook/1.0` |\n\nRespond with any **2xx** within the subscription's `timeoutMs`. Non-2xx or\ntimeouts are retried up to `retryPolicy.maxAttempts` with `backoffMs` between\nattempts. Deliveries (payload, status, attempts) are inspectable at\n`GET /merchants/me/webhooks/delivery-logs`, and any logged delivery can be\nre-sent with `POST /merchants/me/webhooks/delivery-logs/{logId}/replay`.\n\n**Idempotency on your side:** deliveries are at-least-once. Dedupe on\n`eventId`: replays and retries reuse it.\n\n## Verifying the signature\n\nThe signature is `HMAC-SHA256(secret, \"{timestamp}.{rawBody}\")`, hex-encoded,\nprefixed with `sha256=`. The `{timestamp}` is the value of the\n`X-Stow-Webhook-Timestamp` header. Checking it also protects you\nfrom replays (reject deliveries older than, say, 5 minutes).\n\n**Node.js**\n\n```js\nimport crypto from 'node:crypto';\n\nfunction verifyStowWebhook(req, secret, toleranceMs = 5 * 60 * 1000) {\n  const timestamp = req.headers['x-stow-webhook-timestamp'];\n  const received = req.headers['x-stow-webhook-signature'] || '';\n  if (!timestamp || !received.startsWith('sha256=')) return false;\n  if (Math.abs(Date.now() - Date.parse(timestamp)) > toleranceMs) return false;\n\n  const expected = 'sha256=' + crypto\n    .createHmac('sha256', secret)\n    .update(`${timestamp}.${req.rawBody}`)   // rawBody: the exact bytes received\n    .digest('hex');\n  return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));\n}\n```\n\n**Python**\n\n```python\nimport hashlib, hmac, time\nfrom datetime import datetime, timezone\n\ndef verify_stow_webhook(headers, raw_body: bytes, secret: str, tolerance_s=300):\n    timestamp = headers.get(\"X-Stow-Webhook-Timestamp\", \"\")\n    received = headers.get(\"X-Stow-Webhook-Signature\", \"\")\n    if not timestamp or not received.startswith(\"sha256=\"):\n        return False\n    sent = datetime.fromisoformat(timestamp.replace(\"Z\", \"+00:00\"))\n    if abs(time.time() - sent.timestamp()) > tolerance_s:\n        return False\n    expected = \"sha256=\" + hmac.new(\n        secret.encode(), f\"{timestamp}.\".encode() + raw_body, hashlib.sha256\n    ).hexdigest()\n    return hmac.compare_digest(received, expected)\n```\n\n> Compute the HMAC over the **raw request bytes**, not a re-serialized JSON\n> object, because key order matters.\n\n## Choosing events\n\nSubscribe to specific events or `*` for everything. Test any subscription\nwith `POST /merchants/me/webhooks/{id}/test`. It queues a real delivery of\na `test: true` payload using the subscription's first event type.\n",
  "paths": {
    "/auth/api-keys": {
      "post": {
        "operationId": "createApiKey",
        "tags": [
          "API Keys"
        ],
        "summary": "Create an API key",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "description": "Create a key bound to your merchant. Requires a **console session**\n(cookie or Bearer JWT) with `api-keys:create`. `roleIds` must\nreference roles available to the merchant tenant; the key inherits the\nunion of those roles' permissions.\n\nThe full `key` value is returned **only in this response**, so store it\nin your secrets manager immediately. `keyPrefix` is safe to log.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "roleIds"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Human label, e.g. which system uses this key.",
                    "example": "POS integration for store 14"
                  },
                  "roleIds": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Role ids whose permissions the key inherits."
                  },
                  "expiresAt": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Optional automatic expiry."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Key created. `key` is shown once.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/ApiKeyCreated"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "get": {
        "operationId": "listApiKeys",
        "tags": [
          "API Keys"
        ],
        "summary": "List API keys",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "description": "List your keys (never returns secrets or hashes). Requires a console\nsession with `api-keys:view`.\n",
        "responses": {
          "200": {
            "description": "Your keys.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ApiKey"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/auth/api-keys/{id}": {
      "delete": {
        "operationId": "revokeApiKey",
        "tags": [
          "API Keys"
        ],
        "summary": "Revoke an API key",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "description": "Revoke a key immediately. In-flight requests already validated may\ncomplete; new requests with the key fail with `401`. Requires a\nconsole session with `api-keys:delete`.\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The key's `id` (not the key value)."
          }
        ],
        "responses": {
          "200": {
            "description": "Key revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/programs": {
      "get": {
        "operationId": "listPrograms",
        "tags": [
          "Programs"
        ],
        "summary": "List programs",
        "description": "Returns the merchant's programs. Capture the **`_id`** of the program\nyou want to enroll members into.\n\n> ⚠️ **`_id` vs `id`.** Program documents are returned as raw records\n> keyed by **`_id`**. Member objects are serialized with **`id`**. So:\n> read `program._id` but `member.id`.\n",
        "responses": {
          "200": {
            "description": "The merchant's programs.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Program"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/merchants/me/programs/{id}": {
      "get": {
        "operationId": "getProgram",
        "tags": [
          "Programs"
        ],
        "summary": "Get a program",
        "description": "One program by `_id`. `fields.primaryLabel` is the points/stamp unit\n(e.g. `POINTS`, `STARS`, `STAMPS`). Lower-cased, it is the key of\n`member.fieldValues`. You can only enroll into a non-archived program.\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The program.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/Program"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/members": {
      "post": {
        "operationId": "enrollMember",
        "tags": [
          "Members"
        ],
        "summary": "Enroll a member",
        "description": "Enroll a customer into a program. Requires `merchants.members:create`.\n\nApple + Google passes are issued **asynchronously**, so the returned\nmember may not have `apple.passUrl` populated on the very first read;\npoll `GET /merchants/me/members/{id}` if you need it immediately.\n\nIf a member with that email already exists in the program, nothing is\ncreated and the existing member is returned with **HTTP 200** and\n`\"deduped\": true`. Treat that as success.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EnrollMemberRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Existing member returned (`deduped: true`), nothing created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/Member"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "deduped": {
                              "const": true
                            }
                          }
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "201": {
            "description": "New member enrolled.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/Member"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "get": {
        "operationId": "listMembers",
        "tags": [
          "Members"
        ],
        "summary": "List / search members",
        "description": "Paginated member list. `q` searches first name, last name, email or\nphone number and **prefix-matches `memberCode`**. For a POS that just scanned a pass barcode, use\n`serial=`, which exact-matches `memberCode` (new passes) **or** the\nlegacy UUID `serialNumber` (passes installed before the member-code\nrollout), always scoped to your merchant.\n\nEach item embeds a `program` summary so you can render balances\nwithout a second call.\n",
        "parameters": [
          {
            "name": "programId",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter to one program."
          },
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Search by first name, last name, email or phone number, or by the start of a `memberCode` (the Member ID). With several words, each word must match one of these."
          },
          {
            "name": "serial",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Scan lookup by exact `memberCode` or legacy `serialNumber`."
          },
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/PageSize"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated members.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "items": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/MemberWithProgram"
                          }
                        },
                        "page": {
                          "type": "integer",
                          "example": 1
                        },
                        "pageSize": {
                          "type": "integer",
                          "example": 25
                        },
                        "total": {
                          "type": "integer",
                          "example": 137
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/merchants/me/members/tags": {
      "get": {
        "operationId": "listMemberTags",
        "tags": [
          "Members"
        ],
        "summary": "List all tags in use",
        "description": "Distinct tags across the merchant's members, sorted alphabetically.",
        "responses": {
          "200": {
            "description": "Tag list.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "tags": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          },
                          "example": [
                            "vip",
                            "wholesale"
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/merchants/me/members/import": {
      "post": {
        "operationId": "importMembers",
        "tags": [
          "Members"
        ],
        "summary": "Bulk import members (CSV)",
        "description": "Multipart upload with fields `programId` and `file` (`text/csv`, ≤ 1 MB,\n≤ 5000 rows). CSV headers (case-insensitive, any order): `firstName`,\n`lastName`, `email`, `phone`, `initialPoints`. Returns per-row\n`created` / `deduped` / `failed` results. Prefer this over a tight\nloop of single enrolls.\n",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "programId",
                  "file"
                ],
                "properties": {
                  "programId": {
                    "type": "string"
                  },
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "CSV file, ≤ 1 MB, ≤ 5000 rows."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-row import outcome.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "description": "Counts and per-row results (`created` / `deduped` / `failed`).",
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/merchants/me/members/{id}": {
      "get": {
        "operationId": "getMember",
        "tags": [
          "Members"
        ],
        "summary": "Get a member",
        "description": "The full member object, enriched with a `program` snapshot, the number\nof registered wallet devices (`deviceCount`) and the lifetime redeem\ncount (`redeemedCount`).\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/MemberId"
          }
        ],
        "responses": {
          "200": {
            "description": "The member.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/MemberDetail"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/members/{id}/tags": {
      "post": {
        "operationId": "addMemberTags",
        "tags": [
          "Members"
        ],
        "summary": "Add tags to a member",
        "description": "Adds tags (deduplicated, normalized). A member can hold at most\n**25 tags**; exceeding the cap fails with `400`. Requires\n`merchants.members:edit`.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/MemberId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TagsRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated member (with `unchanged: true` when all tags were already present).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/Member"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "operationId": "removeMemberTags",
        "tags": [
          "Members"
        ],
        "summary": "Remove tags from a member",
        "description": "Removes the given tags. Unknown tags are ignored. Requires `merchants.members:edit`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/MemberId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TagsRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated member.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/Member"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/members/{id}/points": {
      "patch": {
        "operationId": "adjustPoints",
        "tags": [
          "Points & Rewards"
        ],
        "summary": "Adjust points",
        "description": "Adjust the member's balance and push the wallet update. Provide\n**exactly one** of `delta` (relative, clamped at ≥ 0) or `value`\n(absolute). Stamp-style programs only accept `delta: 1`.\n\n`changeMessage` (≤ 200 chars) is stamped onto the pass so it shows on\nthe customer's lock screen. Always send an **`Idempotency-Key`**.\nRequires `merchants.members:edit`.\n\nOn tiered programs the response's webhook side-effect (`points.changed`)\nalso reports `tier`, `previousTier` and `tierUpgraded`.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/MemberId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PointsAdjustRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Balance adjusted; wallet update dispatched.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/PointsAdjustResult"
                    }
                  }
                },
                "example": {
                  "success": true,
                  "data": {
                    "member": {
                      "id": "665c1f00-0000-0000-0000-000000000000",
                      "memberCode": "7K2P9QX4M8",
                      "fieldValues": {
                        "points": 60
                      }
                    },
                    "previous": 50,
                    "current": 60,
                    "passUpdate": {
                      "serialNumber": "0b6f7c1e-4d2a-4c8e-9a51-3e7d2f90c4ab",
                      "lastUpdateTag": "2026-07-02T09:41:07.512Z",
                      "rebuilt": true,
                      "apple": {
                        "devicesNotified": 1,
                        "devicesAttempted": 1,
                        "mode": "real"
                      },
                      "google": {
                        "objectPatched": false,
                        "mode": "real"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/merchants/me/members/{id}/redeem": {
      "post": {
        "operationId": "redeemReward",
        "tags": [
          "Points & Rewards"
        ],
        "summary": "Redeem a reward",
        "description": "Perform the program's redemption (e.g. reset a full stamp card) and\npush a pass update with your message. The server re-checks the card is\nactually full. Idempotent via `Idempotency-Key`. Requires\n`merchants.members:edit`. Emits a `reward.redeemed` webhook event.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/MemberId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "changeMessage": {
                    "type": "string",
                    "maxLength": 200,
                    "example": "Free coffee redeemed 🎉"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reward redeemed; balance reset per program rules.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/PointsAdjustResult"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/merchants/me/members/{id}/status": {
      "patch": {
        "operationId": "updateMemberStatus",
        "tags": [
          "Points & Rewards"
        ],
        "summary": "Change member status",
        "description": "Set the member to `active`, `voided` or `expired`. Triggers a pass\nupdate. Requires `merchants.members:edit`.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/MemberId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "status"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "voided",
                      "expired"
                    ]
                  },
                  "reason": {
                    "type": "string",
                    "description": "Optional audit note."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Status updated; pass update dispatched.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/members/{id}/push": {
      "post": {
        "operationId": "pushMemberPass",
        "tags": [
          "Points & Rewards"
        ],
        "summary": "Re-push the wallet pass",
        "description": "Rebuild and push the member's **current** pass state without changing\nany balance. This heals a stale wallet after a failed push. Requires\n`merchants.members:edit`.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/MemberId"
          }
        ],
        "responses": {
          "200": {
            "description": "Push dispatched.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/members/{id}/events": {
      "get": {
        "operationId": "listMemberEvents",
        "tags": [
          "Events & Transactions"
        ],
        "summary": "Member event timeline",
        "description": "Newest-first wallet pass lifecycle events (`pass_built`,\n`device_registered`, `push_sent`, `google_object_patched`,\n`reward_redeemed`, …), each with an `outcome` of\n`success | failure | skipped`. `skipped` marks the dev/stub-mode\n\"would-have-pushed\" case.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/MemberId"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Event timeline.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "items": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/MemberEvent"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/members/{id}/events.csv": {
      "get": {
        "operationId": "exportMemberEventsCsv",
        "tags": [
          "Events & Transactions"
        ],
        "summary": "Export event timeline (CSV)",
        "description": "RFC 4180 CSV download (UTF-8 BOM), capped at 5000 rows.",
        "parameters": [
          {
            "$ref": "#/components/parameters/MemberId"
          }
        ],
        "responses": {
          "200": {
            "description": "CSV file.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/members/{id}/transactions": {
      "get": {
        "operationId": "listMemberTransactions",
        "tags": [
          "Events & Transactions"
        ],
        "summary": "Member transaction ledger",
        "description": "Paginated, newest-first ledger of balance-affecting actions (points\nadjustments, redeems, scans), including before/after balances, tier\nmovement and the acting reader/user where known.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/MemberId"
          },
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/PageSize"
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated transactions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "items": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/MemberTransaction"
                          }
                        },
                        "page": {
                          "type": "integer"
                        },
                        "pageSize": {
                          "type": "integer"
                        },
                        "total": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/webhooks": {
      "get": {
        "operationId": "listWebhookSubscriptions",
        "tags": [
          "Webhook Subscriptions"
        ],
        "summary": "List webhook subscriptions",
        "parameters": [
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "name": "enabled",
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated subscriptions (secrets never included).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "items": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/WebhookSubscription"
                          }
                        },
                        "page": {
                          "type": "integer"
                        },
                        "pageSize": {
                          "type": "integer"
                        },
                        "total": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "operationId": "createWebhookSubscription",
        "tags": [
          "Webhook Subscriptions"
        ],
        "summary": "Create a webhook subscription",
        "description": "Register an **HTTPS** endpoint for one or more [event types](webhooks.html).\nIf you omit `secret`, one is generated. The secret is returned **only**\nby this call and by `rotate-secret`, so store it to verify signatures.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookSubscriptionCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Subscription created (includes `secret`, shown here and on rotation only).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/WebhookSubscriptionWithSecret"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/merchants/me/webhooks/delivery-logs": {
      "get": {
        "operationId": "listWebhookDeliveryLogs",
        "tags": [
          "Webhook Subscriptions"
        ],
        "summary": "List delivery logs",
        "description": "Recent delivery attempts across subscriptions: payload (PII-masked),\nHTTP status, attempts and timing. Filter by `subscriptionId`,\n`eventType` or `success`.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/Page"
          },
          {
            "$ref": "#/components/parameters/PageSize"
          },
          {
            "name": "subscriptionId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "eventType",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "success",
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated delivery logs.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "items": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/WebhookDeliveryLog"
                          }
                        },
                        "page": {
                          "type": "integer"
                        },
                        "pageSize": {
                          "type": "integer"
                        },
                        "total": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/merchants/me/webhooks/delivery-logs/{logId}/replay": {
      "post": {
        "operationId": "replayWebhookDelivery",
        "tags": [
          "Webhook Subscriptions"
        ],
        "summary": "Replay a delivery",
        "description": "Re-queue a logged delivery's payload to its subscription. The replay\nreuses the original `eventId`, so consumers deduping on `eventId` will\nrecognize it.\n",
        "parameters": [
          {
            "name": "logId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Replay queued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "queued": {
                          "const": true
                        },
                        "eventType": {
                          "type": "string"
                        },
                        "eventId": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/webhooks/{id}": {
      "get": {
        "operationId": "getWebhookSubscription",
        "tags": [
          "Webhook Subscriptions"
        ],
        "summary": "Get a subscription",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The subscription (no secret).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/WebhookSubscription"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "operationId": "updateWebhookSubscription",
        "tags": [
          "Webhook Subscriptions"
        ],
        "summary": "Update a subscription",
        "description": "Patch any of `name`, `description`, `url`, `events`, `enabled`, `retryPolicy`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookSubscriptionPatchRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated subscription.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/WebhookSubscription"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "operationId": "deleteWebhookSubscription",
        "tags": [
          "Webhook Subscriptions"
        ],
        "summary": "Delete a subscription",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted. No body."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/webhooks/{id}/rotate-secret": {
      "post": {
        "operationId": "rotateWebhookSecret",
        "tags": [
          "Webhook Subscriptions"
        ],
        "summary": "Rotate the signing secret",
        "description": "Generates a new secret and returns it. This is the **only** place besides\ncreation where the secret appears. Deliveries signed with the old\nsecret stop immediately, so update your verifier first if you need\nzero-gap rotation (or briefly accept both).\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Subscription with the new `secret`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "$ref": "#/components/schemas/WebhookSubscriptionWithSecret"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/merchants/me/webhooks/{id}/test": {
      "post": {
        "operationId": "testWebhookSubscription",
        "tags": [
          "Webhook Subscriptions"
        ],
        "summary": "Send a test event",
        "description": "Queues a real, signed delivery with `data.test: true`, using the\nsubscription's first concrete event type (or `member.updated` when the\nsubscription is `*`).\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Test delivery queued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "queued": {
                          "const": true
                        },
                        "eventType": {
                          "type": "string",
                          "example": "member.created"
                        },
                        "eventId": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/enroll/{slug}": {
      "get": {
        "operationId": "getEnrollmentPage",
        "tags": [
          "Enrollment (Public)"
        ],
        "summary": "Get public enrollment info",
        "security": [],
        "description": "Program branding and which fields the enrollment form should collect.\nNo authentication; this is what the hosted enrollment page calls.\n\nThe view carries what the front of the pass prints, so a page can\npreview the card: the designed fields, `stampGoal`, and for points and\ntiered programs `rewardTarget` and `tiers`, which the pass's\n`{{goal}}` and `{{remaining}}` tokens count towards. Back fields, the\nbarcode value template and the notification settings are not returned.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/EnrollSlug"
          }
        ],
        "responses": {
          "200": {
            "description": "Public program view.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "description": "Public program branding/config (no secrets).",
                      "properties": {
                        "stampGoal": {
                          "type": "number",
                          "description": "Stamps needed for the reward (stamp cards)."
                        },
                        "rewardTarget": {
                          "type": "number",
                          "description": "Points needed for the reward. Present only when the program sets one."
                        },
                        "tiers": {
                          "type": "array",
                          "description": "Status tiers, lowest threshold first. Present only when the program has tiers.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "name": {
                                "type": "string",
                                "example": "Gold"
                              },
                              "threshold": {
                                "type": "number",
                                "description": "Points balance at which a member reaches the tier.",
                                "example": 1000
                              }
                            }
                          }
                        },
                        "fieldSeeds": {
                          "type": "object",
                          "description": "Program-wide values every member's pass prints the same. Only `discountRate` is public, and the object is present only when it is set.",
                          "properties": {
                            "discountRate": {
                              "type": [
                                "number",
                                "string"
                              ],
                              "description": "The offer's discount, as the pass's `{{discountRate}}` token prints it (without the `%`).",
                              "example": 20
                            }
                          }
                        },
                        "barcode": {
                          "type": "string",
                          "description": "The barcode format only. The value template stays private.",
                          "example": "QR"
                        },
                        "enrollment": {
                          "type": "object",
                          "properties": {
                            "slug": {
                              "type": "string"
                            },
                            "fields": {
                              "type": "array",
                              "items": {
                                "type": "string"
                              }
                            },
                            "retrieveByEmail": {
                              "type": "boolean",
                              "description": "Whether `POST /enroll/{slug}/retrieve` looks members up for this program. A missing value counts as `true`."
                            }
                          },
                          "additionalProperties": true
                        }
                      },
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/enroll/{slug}/submit": {
      "post": {
        "operationId": "submitEnrollment",
        "tags": [
          "Enrollment (Public)"
        ],
        "summary": "Submit a public enrollment",
        "security": [],
        "description": "Create a member from the public enrollment form. Dedupes by\n`(programId, email)` like the authenticated enroll, but a duplicate\nemail returns **HTTP 200** with `{ \"success\": true, \"deduped\": true }`\nand no member data or pass links, so a known email cannot be used to\nfetch someone else's pass.\n\n**A duplicate is answered differently by design.** A new sign-up gets\n`201` with the member and a duplicate gets `200` with `deduped: true`,\nso the enrollment page can tell the customer they are already signed\nup. A submit therefore reveals whether an email is enrolled in the\nprogram, whatever its `enrollment.retrieveByEmail` setting. Probing is\nbounded by the rate limits (per IP, per program, and per program and\nemail; see `429`), and every email that is not enrolled creates a real\nmember and fires `member.created`.\n\n**Partial pass issuance:** the member row is always persisted, but if\neither wallet's pass failed to build, the response carries\n`success: false` with `partial: true` and a `passIssuance` report so\nthe caller knows which \"Add to Wallet\" button has a usable URL.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/EnrollSlug"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "firstName",
                  "lastName",
                  "email"
                ],
                "properties": {
                  "firstName": {
                    "type": "string",
                    "maxLength": 100
                  },
                  "lastName": {
                    "type": "string",
                    "maxLength": 100
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 255
                  },
                  "phone": {
                    "type": "string",
                    "maxLength": 50
                  },
                  "birthday": {
                    "type": "string",
                    "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
                    "description": "`YYYY-MM-DD`; unlocks birthday perks in many programs. Must be a real calendar date, 1900 or later and not in the future; anything else is a `400`."
                  },
                  "marketingOptIn": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Email already enrolled (`deduped: true`). Nothing created, no member returned.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "deduped": {
                      "const": true
                    }
                  }
                }
              }
            }
          },
          "201": {
            "description": "Member created. Check `passIssuance` per wallet.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "description": "`false` only when a wallet pass failed to issue (`partial: true`)."
                    },
                    "partial": {
                      "type": "boolean"
                    },
                    "passIssuance": {
                      "type": "object",
                      "properties": {
                        "apple": {
                          "type": "boolean"
                        },
                        "google": {
                          "type": "boolean"
                        }
                      }
                    },
                    "data": {
                      "$ref": "#/components/schemas/PublicMember"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/enroll/{slug}/retrieve": {
      "post": {
        "operationId": "retrieveEnrollment",
        "tags": [
          "Enrollment (Public)"
        ],
        "summary": "Re-issue pass links by email",
        "security": [],
        "description": "Look up an existing enrollment by email and return the member's pass\nlinks again. Always responds `200`; check `found`.\n\nA program can switch this lookup off (`enrollment.retrieveByEmail:\nfalse`, returned by `GET /enroll/{slug}`). Such a program answers\nevery email with `found: false`, enrolled or not, so for those\nprograms `found: false` does not mean the email is not enrolled.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/EnrollSlug"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Lookup result.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "const": true
                    },
                    "data": {
                      "oneOf": [
                        {
                          "type": "object",
                          "properties": {
                            "found": {
                              "const": false
                            },
                            "message": {
                              "type": "string"
                            }
                          }
                        },
                        {
                          "allOf": [
                            {
                              "type": "object",
                              "properties": {
                                "found": {
                                  "const": true
                                }
                              }
                            },
                            {
                              "$ref": "#/components/schemas/PublicMember"
                            }
                          ]
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    }
  },
  "webhooks": {
    "member.created": {
      "post": {
        "operationId": "eventMemberCreated",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#member-created"
        },
        "summary": "A member enrolled (any channel)",
        "description": "Fired by enrollment for both public-page and API enrollments.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "event": {
                        "const": "member.created"
                      },
                      "data": {
                        "$ref": "#/components/schemas/MemberCreatedEventData"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    },
    "member.updated": {
      "post": {
        "operationId": "eventMemberUpdated",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#member-updated"
        },
        "summary": "Member fields changed",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEnvelope"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    },
    "points.changed": {
      "post": {
        "operationId": "eventPointsChanged",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#points-changed"
        },
        "summary": "Points balance changed",
        "description": "Fired on every balance adjustment (console, API, reader scan).",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "event": {
                        "const": "points.changed"
                      },
                      "data": {
                        "$ref": "#/components/schemas/PointsChangedEventData"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    },
    "reward.redeemed": {
      "post": {
        "operationId": "eventRewardRedeemed",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#reward-redeemed"
        },
        "summary": "A reward was redeemed",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "event": {
                        "const": "reward.redeemed"
                      },
                      "data": {
                        "$ref": "#/components/schemas/RewardRedeemedEventData"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    },
    "pass.created": {
      "post": {
        "operationId": "eventPassCreated",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#pass-created"
        },
        "summary": "A wallet pass was built",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEnvelope"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    },
    "pass.installed": {
      "post": {
        "operationId": "eventPassInstalled",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#pass-installed"
        },
        "summary": "Pass added to a wallet",
        "description": "Fired by the wallet web service when a device registers the pass.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "event": {
                        "const": "pass.installed"
                      },
                      "data": {
                        "$ref": "#/components/schemas/PassLifecycleEventData"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    },
    "pass.updated": {
      "post": {
        "operationId": "eventPassUpdated",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#pass-updated"
        },
        "summary": "Pass content pushed/refreshed",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "event": {
                        "const": "pass.updated"
                      },
                      "data": {
                        "$ref": "#/components/schemas/PassLifecycleEventData"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    },
    "pass.removed": {
      "post": {
        "operationId": "eventPassRemoved",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#pass-removed"
        },
        "summary": "Pass removed from a wallet",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/WebhookEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "event": {
                        "const": "pass.removed"
                      },
                      "data": {
                        "$ref": "#/components/schemas/PassLifecycleEventData"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    },
    "pass.scanned": {
      "post": {
        "operationId": "eventPassScanned",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#pass-scanned"
        },
        "summary": "Pass scanned at the till",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEnvelope"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    },
    "campaign.sent": {
      "post": {
        "operationId": "eventCampaignSent",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#campaign-sent"
        },
        "summary": "A campaign finished sending",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEnvelope"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    },
    "batch.completed": {
      "post": {
        "operationId": "eventBatchCompleted",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#batch-completed"
        },
        "summary": "A batch job completed",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEnvelope"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    },
    "batch.failed": {
      "post": {
        "operationId": "eventBatchFailed",
        "tags": [
          "Webhook events"
        ],
        "security": [],
        "externalDocs": {
          "description": "Receiving and verifying this event",
          "url": "webhooks.html#batch-failed"
        },
        "summary": "A batch job failed",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEnvelope"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "`X-API-Key: stow_…`, the standard server-to-server scheme."
      },
      "ApiKeyAuthorization": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization",
        "description": "`Authorization: ApiKey stow_…`, an equivalent alternative."
      },
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "Console user session (JWT). Required only for API-key management."
      }
    },
    "parameters": {
      "Page": {
        "name": "page",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "default": 1
        }
      },
      "PageSize": {
        "name": "pageSize",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 25
        }
      },
      "MemberId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "The member's `id` (UUID), not `memberCode` or `serialNumber`."
      },
      "EnrollSlug": {
        "name": "slug",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "The program's public enrollment slug (from the program's enrollment settings)."
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{8,128}$"
        },
        "description": "Strongly recommended. One key per logical gesture; reuse it on\nretries. Completed requests replay the stored response; concurrent\nreuse returns `409`.\n"
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Validation failed.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "success": false,
              "error": {
                "code": "VALIDATION_ERROR",
                "message": "\"email\" must be a valid email"
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid API key / session.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "success": false,
              "error": {
                "code": "INVALID_API_KEY",
                "message": "Invalid or expired API key"
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Authenticated but missing the required permission.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource not found within your merchant.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "Conflict": {
        "description": "An in-flight request already holds this `Idempotency-Key`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limit exceeded. Check the `RateLimit-*` headers and back off.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      }
    },
    "schemas": {
      "ErrorEnvelope": {
        "type": "object",
        "properties": {
          "success": {
            "const": false
          },
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "example": "VALIDATION_ERROR"
              },
              "message": {
                "type": "string"
              },
              "details": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        }
      },
      "ApiKey": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "example": "POS integration for store 14"
          },
          "keyPrefix": {
            "type": "string",
            "example": "stow_8Kf2pQ9x",
            "description": "First characters of the key, safe to display/log."
          },
          "tenantId": {
            "type": "string"
          },
          "roleNames": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "permissions": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "revoked",
              "expired"
            ]
          },
          "lastUsedAt": {
            "type": "string",
            "format": "date-time"
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ApiKeyCreated": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ApiKey"
          },
          {
            "type": "object",
            "properties": {
              "key": {
                "type": "string",
                "example": "stow_8Kf2pQ9xN3...full-secret...",
                "description": "The full secret, returned only at creation. Store it immediately."
              }
            }
          }
        ]
      },
      "Program": {
        "type": "object",
        "description": "Raw program record. Note the **`_id`** key (programs are not\nre-serialized with `id`). Additional designer fields are present and\nmay grow over time.\n",
        "properties": {
          "_id": {
            "type": "string",
            "example": "665b0a0c2f8b9c0012345678"
          },
          "name": {
            "type": "string",
            "example": "Coffee Club"
          },
          "slug": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "active",
              "paused",
              "archived"
            ]
          },
          "programType": {
            "type": "string",
            "example": "loyalty"
          },
          "productType": {
            "type": "string",
            "description": "Merchant-facing pass category (`loyalty`, `membership`, `coupon`, …)."
          },
          "loyaltyStyle": {
            "type": "string",
            "description": "`points` or `stamps` for loyalty programs."
          },
          "balanceKey": {
            "type": "string",
            "description": "The `fieldValues` key this program's balance lives under."
          },
          "stampGoal": {
            "type": "integer",
            "description": "Stamps needed for a reward (stamp programs)."
          },
          "pointsStep": {
            "type": "integer"
          },
          "fields": {
            "type": "object",
            "properties": {
              "primaryLabel": {
                "type": "string",
                "example": "POINTS",
                "description": "The points/stamp unit label; lower-cased it keys `member.fieldValues`."
              }
            },
            "additionalProperties": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": true
      },
      "ProgramSummary": {
        "type": "object",
        "description": "Compact program snapshot embedded in member responses.",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "programType": {
            "type": "string"
          },
          "loyaltyStyle": {
            "type": "string"
          },
          "passType": {
            "type": "string",
            "description": "Internal wallet rendering style. Do not display."
          },
          "balanceKey": {
            "type": "string"
          },
          "stampGoal": {
            "type": "integer"
          },
          "pointsStep": {
            "type": "integer"
          }
        },
        "additionalProperties": true
      },
      "EnrollMemberRequest": {
        "type": "object",
        "required": [
          "programId",
          "firstName",
          "lastName",
          "email"
        ],
        "properties": {
          "programId": {
            "type": "string",
            "description": "Must belong to your merchant and not be archived."
          },
          "firstName": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "lastName": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 255
          },
          "phone": {
            "type": "string",
            "maxLength": 50
          },
          "initialPoints": {
            "type": "integer",
            "minimum": 0,
            "maximum": 1000000,
            "description": "Seeds the program's primary field."
          }
        },
        "example": {
          "programId": "665b0a0c2f8b9c0012345678",
          "firstName": "Ada",
          "lastName": "Lovelace",
          "email": "ada@example.com",
          "initialPoints": 50
        }
      },
      "Member": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "UUID. Use this in API paths."
          },
          "programId": {
            "type": "string"
          },
          "serialNumber": {
            "type": "string",
            "description": "Internal wallet serial. Never display to staff or customers."
          },
          "memberCode": {
            "type": "string",
            "example": "7K2P9QX4M8",
            "description": "The human-facing **\"Member ID\"** that the pass barcode encodes."
          },
          "firstName": {
            "type": "string"
          },
          "lastName": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "phone": {
            "type": "string"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 25
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "voided",
              "expired"
            ]
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time"
          },
          "passStatus": {
            "type": "string",
            "enum": [
              "created",
              "installed",
              "removed"
            ]
          },
          "fieldValues": {
            "type": "object",
            "description": "Balances keyed by the lower-cased program primary label (`points`, `stars`, `stamps`, …).",
            "additionalProperties": {
              "type": "integer"
            },
            "example": {
              "points": 60
            }
          },
          "tier": {
            "type": "string",
            "description": "Current tier name (tiered programs only)."
          },
          "apple": {
            "type": "object",
            "properties": {
              "passUrl": {
                "type": "string",
                "description": "`.pkpass` download URL."
              },
              "installed": {
                "type": "boolean"
              },
              "installedAt": {
                "type": "string",
                "format": "date-time"
              },
              "lastUpdateTag": {
                "type": "string"
              },
              "mode": {
                "type": "string",
                "enum": [
                  "real",
                  "stub"
                ],
                "description": "Whether a real Apple credential signed this pass. Read this; never infer from the URL."
              }
            }
          },
          "google": {
            "type": "object",
            "properties": {
              "saveUrl": {
                "type": "string",
                "description": "Google Wallet save link."
              },
              "installed": {
                "type": "boolean"
              },
              "installedAt": {
                "type": "string",
                "format": "date-time"
              },
              "mode": {
                "type": "string",
                "enum": [
                  "real",
                  "stub"
                ]
              }
            }
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "lastUsedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "MemberWithProgram": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Member"
          },
          {
            "type": "object",
            "properties": {
              "program": {
                "$ref": "#/components/schemas/ProgramSummary"
              }
            }
          }
        ]
      },
      "MemberDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/MemberWithProgram"
          },
          {
            "type": "object",
            "properties": {
              "deviceCount": {
                "type": "integer",
                "description": "Registered wallet devices."
              },
              "redeemedCount": {
                "type": "integer",
                "description": "Lifetime reward redemptions."
              }
            }
          }
        ]
      },
      "PublicMember": {
        "type": "object",
        "description": "Member as returned by the public enrollment endpoints (no auth token, no balance internals).",
        "properties": {
          "id": {
            "type": "string"
          },
          "serialNumber": {
            "type": "string"
          },
          "memberCode": {
            "type": "string"
          },
          "barcode": {
            "type": "object",
            "description": "What this member's pass barcode encodes, resolved from the program's barcode template, so a page can draw the same symbol the wallet shows. Omitted when the template uses a value that is only known when the pass is built (a points balance, for example).",
            "required": [
              "value"
            ],
            "properties": {
              "value": {
                "type": "string",
                "example": "MEMBER-X7K2M9PQ4T"
              },
              "altText": {
                "type": "string",
                "description": "The text printed under the barcode. Absent when the program hides it.",
                "example": "X7K2M9PQ4T"
              }
            }
          },
          "firstName": {
            "type": "string"
          },
          "lastName": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time"
          },
          "apple": {
            "type": "object",
            "properties": {
              "passUrl": {
                "type": "string"
              },
              "installed": {
                "type": "boolean"
              }
            },
            "additionalProperties": true
          },
          "google": {
            "type": "object",
            "properties": {
              "saveUrl": {
                "type": "string"
              },
              "installed": {
                "type": "boolean"
              }
            },
            "additionalProperties": true
          }
        },
        "additionalProperties": true
      },
      "TagsRequest": {
        "type": "object",
        "required": [
          "tags"
        ],
        "properties": {
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "example": [
              "vip",
              "wholesale"
            ]
          }
        }
      },
      "PointsAdjustRequest": {
        "type": "object",
        "description": "Provide exactly one of `delta` or `value`.",
        "properties": {
          "delta": {
            "type": "integer",
            "minimum": -100000,
            "maximum": 100000,
            "description": "Relative change; the balance is clamped at ≥ 0. Stamp programs only accept `1`."
          },
          "value": {
            "type": "integer",
            "minimum": 0,
            "maximum": 1000000,
            "description": "Absolute new balance."
          },
          "changeMessage": {
            "type": "string",
            "maxLength": 200,
            "description": "Shown on the customer's lock screen when the pass updates."
          }
        },
        "example": {
          "delta": 10,
          "changeMessage": "Thanks for visiting!"
        }
      },
      "PassUpdateReport": {
        "type": [
          "object",
          "null"
        ],
        "description": "What happened to the wallet pass after the change. `null` when the\npass service did not confirm the update; the balance change itself\nis already saved.\n",
        "properties": {
          "serialNumber": {
            "type": "string",
            "description": "The pass serial (a UUID). Not the Member ID."
          },
          "lastUpdateTag": {
            "type": "string",
            "description": "Timestamp of the latest pass version. Apple devices compare it to decide whether to fetch the pass again."
          },
          "rebuilt": {
            "type": "boolean",
            "description": "True when the signed pass file was rebuilt (real mode only)."
          },
          "apple": {
            "type": "object",
            "properties": {
              "devicesNotified": {
                "type": "integer",
                "description": "Apple devices that accepted the push."
              },
              "devicesAttempted": {
                "type": "integer",
                "description": "Apple devices registered for this pass."
              },
              "mode": {
                "type": "string",
                "enum": [
                  "real",
                  "stub"
                ],
                "description": "`stub` when Apple credentials are missing or do not match, or when the program's push messages are off."
              }
            }
          },
          "google": {
            "type": "object",
            "properties": {
              "objectPatched": {
                "type": "boolean",
                "description": "True when the Google Wallet pass was updated. Only a pass the customer saved to Google Wallet is updated, and only in real mode."
              },
              "mode": {
                "type": "string",
                "enum": [
                  "real",
                  "stub"
                ]
              }
            }
          }
        },
        "additionalProperties": true
      },
      "PointsAdjustResult": {
        "type": "object",
        "properties": {
          "member": {
            "$ref": "#/components/schemas/Member"
          },
          "previous": {
            "type": "integer",
            "example": 50
          },
          "current": {
            "type": "integer",
            "example": 60
          },
          "passUpdate": {
            "$ref": "#/components/schemas/PassUpdateReport"
          }
        }
      },
      "MemberEvent": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "eventType": {
            "type": "string",
            "description": "`pass_built`, `device_registered`, `push_sent`, `google_object_patched`, `reward_redeemed`, … (open set)."
          },
          "platform": {
            "type": "string",
            "description": "`apple` / `google` where applicable."
          },
          "outcome": {
            "type": "string",
            "enum": [
              "success",
              "failure",
              "skipped"
            ]
          },
          "payload": {
            "type": "object",
            "additionalProperties": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "MemberTransaction": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "memberId": {
            "type": "string"
          },
          "memberCode": {
            "type": "string"
          },
          "programId": {
            "type": "string"
          },
          "programName": {
            "type": "string"
          },
          "action": {
            "type": "string",
            "description": "What happened (adjust, redeem, scan, …)."
          },
          "source": {
            "type": "string"
          },
          "channel": {
            "type": "string"
          },
          "readerId": {
            "type": "string"
          },
          "readerName": {
            "type": "string"
          },
          "locationId": {
            "type": "string"
          },
          "balanceKey": {
            "type": "string"
          },
          "balanceLabel": {
            "type": "string"
          },
          "delta": {
            "type": "integer"
          },
          "balanceBefore": {
            "type": "integer"
          },
          "balanceAfter": {
            "type": "integer"
          },
          "note": {
            "type": "string"
          },
          "tierBefore": {
            "type": "string"
          },
          "tierAfter": {
            "type": "string"
          },
          "tierUpgraded": {
            "type": "boolean"
          },
          "actor": {
            "type": "object",
            "properties": {
              "email": {
                "type": "string"
              },
              "scope": {
                "type": "string"
              }
            }
          }
        },
        "additionalProperties": true
      },
      "RetryPolicy": {
        "type": "object",
        "properties": {
          "maxAttempts": {
            "type": "integer",
            "minimum": 1,
            "maximum": 5,
            "default": 3
          },
          "backoffMs": {
            "type": "integer",
            "minimum": 100,
            "maximum": 10000,
            "default": 500
          },
          "timeoutMs": {
            "type": "integer",
            "minimum": 500,
            "maximum": 30000,
            "default": 5000
          }
        }
      },
      "WebhookSubscriptionCreateRequest": {
        "type": "object",
        "required": [
          "name",
          "url"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 200,
            "example": "Zapier hook"
          },
          "description": {
            "type": "string",
            "maxLength": 1000
          },
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 2048,
            "description": "Must be **https**.",
            "example": "https://hooks.example.com/stow"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "member.created",
                "member.updated",
                "points.changed",
                "reward.redeemed",
                "pass.created",
                "pass.installed",
                "pass.updated",
                "pass.removed",
                "pass.scanned",
                "campaign.sent",
                "batch.completed",
                "batch.failed",
                "*"
              ]
            },
            "default": [
              "member.created"
            ]
          },
          "enabled": {
            "type": "boolean",
            "default": true
          },
          "secret": {
            "type": "string",
            "maxLength": 500,
            "description": "Optional. Omit to have one generated (recommended)."
          },
          "retryPolicy": {
            "$ref": "#/components/schemas/RetryPolicy"
          }
        }
      },
      "WebhookSubscriptionPatchRequest": {
        "type": "object",
        "minProperties": 1,
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 200
          },
          "description": {
            "type": "string",
            "maxLength": 1000
          },
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 2048
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "enabled": {
            "type": "boolean"
          },
          "retryPolicy": {
            "$ref": "#/components/schemas/RetryPolicy"
          }
        }
      },
      "WebhookSubscription": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "url": {
            "type": "string"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "enabled": {
            "type": "boolean"
          },
          "retryPolicy": {
            "$ref": "#/components/schemas/RetryPolicy"
          },
          "lastTriggeredAt": {
            "type": "string",
            "format": "date-time"
          },
          "lastSuccessAt": {
            "type": "string",
            "format": "date-time"
          },
          "lastFailureAt": {
            "type": "string",
            "format": "date-time"
          },
          "lastError": {
            "type": "string"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WebhookSubscriptionWithSecret": {
        "allOf": [
          {
            "$ref": "#/components/schemas/WebhookSubscription"
          },
          {
            "type": "object",
            "properties": {
              "secret": {
                "type": "string",
                "description": "The signing secret, returned only on create and rotate."
              }
            }
          }
        ]
      },
      "WebhookDeliveryLog": {
        "type": "object",
        "description": "One delivery attempt record. `payload` is PII-masked.",
        "properties": {
          "_id": {
            "type": "string"
          },
          "subscriptionId": {
            "type": "string"
          },
          "eventType": {
            "type": "string"
          },
          "eventId": {
            "type": "string"
          },
          "success": {
            "type": "boolean"
          },
          "statusCode": {
            "type": "integer"
          },
          "attempts": {
            "type": "integer"
          },
          "responseTimeMs": {
            "type": "integer"
          },
          "lastError": {
            "type": "string"
          },
          "payload": {
            "type": "object",
            "additionalProperties": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": true
      },
      "WebhookEnvelope": {
        "type": "object",
        "description": "CloudEvents-flavoured envelope delivered to your endpoint. Dedupe on\n`eventId`; treat unknown fields as additive.\n",
        "properties": {
          "specversion": {
            "const": "1.0"
          },
          "id": {
            "type": "string",
            "description": "Same as `eventId`."
          },
          "type": {
            "type": "string",
            "example": "cards.stow.points.changed"
          },
          "time": {
            "type": "string",
            "format": "date-time"
          },
          "event": {
            "type": "string",
            "example": "points.changed"
          },
          "eventId": {
            "type": "string",
            "description": "Stable across retries and replays. Use it as your dedupe key."
          },
          "occurredAt": {
            "type": "string",
            "format": "date-time"
          },
          "tenantId": {
            "type": "string",
            "description": "Your merchant id."
          },
          "sourceService": {
            "type": "string"
          },
          "data": {
            "type": "object",
            "description": "Event-specific payload (see per-event schemas).",
            "additionalProperties": true
          }
        }
      },
      "MemberCreatedEventData": {
        "type": "object",
        "properties": {
          "memberId": {
            "type": "string"
          },
          "memberCode": {
            "type": "string"
          },
          "programId": {
            "type": "string"
          },
          "merchantId": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "firstName": {
            "type": "string"
          },
          "lastName": {
            "type": "string"
          },
          "source": {
            "type": "string",
            "description": "Enrollment channel (public page, console, API, import)."
          },
          "attribution": {
            "type": "object",
            "additionalProperties": true
          },
          "passIssuance": {
            "type": "object",
            "properties": {
              "apple": {
                "type": "boolean"
              },
              "google": {
                "type": "boolean"
              }
            }
          }
        },
        "additionalProperties": true
      },
      "PointsChangedEventData": {
        "type": "object",
        "properties": {
          "memberId": {
            "type": "string"
          },
          "memberCode": {
            "type": "string"
          },
          "programId": {
            "type": "string"
          },
          "merchantId": {
            "type": "string"
          },
          "field": {
            "type": "string",
            "description": "The `fieldValues` key that changed."
          },
          "previous": {
            "type": "integer"
          },
          "current": {
            "type": "integer"
          },
          "delta": {
            "type": "integer"
          },
          "tier": {
            "type": "string",
            "description": "Tiered programs only."
          },
          "previousTier": {
            "type": "string"
          },
          "tierUpgraded": {
            "type": "boolean"
          },
          "changeMessage": {
            "type": "string"
          },
          "passUpdate": {
            "$ref": "#/components/schemas/PassUpdateReport"
          }
        },
        "additionalProperties": true
      },
      "RewardRedeemedEventData": {
        "type": "object",
        "properties": {
          "memberId": {
            "type": "string"
          },
          "memberCode": {
            "type": "string"
          },
          "programId": {
            "type": "string"
          },
          "merchantId": {
            "type": "string"
          },
          "field": {
            "type": "string"
          },
          "previous": {
            "type": "integer"
          },
          "current": {
            "type": "integer",
            "description": "Post-redeem balance (0 for stamp resets)."
          },
          "goal": {
            "type": "integer"
          },
          "changeMessage": {
            "type": "string"
          },
          "passUpdate": {
            "$ref": "#/components/schemas/PassUpdateReport"
          }
        },
        "additionalProperties": true
      },
      "PassLifecycleEventData": {
        "type": "object",
        "properties": {
          "memberId": {
            "type": "string"
          },
          "memberCode": {
            "type": "string"
          },
          "serialNumber": {
            "type": "string"
          },
          "programId": {
            "type": "string"
          },
          "platform": {
            "type": "string",
            "enum": [
              "apple",
              "google"
            ]
          }
        },
        "additionalProperties": true
      }
    }
  }
}
