{
  "openapi": "3.1.0",
  "info": {
    "title": "Perx Connect — Partner API",
    "version": "1.0.0",
    "summary": "Public integration surface for POS, ERP and eCommerce vendors.",
    "description": "The vendor-facing surface of `perx-connect`, Perx's integration gateway.\n\nVendors never call `perx-core-api` directly — every request lands here, is authenticated against the merchant's own connection secret, resolved to a merchant and outlet, and forwarded to the loyalty engine.\n\nTwo integration tiers share this API:\n\n- **Generic** — you send Perx's canonical payload to the shared `/webhooks/generic` endpoint. No Perx engineering required.\n- **Featured** — Perx writes a native adapter for your existing payload, served at a dedicated `/webhooks/{slug}` endpoint not documented here.\n\nEndpoints marked `x-status: coming` are implemented and in review but not yet enabled in production. Their shapes are final — build against them now.",
    "contact": { "name": "Perx Integration Support", "url": "https://perx.mv" },
    "license": { "name": "Proprietary — partner use only", "identifier": "LicenseRef-Perx-Partner" }
  },
  "servers": [
    { "url": "https://uat-connect.perx.mv", "description": "UAT — build and certify here" },
    { "url": "https://beta-connect.perx.mv", "description": "Beta — pre-production merchant pilots" },
    { "url": "https://connect.perx.mv", "description": "Production — issued at go-live" }
  ],
  "tags": [
    { "name": "Webhooks", "description": "Push sale and refund events to Perx. Start here — earn is the whole integration for most vendors." },
    { "name": "Redemption", "description": "Synchronous calls your POS makes during checkout to spend rewards and points." },
    { "name": "Catalog", "description": "Push your item catalog so merchants can build item-level stamp rules against a real menu." },
    { "name": "System", "description": "Health and diagnostics." }
  ],
  "security": [{ "PerxSignature": [] }],
  "paths": {
    "/webhooks/generic": {
      "post": {
        "operationId": "postGenericWebhook",
        "summary": "Submit a sale or refund event",
        "tags": ["Webhooks"],
        "description": "Send one request per finalised sale, as soon as it is paid. Do not send open, parked or quoted orders.\n\nIdempotent on `transaction.id`, scoped to your vendor slug and store — sending the same transaction twice earns points once, enforced both by a 24-hour gateway cache and permanently in the loyalty ledger.\n\nAlways returns `200` for events Perx cannot act on (unknown event type, unknown customer) so that a normal business outcome never triggers a retry on your side.",
        "parameters": [
          { "$ref": "#/components/parameters/Vendor" },
          { "$ref": "#/components/parameters/Store" },
          { "$ref": "#/components/parameters/Timestamp" },
          { "$ref": "#/components/parameters/Signature" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/Envelope" },
              "examples": {
                "sale": { "summary": "Completed sale with an identified customer", "value": { "eventId": "evt_2026-08-27_00041", "eventType": "sale.completed", "occurredAt": "2026-08-27T09:14:22Z", "store": { "id": "12345", "name": "Majeedhee Magu" }, "transaction": { "id": "INV-2026-0042", "total": 45.0, "currency": "MVR", "channel": "pos", "customer": { "type": "phone", "value": "+9607123456" }, "items": [ { "id": "prod-8891", "name": "Flat White", "sku": "SKU-COFFEE-001", "quantity": 2, "unitPrice": 5.5 }, { "id": "prod-2210", "name": "Chicken Sandwich", "sku": "SKU-FOOD-014", "quantity": 1, "unitPrice": 34.0 } ] } } },
                "walkIn": { "summary": "Walk-in with no customer identifier", "value": { "eventId": "evt_2026-08-27_00042", "eventType": "sale.completed", "occurredAt": "2026-08-27T09:20:00Z", "store": { "id": "12345" }, "transaction": { "id": "INV-2026-0043", "total": 12.0, "currency": "MVR", "items": [] } } },
                "perxCode": { "summary": "Order redeeming a Perx online reward code", "value": { "eventId": "evt_2026-08-27_00043", "eventType": "sale.completed", "occurredAt": "2026-08-27T10:02:00Z", "store": { "id": "12345" }, "transaction": { "id": "INV-2026-0044", "total": 25.0, "currency": "MVR", "channel": "web", "customer": { "type": "email", "value": "aisha@example.mv" }, "items": [ { "name": "Set Menu", "sku": "SKU-SET-01", "quantity": 1, "unitPrice": 75.0 } ], "discountCodes": [ { "code": "PERX-K7Q2M9XW", "amount": 50.0, "type": "fixed_amount" } ] } } },
                "refund": { "summary": "Partial refund of an earlier sale", "value": { "eventId": "evt_2026-08-27_00099", "eventType": "sale.refunded", "occurredAt": "2026-08-27T11:02:10Z", "store": { "id": "12345" }, "transaction": { "id": "INV-2026-0042", "currency": "MVR" }, "refund": { "id": "CRN-2026-0007", "amount": 45.0 } } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted. Inspect `status` in the body — `duplicate` and `ignored` are successes, not errors.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WebhookResult" },
                "examples": {
                  "processed": { "summary": "Points earned, stamp card completed", "value": { "status": "processed", "membershipId": "0f6a1c9e-4b7d-4c2a-9f31-8a2d5e6b0c31", "pointsEarned": 45, "stampsUpdated": [ { "rewardId": "b21e77a0-3c14-4e77-9a02-1d55f0b9c8e2", "rewardTitle": "Coffee Club", "current": 5, "required": 5, "completed": true } ], "warning": null } },
                  "unknownCustomer": { "summary": "Customer is not a Perx member", "value": { "status": "processed", "membershipId": "", "pointsEarned": 0, "stampsUpdated": [], "warning": "Customer not found (phone=+9607123456) — transaction logged but no points/stamps awarded" } },
                  "duplicate": { "summary": "Transaction already processed", "value": { "status": "duplicate", "membershipId": "", "pointsEarned": 0, "stampsUpdated": [], "warning": null } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "422": { "$ref": "#/components/responses/Unprocessable" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/v1/redemption/eligibility": {
      "post": {
        "operationId": "postEligibility",
        "summary": "List what a customer can redeem",
        "tags": ["Redemption"],
        "x-status": "coming",
        "description": "Populates the cashier's reward picker. Read-only and safe to call every time the cart changes.\n\nA customer who is not a Perx member returns `200` with an empty `membershipId` and a `warning` — **not** a `404`. Check `membershipId` before rendering a picker.",
        "parameters": [
          { "$ref": "#/components/parameters/Vendor" },
          { "$ref": "#/components/parameters/Store" },
          { "$ref": "#/components/parameters/Timestamp" },
          { "$ref": "#/components/parameters/Signature" }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EligibilityRequest" }, "example": { "customer": { "type": "phone", "value": "+9607123456" }, "orderValue": 45.0 } } }
        },
        "responses": {
          "200": { "description": "Resolved balance and reward list.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EligibilityResult" }, "example": { "membershipId": "0f6a1c9e-4b7d-4c2a-9f31-8a2d5e6b0c31", "walletBalance": 1240, "redemptionRate": 10, "eligibleRewards": [ { "rewardId": "b21e77a0-3c14-4e77-9a02-1d55f0b9c8e2", "title": "Free Coffee", "type": "STAMP", "pointsCost": null, "discountValue": 45.0, "eligible": true, "reason": null }, { "rewardId": "9c04e2b1-77aa-4c31-8f0d-2b6e91a4d770", "title": "MVR 100 off", "type": "POINTS", "pointsCost": 1000, "discountValue": 100.0, "eligible": true, "reason": null }, { "rewardId": "77af0c12-9de4-4b88-a1e7-3c520f9b6a44", "title": "Birthday Cake", "type": "FREE_ITEM", "pointsCost": null, "discountValue": 250.0, "eligible": false, "reason": "Minimum spend of MVR 300 not met" } ], "warning": null } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "502": { "$ref": "#/components/responses/UpstreamError" }
        }
      }
    },
    "/v1/redemption/redeem": {
      "post": {
        "operationId": "postRedeem",
        "summary": "Redeem a reward or points",
        "tags": ["Redemption"],
        "x-status": "coming",
        "description": "Call at **payment success**, not at cart time — there is no reserve/hold in v1. A redemption committed against an abandoned sale must be reversed with a `sale.refunded` event.\n\nProvide exactly one of `rewardId` or `pointsToRedeem`. Idempotent per (`externalTransactionId`, reward): a retry returns `ALREADY_PROCESSED` without double-deducting.\n\n`externalTransactionId` must be the same value you later send as `transaction.id` on the earn webhook — that is what binds the redemption to the sale for refunds.",
        "parameters": [
          { "$ref": "#/components/parameters/Vendor" },
          { "$ref": "#/components/parameters/Store" },
          { "$ref": "#/components/parameters/Timestamp" },
          { "$ref": "#/components/parameters/Signature" }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RedeemRequest" }, "examples": { "reward": { "summary": "Redeem a specific reward", "value": { "customer": { "type": "phone", "value": "+9607123456" }, "externalTransactionId": "INV-2026-0042", "rewardId": "9c04e2b1-77aa-4c31-8f0d-2b6e91a4d770", "orderValue": 45.0 } }, "points": { "summary": "Spend points directly against the bill", "value": { "customer": { "type": "membershipId", "value": "0f6a1c9e-4b7d-4c2a-9f31-8a2d5e6b0c31" }, "externalTransactionId": "INV-2026-0042", "pointsToRedeem": 500, "orderValue": 45.0 } } } } }
        },
        "responses": {
          "200": { "description": "Redeemed, or already redeemed on a previous identical call.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RedeemResult" }, "example": { "status": "REDEEMED", "membershipId": "0f6a1c9e-4b7d-4c2a-9f31-8a2d5e6b0c31", "redemptionId": "4d81b0a7-6c33-49f2-b0aa-77e1d2c48f90", "rewardId": "9c04e2b1-77aa-4c31-8f0d-2b6e91a4d770", "pointsDeducted": 1000, "walletBalance": 240 } } } },
          "400": { "description": "Not eligible, insufficient points, or both/neither of rewardId and pointsToRedeem supplied. `message` is written for the cashier — display it verbatim.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "statusCode": 400, "message": "Not enough points — 1000 required, balance is 240", "requestId": "5f2c9a1e-0d47-4a2b-9c88-31ea7b40d6f1" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "description": "Customer is not a Perx member. (Eligibility returns 200 + `warning` for this case; redeem returns 404.)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "Already redeemed against this order — treat as success.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "502": { "$ref": "#/components/responses/UpstreamError" }
        }
      }
    },
    "/v1/refund": {
      "post": {
        "operationId": "postRefund",
        "summary": "Reverse loyalty for a refunded sale",
        "tags": ["Redemption"],
        "x-status": "coming",
        "description": "Direct equivalent of sending a `sale.refunded` webhook — use whichever fits your architecture. Reverses points earned on the original sale (proportionally when `refundAmount` is set) and re-credits any points reward redeemed against it.\n\nIdempotent on `refundTransactionId`, so two partial refunds of one sale are two distinct events.",
        "parameters": [
          { "$ref": "#/components/parameters/Vendor" },
          { "$ref": "#/components/parameters/Store" },
          { "$ref": "#/components/parameters/Timestamp" },
          { "$ref": "#/components/parameters/Signature" }
        ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RefundRequest" }, "example": { "externalTransactionId": "INV-2026-0042", "refundTransactionId": "CRN-2026-0007", "refundAmount": 45.0 } } } },
        "responses": {
          "200": { "description": "Reversal outcome. `NOTHING_TO_REVERSE` is a success — it means the original sale never reached Perx or earned nothing.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RefundResult" }, "example": { "status": "REVERSED", "pointsReversed": 45, "pointsReCredited": 1000 } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "502": { "$ref": "#/components/responses/UpstreamError" }
        }
      }
    },
    "/v1/catalog/sync": {
      "post": {
        "operationId": "postCatalogSync",
        "summary": "Push your item catalog",
        "tags": ["Catalog"],
        "x-status": "coming",
        "description": "Full-snapshot semantics: send your **entire active catalog** on each call. Items absent from the payload are marked inactive, never hard-deleted. A nightly push is plenty.\n\nThis is what lets a merchant build \"buy 5 flat whites, get one free\" by picking from a real menu instead of typing item names. Featured vendors do not build this — Perx pulls from your catalog API instead.",
        "parameters": [
          { "$ref": "#/components/parameters/Vendor" },
          { "$ref": "#/components/parameters/Store" },
          { "$ref": "#/components/parameters/Timestamp" },
          { "$ref": "#/components/parameters/Signature" }
        ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CatalogSyncRequest" }, "example": { "store": { "id": "12345" }, "items": [ { "externalId": "prod-8891", "name": "Flat White", "sku": "SKU-COFFEE-001", "category": "Beverages", "price": 5.5, "isActive": true }, { "externalId": "prod-2210", "name": "Chicken Sandwich", "sku": "SKU-FOOD-014", "category": "Food", "price": 34.0, "isActive": true } ] } } } },
        "responses": {
          "200": { "description": "Catalog accepted.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CatalogSyncResult" }, "example": { "status": "synced", "itemsReceived": 2, "itemsCreated": 0, "itemsUpdated": 2, "itemsDeactivated": 0 } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "502": { "$ref": "#/components/responses/UpstreamError" }
        }
      }
    },
    "/health": {
      "get": {
        "operationId": "getHealth",
        "summary": "Gateway health check",
        "tags": ["System"],
        "security": [],
        "description": "Unauthenticated liveness probe. Use it to verify network reachability and TLS from your environment before debugging signatures.",
        "responses": { "200": { "description": "Gateway is up.", "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } } } }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "PerxSignature": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Perx-Signature",
        "description": "Every request carries four headers. The signature is `v1=` followed by base64 HMAC-SHA256 over the timestamp and the exact raw body bytes joined by a period:\n\n```\nsigningString = X-Perx-Timestamp + \".\" + rawRequestBody\nsignature     = \"v1=\" + base64(HMAC_SHA256(signingString, connectionSecret))\n```\n\nSign the bytes you actually transmit — serialising, signing, then re-serialising produces different bytes and fails.\n\nRequests whose timestamp is more than 300 seconds from server time are rejected, which is what makes a captured request un-replayable. Keep your servers on NTP.\n\nThe `connectionSecret` is generated by the merchant in their Perx dashboard, per store, and pasted into your product."
      }
    },
    "parameters": {
      "Vendor": { "name": "X-Perx-Vendor", "in": "header", "required": true, "schema": { "type": "string", "example": "acmepos" }, "description": "Your vendor slug, assigned by Perx at onboarding. Lowercase and permanent." },
      "Store": { "name": "X-Perx-Store", "in": "header", "required": true, "schema": { "type": "string", "example": "12345" }, "description": "The store/location identifier on your side, exactly as the merchant entered it in Perx. Routes the request to the right merchant and outlet." },
      "Timestamp": { "name": "X-Perx-Timestamp", "in": "header", "required": true, "schema": { "type": "string", "example": "1787857200" }, "description": "Unix epoch seconds at the moment of signing, as a decimal string. Must be within 300 seconds of Perx server time." },
      "Signature": { "name": "X-Perx-Signature", "in": "header", "required": true, "schema": { "type": "string", "example": "v1=8Yk2n1pQe4Rm0sTvXw7ZaB3cD5eF6gH8iJ9kL0mN1oP=" }, "description": "`v1=` + base64(HMAC-SHA256(timestamp + \".\" + rawBody, connectionSecret))." }
    },
    "schemas": {
      "Envelope": {
        "type": "object",
        "required": ["eventId", "eventType", "occurredAt", "store", "transaction"],
        "properties": {
          "eventId": { "type": "string", "maxLength": 255, "description": "Unique per event you emit — used to collapse your own retries. If you have no such id, use the transaction id with a suffix.", "example": "evt_2026-08-27_00041" },
          "eventType": { "type": "string", "enum": ["sale.completed", "sale.refunded", "sale.voided"], "description": "`sale.voided` is accepted and ignored — useful as a no-op for a Test Connection button." },
          "occurredAt": { "type": "string", "format": "date-time", "description": "When the sale was finalised, not when you sent it. Backdated values are accepted, so a queued event delivered hours late still earns correctly.", "example": "2026-08-27T09:14:22Z" },
          "store": { "$ref": "#/components/schemas/Store" },
          "transaction": { "$ref": "#/components/schemas/Transaction" },
          "refund": { "$ref": "#/components/schemas/Refund" }
        }
      },
      "Store": {
        "type": "object",
        "required": ["id"],
        "properties": {
          "id": { "type": "string", "maxLength": 255, "description": "Must equal the `X-Perx-Store` header. A mismatch is a `400` — this stops a valid signature for store A carrying a payload for store B.", "example": "12345" },
          "name": { "type": "string", "description": "Display only. Helps support identify the outlet.", "example": "Majeedhee Magu" }
        }
      },
      "Transaction": {
        "type": "object",
        "required": ["id", "total", "currency", "items"],
        "properties": {
          "id": { "type": "string", "maxLength": 255, "description": "Your order/invoice/bill number. **This is the idempotency key** and the reference you reuse for refunds.", "example": "INV-2026-0042" },
          "total": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01, "description": "What the customer actually paid, **after** discounts, in `currency`. Points are calculated on this.", "example": 45.0 },
          "currency": { "type": "string", "maxLength": 10, "description": "ISO 4217.", "example": "MVR" },
          "channel": { "type": "string", "maxLength": 50, "description": "Order channel — lets merchants write channel-specific rules.", "examples": ["pos", "web", "app", "kiosk"] },
          "customer": { "$ref": "#/components/schemas/CustomerIdentifier" },
          "items": { "type": "array", "description": "May be empty, but then item-based stamp rewards cannot match. Send lines whenever you have them.", "items": { "$ref": "#/components/schemas/Item" } },
          "discountCodes": { "type": "array", "description": "Report **every** code applied. Perx matches `PERX-` prefixed codes against issued online rewards; other codes are ignored harmlessly.", "items": { "$ref": "#/components/schemas/DiscountCode" } }
        }
      },
      "CustomerIdentifier": {
        "type": "object",
        "required": ["type", "value"],
        "description": "Omit the whole object for a walk-in with no identifier. If you can store the `membershipId` returned by eligibility, send that on later sales — it removes all matching ambiguity.",
        "properties": {
          "type": { "type": "string", "enum": ["phone", "email", "membershipId", "externalId"] },
          "value": { "type": "string", "maxLength": 255, "description": "For `phone`, send full E.164 with the leading plus (`+9607123456`). A bare local Maldivian number (`7123456`) is also accepted. Do **not** send a country code without the plus.", "example": "+9607123456" }
        }
      },
      "Item": {
        "type": "object",
        "required": ["name", "quantity", "unitPrice"],
        "properties": {
          "id": { "type": "string", "description": "Your product or variant id.", "example": "prod-8891" },
          "name": { "type": "string", "maxLength": 255, "description": "Used for keyword matching on stamp rules.", "example": "Flat White" },
          "sku": { "type": "string", "maxLength": 255, "description": "Used for exact matching on stamp rules. Send it if you have it — it makes merchant rules far more reliable than names.", "example": "SKU-COFFEE-001" },
          "quantity": { "type": "integer", "minimum": 1, "example": 2 },
          "unitPrice": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01, "description": "Price for one unit, before multiplying by quantity.", "example": 5.5 }
        }
      },
      "DiscountCode": {
        "type": "object",
        "required": ["code"],
        "properties": {
          "code": { "type": "string", "maxLength": 255, "example": "PERX-K7Q2M9XW" },
          "amount": { "type": "number", "minimum": 0, "description": "Amount discounted, in order currency.", "example": 50.0 },
          "type": { "type": "string", "maxLength": 50, "examples": ["fixed_amount", "percentage"] }
        }
      },
      "Refund": {
        "type": "object",
        "required": ["id"],
        "description": "Present only on `sale.refunded`. `transaction.id` must be the **original** sale's id.",
        "properties": {
          "id": { "type": "string", "maxLength": 255, "description": "Unique id for this refund — the idempotency key for the reversal.", "example": "CRN-2026-0007" },
          "amount": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01, "description": "Omit for a full refund. When set, points reverse in proportion to the original order total.", "example": 45.0 }
        }
      },
      "WebhookResult": {
        "type": "object",
        "required": ["status"],
        "properties": {
          "status": { "type": "string", "enum": ["processed", "duplicate", "ignored"], "description": "`duplicate` and `ignored` are successes — stop retrying." },
          "membershipId": { "type": "string", "description": "Empty when the customer could not be resolved.", "example": "0f6a1c9e-4b7d-4c2a-9f31-8a2d5e6b0c31" },
          "pointsEarned": { "type": "integer", "example": 45 },
          "stampsUpdated": { "type": "array", "items": { "$ref": "#/components/schemas/StampUpdate" } },
          "warning": { "type": ["string", "null"], "description": "Set when the sale was accepted but earned nothing — most often an unrecognised customer." }
        }
      },
      "StampUpdate": {
        "type": "object",
        "description": "Show a completed stamp card on the receipt if you can — it is the single highest-value thing you can do for the merchant's customers.",
        "properties": {
          "rewardId": { "type": "string" },
          "rewardTitle": { "type": "string", "example": "Coffee Club" },
          "current": { "type": "integer", "example": 5 },
          "required": { "type": "integer", "example": 5 },
          "completed": { "type": "boolean", "example": true }
        }
      },
      "EligibilityRequest": {
        "type": "object",
        "required": ["customer"],
        "properties": {
          "customer": { "$ref": "#/components/schemas/CustomerIdentifier" },
          "orderValue": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01, "description": "Current cart total — used to evaluate minimum-spend eligibility.", "example": 45.0 }
        }
      },
      "EligibilityResult": {
        "type": "object",
        "properties": {
          "membershipId": { "type": "string", "description": "Empty string when the customer is not a Perx member. Check this before rendering a picker." },
          "walletBalance": { "type": "integer", "description": "Current spendable points.", "example": 1240 },
          "redemptionRate": { "type": "number", "description": "Points required per one unit of currency, for a spend-points-directly option.", "example": 10 },
          "eligibleRewards": { "type": "array", "items": { "$ref": "#/components/schemas/EligibleReward" } },
          "warning": { "type": ["string", "null"] }
        }
      },
      "EligibleReward": {
        "type": "object",
        "properties": {
          "rewardId": { "type": "string" },
          "title": { "type": "string", "example": "Free Coffee" },
          "type": { "type": "string", "enum": ["STAMP", "POINTS", "DISCOUNT", "FREE_ITEM", "TIER"] },
          "pointsCost": { "type": ["integer", "null"], "description": "Points required — POINTS rewards only." },
          "discountValue": { "type": ["number", "null"], "description": "Configured monetary value of the reward." },
          "eligible": { "type": "boolean" },
          "reason": { "type": ["string", "null"], "description": "Human-readable explanation when `eligible` is false. Always display it — cashiers get asked why." }
        }
      },
      "RedeemRequest": {
        "type": "object",
        "required": ["customer", "externalTransactionId"],
        "description": "Provide exactly one of `rewardId` or `pointsToRedeem`.",
        "properties": {
          "customer": { "$ref": "#/components/schemas/CustomerIdentifier" },
          "externalTransactionId": { "type": "string", "maxLength": 255, "description": "Must match the `transaction.id` you will send on the earn webhook.", "example": "INV-2026-0042" },
          "rewardId": { "type": "string", "format": "uuid" },
          "pointsToRedeem": { "type": "integer", "exclusiveMinimum": 0, "example": 500 },
          "orderValue": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01, "example": 45.0 }
        }
      },
      "RedeemResult": {
        "type": "object",
        "properties": {
          "status": { "type": "string", "enum": ["REDEEMED", "ALREADY_PROCESSED"], "description": "`ALREADY_PROCESSED` means a retry of an identical call — the customer was not charged twice." },
          "membershipId": { "type": "string" },
          "redemptionId": { "type": ["string", "null"] },
          "rewardId": { "type": ["string", "null"] },
          "pointsDeducted": { "type": "integer", "example": 1000 },
          "walletBalance": { "type": "integer", "description": "Balance after the redemption.", "example": 240 }
        }
      },
      "RefundRequest": {
        "type": "object",
        "required": ["externalTransactionId", "refundTransactionId"],
        "properties": {
          "externalTransactionId": { "type": "string", "maxLength": 255, "description": "The **original** sale's id.", "example": "INV-2026-0042" },
          "refundTransactionId": { "type": "string", "maxLength": 255, "description": "Unique id of this refund — the idempotency key.", "example": "CRN-2026-0007" },
          "refundAmount": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01, "description": "Omit for a full refund." }
        }
      },
      "RefundResult": {
        "type": "object",
        "properties": {
          "status": { "type": "string", "enum": ["REVERSED", "ALREADY_PROCESSED", "NOTHING_TO_REVERSE"] },
          "pointsReversed": { "type": "integer", "description": "Points removed from the wallet (earn reversal).", "example": 45 },
          "pointsReCredited": { "type": "integer", "description": "Points returned to the wallet (re-credited redemption).", "example": 1000 }
        }
      },
      "CatalogSyncRequest": {
        "type": "object",
        "required": ["store", "items"],
        "properties": {
          "store": { "$ref": "#/components/schemas/Store" },
          "items": { "type": "array", "items": { "$ref": "#/components/schemas/CatalogItem" } }
        }
      },
      "CatalogItem": {
        "type": "object",
        "required": ["externalId", "name", "isActive"],
        "properties": {
          "externalId": { "type": "string", "maxLength": 255, "description": "Item id on your side — the stable key across syncs.", "example": "prod-8891" },
          "name": { "type": "string", "maxLength": 255, "example": "Flat White" },
          "sku": { "type": "string", "maxLength": 255, "example": "SKU-COFFEE-001" },
          "category": { "type": "string", "maxLength": 255, "example": "Beverages" },
          "price": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01, "example": 5.5 },
          "isActive": { "type": "boolean", "example": true }
        }
      },
      "CatalogSyncResult": {
        "type": "object",
        "properties": {
          "status": { "type": "string", "enum": ["synced"] },
          "itemsReceived": { "type": "integer" },
          "itemsCreated": { "type": "integer" },
          "itemsUpdated": { "type": "integer" },
          "itemsDeactivated": { "type": "integer", "description": "Items previously known but absent from this snapshot." }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "statusCode": { "type": "integer", "example": 400 },
          "message": { "type": "string", "description": "On business errors this is written for the cashier — display it verbatim.", "example": "Not enough points — 1000 required, balance is 240" },
          "code": { "type": "string", "description": "Stable machine-readable reason, where one applies.", "examples": ["bad_signature", "stale_timestamp", "unknown_connection", "validation_failed", "store_mismatch"] },
          "fields": { "type": "array", "description": "Present on validation failures — the specific fields at fault.", "items": { "type": "object", "properties": { "path": { "type": "string", "example": "transaction.items[0].unitPrice" }, "message": { "type": "string", "example": "must be a positive number with at most 2 decimal places" } } } },
          "requestId": { "type": "string", "description": "Echoes `X-Request-ID`. Quote this to Perx support and we can trace the exact delivery." }
        }
      }
    },
    "responses": {
      "BadRequest": { "description": "Malformed body, missing required field, or `store.id` does not match the `X-Perx-Store` header. Do not retry — fix the payload.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "statusCode": 400, "message": "store.id does not match X-Perx-Store", "code": "store_mismatch", "requestId": "5f2c9a1e-0d47-4a2b-9c88-31ea7b40d6f1" } } } },
      "Unauthorized": { "description": "Bad or missing signature, unknown vendor or store, timestamp outside the 300s window, or the merchant's connection is inactive. Do not retry — check credentials and your clock. After a previously working integration this usually means the merchant rotated or removed their connection; surface it in your admin UI rather than retrying forever.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "statusCode": 401, "message": "Invalid signature", "code": "bad_signature", "requestId": "5f2c9a1e-0d47-4a2b-9c88-31ea7b40d6f1" } } } },
      "Unprocessable": { "description": "Well-formed JSON that fails validation — unknown fields, wrong types, non-positive totals. `fields` names each problem. Do not retry.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "statusCode": 422, "message": "Payload validation failed", "code": "validation_failed", "fields": [ { "path": "transaction.items[0].unitPrice", "message": "must be a positive number with at most 2 decimal places" } ], "requestId": "5f2c9a1e-0d47-4a2b-9c88-31ea7b40d6f1" } } } },
      "RateLimited": { "description": "Too many requests. Retry, honouring `Retry-After`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "ServerError": { "description": "Perx-side fault. Retry with exponential backoff — suggested 1s, 5s, 30s, 2m, 10m, 1h, then dead-letter. Never block a sale on this.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "UpstreamError": { "description": "Perx loyalty engine temporarily unreachable. Retry once, then complete the sale without loyalty and queue the earn event.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
    }
  }
}
