{
  "openapi": "3.1.0",
  "info": {
    "title": "nextech connecthub — Partner API",
    "version": "1.0.0",
    "summary": "Two-way integration API for aggregators, OTAs and technology partners.",
    "description": "The connecthub Partner API lets an authorized partner read a tenant's branches and menu, ingest orders into the shared order spine (KDS + stock + accounting), and receive signed outbound webhooks.\n\n**Authentication** is OAuth2 `client_credentials`: POST your `client_id`/`client_secret` to `/partner-oauth-token` and receive a short-lived (1h) Bearer token carrying your granted scopes. Every data endpoint additionally checks that your app holds an **active grant** for the `tenant_id` you name — a partner can only ever touch tenants that authorized it.\n\n**Rate limits & cost** are surfaced on every authenticated response via `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` and `X-Request-Cost` headers.\n\n**Idempotency**: order ingest is keyed on `(tenant_id, provider, external_order_id)` — retrying the same external order always returns the same internal order, never a duplicate.",
    "contact": { "name": "nextech connecthub", "url": "https://connecthub.nextechhq.com" }
  },
  "servers": [
    { "url": "https://d.nextechhq.com/functions/v1", "description": "Production partner API" }
  ],
  "security": [ { "partnerToken": [] } ],
  "tags": [
    { "name": "Authentication", "description": "Obtain and inspect an access token." },
    { "name": "Directory", "description": "Branches and reference lists." },
    { "name": "Menu", "description": "Categories, products and modifier groups (customer-facing fields only)." },
    { "name": "Orders", "description": "Ingest and poll aggregator orders." },
    { "name": "Webhooks", "description": "Manage outbound signed webhook endpoints." }
  ],
  "paths": {
    "/partner-oauth-token": {
      "post": {
        "tags": ["Authentication"],
        "summary": "Get an access token",
        "description": "OAuth2 `client_credentials` grant. Accepts JSON or `application/x-www-form-urlencoded`. Returns a 1-hour Bearer token. Unknown client, wrong secret and suspended app all collapse to `401 invalid_client` (no credential oracle).",
        "operationId": "getToken",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/TokenRequest" } },
            "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/TokenRequest" } }
          }
        },
        "responses": {
          "200": {
            "description": "Access token issued.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TokenResponse" },
              "example": { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600, "scope": "directory:read menu:read orders:read orders:write webhooks:manage" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "description": "Invalid client credentials.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "invalid_client" } } } }
        }
      }
    },
    "/partner-token-details": {
      "get": {
        "tags": ["Authentication"],
        "summary": "Inspect the current token",
        "description": "Token diagnostics — echoes ONLY the caller's own token: its `app_id`, granted `scopes`, `environment`, expiry, and current rate-limit budget. Never an oracle for other apps.",
        "operationId": "getTokenDetails",
        "responses": {
          "200": { "description": "Token diagnostics.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TokenDetails" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/partner-directory": {
      "get": {
        "tags": ["Directory"],
        "summary": "List branches or reference lists",
        "description": "`resource=shops` returns the tenant's active branches; `resource=order-types` and `resource=payment-methods` return the aggregator-relevant reference lists. Requires scope `directory:read` and an active grant for `tenant_id`.",
        "operationId": "getDirectory",
        "security": [ { "partnerToken": ["directory:read"] } ],
        "parameters": [
          { "name": "resource", "in": "query", "required": true, "schema": { "type": "string", "enum": ["shops", "order-types", "payment-methods"] } },
          { "$ref": "#/components/parameters/TenantId" }
        ],
        "responses": {
          "200": { "description": "The requested reference data.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DirectoryResponse" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/partner-menu": {
      "get": {
        "tags": ["Menu"],
        "summary": "Get the tenant menu",
        "description": "Returns categories, visible products with per-channel price, and modifier groups with options. Only customer-facing fields are exposed (never cost, margin or tech-card). Requires scope `menu:read` and an active grant.",
        "operationId": "getMenu",
        "security": [ { "partnerToken": ["menu:read"] } ],
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" },
          { "name": "channel", "in": "query", "required": false, "schema": { "type": "string", "enum": ["delivery", "pickup"], "default": "delivery" } }
        ],
        "responses": {
          "200": { "description": "The menu.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MenuResponse" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/partner-orders": {
      "get": {
        "tags": ["Orders"],
        "summary": "Poll an order's status",
        "description": "Resolves `(tenant_id, provider, external_order_id)` to the internal order and returns its live status. Requires scope `orders:read`.",
        "operationId": "getOrder",
        "security": [ { "partnerToken": ["orders:read"] } ],
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" },
          { "name": "provider", "in": "query", "required": true, "schema": { "type": "string" } },
          { "name": "external_order_id", "in": "query", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "The order.", "content": { "application/json": { "schema": { "type": "object", "properties": { "order": { "$ref": "#/components/schemas/OrderView" } } } } } },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "post": {
        "tags": ["Orders"],
        "summary": "Ingest an aggregator order",
        "description": "Idempotently lands an order in the shared spine (KDS + stock + accounting). Line prices are recorded **as sent** — connecthub does not re-price. Retrying the same `external_order_id` returns the original order with `idempotent:true`. Requires scope `orders:write`.",
        "operationId": "createOrder",
        "security": [ { "partnerToken": ["orders:write"] } ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OrderIngest" } } } },
        "responses": {
          "201": { "description": "Order created.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OrderCreated" } } } },
          "200": { "description": "Idempotent replay — the order already existed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OrderCreated" }, "example": { "order_id": "0e8…", "order_number": "AG-260810-7K2QP", "status": "confirmed", "idempotent": true } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/partner-webhooks": {
      "get": {
        "tags": ["Webhooks"],
        "summary": "List webhook endpoints or deliveries",
        "description": "Default lists this partner's endpoints for the tenant (the signing secret is NEVER returned). Pass `resource=deliveries` to list recent delivery attempts (status/attempts/error) for your endpoints. Requires scope `webhooks:read` (or `webhooks:manage`).",
        "operationId": "listWebhooks",
        "security": [ { "partnerToken": ["webhooks:read", "webhooks:manage"] } ],
        "parameters": [
          { "$ref": "#/components/parameters/TenantId" },
          { "name": "resource", "in": "query", "required": false, "schema": { "type": "string", "enum": ["endpoints", "deliveries"] } },
          { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } }
        ],
        "responses": {
          "200": { "description": "Endpoints.", "content": { "application/json": { "schema": { "type": "object", "properties": { "endpoints": { "type": "array", "items": { "$ref": "#/components/schemas/WebhookEndpoint" } } } } } } },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      },
      "post": {
        "tags": ["Webhooks"],
        "summary": "Register a webhook endpoint (or replay a delivery)",
        "description": "Registers an `https://` endpoint to receive signed events. The `secret` is returned ONCE — store it, it is used to verify the HMAC signature. Requires scope `webhooks:manage`.\n\nTo re-queue a failed delivery, POST `{ tenant_id, action: \"replay\", delivery_id }` — it re-attempts one of your own dead/failed/delivered deliveries.",
        "operationId": "registerWebhook",
        "security": [ { "partnerToken": ["webhooks:manage"] } ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookRegister" } } } },
        "responses": {
          "201": { "description": "Endpoint registered (secret shown once).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookRegistered" } } } },
          "200": { "description": "Endpoint already existed.", "content": { "application/json": { "schema": { "type": "object", "properties": { "endpoint_id": { "type": "string" }, "existing": { "type": "boolean" } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      },
      "delete": {
        "tags": ["Webhooks"],
        "summary": "Disable a webhook endpoint",
        "description": "Disables one of this partner's own endpoints. Requires scope `webhooks:manage`.",
        "operationId": "disableWebhook",
        "security": [ { "partnerToken": ["webhooks:manage"] } ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["tenant_id", "endpoint_id"], "properties": { "tenant_id": { "type": "string" }, "endpoint_id": { "type": "string" } } } } } },
        "responses": {
          "200": { "description": "Disabled.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "endpoint_id": { "type": "string" }, "status": { "type": "string" } } } } } },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    }
  },
  "webhooks": {
    "order.status_changed": {
      "post": {
        "summary": "Order status changed",
        "description": "Sent to your registered endpoint when an aggregator order's status changes. Signed with HMAC-SHA256 over `<timestamp>.<body>` — verify the `Webhook-Signature: t=<ts>,v1=<hex>` header using your endpoint secret. Delivered from a durable queue with exponential-backoff retry and a dead-letter after repeated failure.",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEvent" } } } },
        "responses": { "200": { "description": "Acknowledged (any 2xx marks the delivery delivered)." } }
      }
    },
    "stop_list.changed": {
      "post": {
        "summary": "Stop-list changed",
        "description": "Sent when a product's availability toggles (stop-list on/off) so you can hide or re-show the item. Same HMAC signing + durable-retry delivery as `order.status_changed`.",
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StopListEvent" } } } },
        "responses": { "200": { "description": "Acknowledged." } }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "partnerToken": {
        "type": "oauth2",
        "description": "OAuth2 client-credentials Bearer token from /partner-oauth-token.",
        "flows": { "clientCredentials": { "tokenUrl": "https://d.nextechhq.com/functions/v1/partner-oauth-token", "scopes": {
          "directory:read": "Read branches and reference lists",
          "menu:read": "Read the tenant menu",
          "orders:read": "Poll order status",
          "orders:write": "Ingest orders",
          "webhooks:read": "List webhook endpoints",
          "webhooks:manage": "Register and disable webhook endpoints"
        } } }
      }
    },
    "parameters": {
      "TenantId": { "name": "tenant_id", "in": "query", "required": true, "description": "The tenant you were granted access to.", "schema": { "type": "string" } }
    },
    "responses": {
      "BadRequest": { "description": "Invalid request.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "invalid_request", "error_description": "tenant_id is required" } } } },
      "Unauthorized": { "description": "Missing or invalid token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "invalid_token" } } } },
      "Forbidden": { "description": "No active grant for this tenant, or insufficient scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "no_grant", "error_description": "partner is not authorized for this tenant" } } } },
      "NotFound": { "description": "No matching resource.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "not_found" } } } },
      "RateLimited": { "description": "Rate limit exceeded. See the X-RateLimit-* headers.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "rate_limited" } } } }
    },
    "schemas": {
      "TokenRequest": {
        "type": "object", "required": ["grant_type", "client_id", "client_secret"],
        "properties": {
          "grant_type": { "type": "string", "enum": ["client_credentials"] },
          "client_id": { "type": "string" },
          "client_secret": { "type": "string" }
        }
      },
      "TokenResponse": {
        "type": "object",
        "properties": {
          "access_token": { "type": "string" },
          "token_type": { "type": "string", "enum": ["Bearer"] },
          "expires_in": { "type": "integer", "example": 3600 },
          "scope": { "type": "string", "description": "Space-separated granted scopes." }
        }
      },
      "TokenDetails": {
        "type": "object",
        "properties": {
          "app_id": { "type": "string" },
          "scopes": { "type": "array", "items": { "type": "string" } },
          "environment": { "type": "string", "enum": ["sandbox", "production"] },
          "expires_at": { "type": ["string", "null"], "format": "date-time" },
          "expires_in": { "type": ["integer", "null"] },
          "rate_limit": { "type": "object", "properties": { "limit": { "type": "integer" }, "window_seconds": { "type": "integer" }, "buckets": { "type": "object", "description": "Remaining calls per API bucket in the current window.", "additionalProperties": { "type": "integer" } } } }
        }
      },
      "DirectoryResponse": {
        "type": "object",
        "description": "Shape depends on the requested resource.",
        "properties": {
          "shops": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "is_active": { "type": "boolean" } } } },
          "order_types": { "type": "array", "items": { "$ref": "#/components/schemas/CodeLabel" } },
          "payment_methods": { "type": "array", "items": { "$ref": "#/components/schemas/CodeLabel" } }
        }
      },
      "CodeLabel": { "type": "object", "properties": { "code": { "type": "string" }, "label": { "type": "string" } } },
      "MenuResponse": {
        "type": "object",
        "properties": {
          "channel": { "type": "string", "enum": ["delivery", "pickup"] },
          "categories": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" } } } },
          "products": { "type": "array", "items": { "$ref": "#/components/schemas/Product" } },
          "modifier_groups": { "type": "array", "items": { "$ref": "#/components/schemas/ModifierGroup" } }
        }
      },
      "Product": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "category_id": { "type": ["string", "null"] },
          "name": { "type": "string" },
          "price": { "type": ["number", "null"] },
          "available": { "type": "boolean" },
          "modifier_group_ids": { "type": "array", "items": { "type": "string" } }
        }
      },
      "ModifierGroup": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "selection_type": { "type": "string", "enum": ["single", "multi"] },
          "min_select": { "type": "integer" },
          "max_select": { "type": ["integer", "null"] },
          "is_required": { "type": "boolean" },
          "options": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "price_adjustment": { "type": "number" }, "is_default": { "type": "boolean" } } } }
        }
      },
      "OrderView": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "order_number": { "type": "string" },
          "status": { "type": "string", "example": "confirmed" },
          "order_type": { "type": "string", "enum": ["pickup", "delivery"] },
          "total": { "type": "number" },
          "currency": { "type": "string", "example": "AZN" },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "OrderIngest": {
        "type": "object",
        "required": ["tenant_id", "shop_id", "provider", "external_order_id", "order_type", "items"],
        "properties": {
          "tenant_id": { "type": "string" },
          "shop_id": { "type": "string" },
          "provider": { "type": "string", "description": "The tenant-visible marketplace (e.g. wolt, bolt_food)." },
          "external_order_id": { "type": "string", "description": "Your order id — the idempotency key." },
          "order_type": { "type": "string", "enum": ["pickup", "delivery"] },
          "payment_method": { "type": "string", "enum": ["cash", "card", "card_on_pickup"], "default": "card" },
          "total": { "type": "number", "description": "Optional; computed from items + delivery_fee when omitted." },
          "delivery_fee": { "type": "number" },
          "commission": { "type": "number", "description": "Optional partner-reported commission; wins the accounting resolver chain." },
          "customer_name": { "type": "string" },
          "customer_phone": { "type": "string" },
          "customer_note": { "type": "string" },
          "items": { "type": "array", "minItems": 1, "items": { "type": "object", "required": ["product_id", "quantity", "unit_price"], "properties": { "product_id": { "type": "string" }, "quantity": { "type": "number", "minimum": 1 }, "unit_price": { "type": "number", "minimum": 0 } } } }
        }
      },
      "OrderCreated": {
        "type": "object",
        "properties": {
          "order_id": { "type": "string" },
          "order_number": { "type": "string" },
          "status": { "type": "string", "example": "confirmed" },
          "idempotent": { "type": "boolean", "description": "Present and true on a replay." }
        }
      },
      "WebhookRegister": {
        "type": "object", "required": ["tenant_id", "url"],
        "properties": {
          "tenant_id": { "type": "string" },
          "url": { "type": "string", "format": "uri", "description": "Must be https://" },
          "event_types": { "type": "array", "items": { "type": "string" }, "description": "Defaults to all event types when omitted." }
        }
      },
      "WebhookRegistered": {
        "type": "object",
        "properties": { "endpoint_id": { "type": "string" }, "secret": { "type": "string", "description": "Shown once — used to verify the HMAC signature." } }
      },
      "WebhookEndpoint": {
        "type": "object",
        "properties": { "id": { "type": "string" }, "url": { "type": "string" }, "event_types": { "type": ["array", "null"], "items": { "type": "string" } }, "status": { "type": "string", "enum": ["active", "disabled"] }, "created_at": { "type": "string", "format": "date-time" } }
      },
      "WebhookEvent": {
        "type": "object",
        "properties": {
          "event": { "type": "string", "example": "order.status_changed" },
          "order_id": { "type": "string" },
          "external_order_id": { "type": "string" },
          "provider": { "type": "string" },
          "status": { "type": "string", "description": "Internal order status." },
          "provider_status": { "type": "string", "description": "Canonical provider-facing status." },
          "occurred_at": { "type": "string", "format": "date-time" }
        }
      },
      "StopListEvent": {
        "type": "object",
        "properties": {
          "event": { "type": "string", "example": "stop_list.changed" },
          "product_id": { "type": "string" },
          "available": { "type": "boolean", "description": "true = back on the menu, false = stopped." },
          "visibility": { "type": "string" },
          "occurred_at": { "type": "string", "format": "date-time" }
        }
      },
      "WebhookDelivery": {
        "type": "object",
        "description": "A delivery attempt for one of your endpoints (GET /partner-webhooks?resource=deliveries).",
        "properties": {
          "id": { "type": "string" },
          "event_type": { "type": "string" },
          "status": { "type": "string", "enum": ["pending", "delivering", "delivered", "failed", "dead"] },
          "attempts": { "type": "integer" },
          "max_attempts": { "type": "integer" },
          "last_status_code": { "type": ["integer", "null"] },
          "last_error": { "type": ["string", "null"] },
          "created_at": { "type": "string", "format": "date-time" },
          "delivered_at": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "Error": {
        "type": "object",
        "properties": { "error": { "type": "string" }, "error_description": { "type": "string" } }
      }
    }
  }
}
