{
  "openapi": "3.1.0",
  "info": {
    "title": "Shatale Publisher API",
    "version": "1.0.0",
    "description": "API for managing AI agents, delegations, policies, budgets, and payment cards on the Shatale platform.",
    "contact": { "email": "api@shatale.com" }
  },
  "servers": [
    { "url": "https://api.shatale.com", "description": "Production" }
  ],
  "security": [{ "ApiKeyAuth": [] }],
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key obtained from the Publisher Admin console."
      }
    },
    "schemas": {
      "Agent": {
        "type": "object",
        "properties": {
          "id":           { "type": "string", "example": "01J..." },
          "name":         { "type": "string", "example": "TravelBot" },
          "publisher_id": { "type": "string" },
          "status":       { "type": "string", "enum": ["active","suspended","inactive"] },
          "created_at":   { "type": "string", "format": "date-time" }
        }
      },
      "Delegation": {
        "type": "object",
        "properties": {
          "id":         { "type": "string" },
          "agent_id":   { "type": "string" },
          "user_id":    { "type": "string" },
          "policy_id":  { "type": "string" },
          "budget_id":  { "type": "string" },
          "status":     { "type": "string", "enum": ["active","revoked","expired"] },
          "expires_at": { "type": "string", "format": "date-time", "nullable": true }
        }
      },
      "Policy": {
        "type": "object",
        "properties": {
          "id":          { "type": "string" },
          "name":        { "type": "string" },
          "publisher_id":{ "type": "string" },
          "status":      { "type": "string", "enum": ["draft","active","archived"] },
          "rules":       { "type": "array", "items": { "type": "object" } },
          "created_at":  { "type": "string", "format": "date-time" }
        }
      },
      "Authorization": {
        "type": "object",
        "properties": {
          "id":          { "type": "string" },
          "agent_id":    { "type": "string" },
          "amount":      { "type": "integer", "description": "Amount in minor units (cents)" },
          "currency":    { "type": "string", "example": "EUR" },
          "merchant_id": { "type": "string" },
          "mcc":         { "type": "string" },
          "decision":    { "type": "string", "enum": ["approved","declined"] },
          "reason_code": { "type": "string" },
          "created_at":  { "type": "string", "format": "date-time" }
        }
      },
      "PendingApproval": {
        "type": "object",
        "properties": {
          "id":              { "type": "string" },
          "authorization_id":{ "type": "string" },
          "agent_id":        { "type": "string" },
          "amount":          { "type": "integer" },
          "currency":        { "type": "string" },
          "merchant_id":     { "type": "string" },
          "expires_at":      { "type": "string", "format": "date-time" }
        }
      },
      "APIKey": {
        "type": "object",
        "properties": {
          "id":           { "type": "string" },
          "name":         { "type": "string" },
          "is_active":    { "type": "boolean" },
          "created_at":   { "type": "string", "format": "date-time" },
          "last_used_at": { "type": "string", "format": "date-time", "nullable": true }
        }
      },
      "WebhookEndpoint": {
        "type": "object",
        "properties": {
          "url":          { "type": "string", "format": "uri" },
          "enabled":      { "type": "boolean" },
          "event_types":  { "type": "array", "items": { "type": "string" } },
          "description":  { "type": "string" }
        }
      },
      "OverrideToken": {
        "type": "object",
        "properties": {
          "id":          { "type": "string" },
          "agent_id":    { "type": "string" },
          "max_amount":  { "type": "integer" },
          "bypass_rules":{ "type": "array", "items": { "type": "string" } },
          "status":      { "type": "string", "enum": ["active","used","expired","revoked"] },
          "expires_at":  { "type": "string", "format": "date-time" }
        }
      },
      "Card": {
        "type": "object",
        "properties": {
          "id":         { "type": "string" },
          "agent_id":   { "type": "string" },
          "status":     { "type": "string", "enum": ["active","frozen","terminated"] },
          "last4":      { "type": "string", "example": "4242" },
          "brand":      { "type": "string", "enum": ["visa","mastercard"] },
          "card_type":  { "type": "string", "enum": ["virtual"] },
          "exp_month":  { "type": "integer" },
          "exp_year":   { "type": "integer" },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": { "type": "string" }
        }
      }
    }
  },
  "paths": {
    "/v1/agents": {
      "get": {
        "summary": "List agents",
        "operationId": "listAgents",
        "tags": ["Agents"],
        "responses": {
          "200": { "description": "Agent list", "content": { "application/json": { "schema": { "type": "object", "properties": { "agents": { "type": "array", "items": { "$ref": "#/components/schemas/Agent" } } } } } } }
        }
      },
      "post": {
        "summary": "Create agent",
        "operationId": "createAgent",
        "tags": ["Agents"],
        "parameters": [{ "name": "Idempotency-Key", "in": "header", "required": true, "description": "Required: an absent or empty header is rejected with 422. THE VALUE IS ACTED ON: a repeat with the same key and the same body returns the STORED response and runs nothing (that replay carries an Idempotent-Replay: true response header); the same key with a DIFFERENT body answers 409; a request still in flight under this key answers 409 rather than waiting. A key is remembered for 24 hours, and retry logic MAY rely on a repeat with an identical key being a no-op. \u26a0\ufe0f This paragraph used to say the OPPOSITE \u2014 \u0027not yet used for deduplication ... replaying the same key WILL execute the request again\u0027 \u2014 which was true when written and became false when SHAT-2096 wired internal/idempotency; the document did not follow.", "schema": { "type": "string" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string" } } } } } },
        "responses": {
          "201": { "description": "Created agent", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Agent" } } } }
        }
      }
    },
    "/v1/agents/{id}": {
      "get": {
        "summary": "Get agent",
        "operationId": "getAgent",
        "tags": ["Agents"],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "Agent", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Agent" } } } } }
      },
      "delete": {
        "summary": "Deactivate agent",
        "operationId": "deactivateAgent",
        "tags": ["Agents"],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "OK" } }
      }
    },
    "/v1/delegations": {
      "post": {
        "summary": "Create delegation",
        "operationId": "createDelegation",
        "tags": ["Delegations"],
        "parameters": [{ "name": "Idempotency-Key", "in": "header", "required": true, "description": "Required: an absent or empty header is rejected with 422. THE VALUE IS ACTED ON: a repeat with the same key and the same body returns the STORED response and runs nothing (that replay carries an Idempotent-Replay: true response header); the same key with a DIFFERENT body answers 409; a request still in flight under this key answers 409 rather than waiting. A key is remembered for 24 hours, and retry logic MAY rely on a repeat with an identical key being a no-op. \u26a0\ufe0f This paragraph used to say the OPPOSITE \u2014 \u0027not yet used for deduplication ... replaying the same key WILL execute the request again\u0027 \u2014 which was true when written and became false when SHAT-2096 wired internal/idempotency; the document did not follow.", "schema": { "type": "string" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["agent_id","user_id"], "properties": { "agent_id": { "type": "string" }, "user_id": { "type": "string" }, "policy_id": { "type": "string" }, "budget_id": { "type": "string" }, "expires_at": { "type": "string", "format": "date-time" } } } } } },
        "responses": { "201": { "description": "Created delegation", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Delegation" } } } } }
      }
    },
    "/v1/delegations/{id}": {
      "get": {
        "summary": "Get delegation",
        "operationId": "getDelegation",
        "tags": ["Delegations"],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "Delegation", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Delegation" } } } } }
      },
      "delete": {
        "summary": "Revoke delegation",
        "operationId": "revokeDelegation",
        "tags": ["Delegations"],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "OK" } }
      }
    },
    "/v1/delegations/{id}/spend": {
      "get": {
        "summary": "Remaining allowance for a delegation",
        "description": "What is left before a purchase is refused, read from the SAME two sources the purchase evaluator uses: the policy snapshot for the limits and the ledger for the spend. Only windows that are actually configured appear \u2014 an absent window means no limit of that kind, never a ceiling of zero. When the spend cannot be read, spend_known is false and the windows carry limits with NO spent or remaining beside them, because a zero there is indistinguishable from a fresh budget.",
        "operationId": "getDelegationSpend",
        "tags": ["Delegations"],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": {
          "200": { "description": "Allowance", "content": { "application/json": { "schema": { "type": "object", "properties": { "delegation_id": { "type": "string" }, "policy_id": { "type": "string" }, "budget_id": { "type": "string" }, "as_of": { "type": "string", "format": "date-time" }, "spend_known": { "type": "boolean" }, "reason": { "type": "string" }, "windows": { "type": "array", "items": { "type": "object", "properties": { "window": { "type": "string", "enum": ["daily", "weekly", "monthly"] }, "limit_minor": { "type": "integer", "format": "int64" }, "spent_minor": { "type": "integer", "format": "int64", "nullable": true }, "remaining_minor": { "type": "integer", "format": "int64", "nullable": true } } } }, "max_single_tx_minor": { "type": "integer", "format": "int64", "nullable": true }, "require_approval_above_minor": { "type": "integer", "format": "int64", "nullable": true }, "budget_total_minor": { "type": "integer", "format": "int64", "nullable": true }, "budget_available_minor": { "type": "integer", "format": "int64", "nullable": true } } } } } },
          "404": { "description": "No such delegation for this publisher, or no policy snapshot for it" },
          "501": { "description": "The spend view is not wired on this deployment; no allowance is reported rather than zeroes" }
        }
      }
    },
    "/v1/policies": {
      "get": {
        "summary": "List policies",
        "operationId": "listPolicies",
        "tags": ["Policies"],
        "responses": { "200": { "description": "Policy list", "content": { "application/json": { "schema": { "type": "object", "properties": { "policies": { "type": "array", "items": { "$ref": "#/components/schemas/Policy" } } } } } } } }
      },
      "post": {
        "summary": "Create policy",
        "operationId": "createPolicy",
        "tags": ["Policies"],
        "parameters": [{ "name": "Idempotency-Key", "in": "header", "required": true, "description": "Required: an absent or empty header is rejected with 422. THE VALUE IS ACTED ON: a repeat with the same key and the same body returns the STORED response and runs nothing (that replay carries an Idempotent-Replay: true response header); the same key with a DIFFERENT body answers 409; a request still in flight under this key answers 409 rather than waiting. A key is remembered for 24 hours, and retry logic MAY rely on a repeat with an identical key being a no-op. \u26a0\ufe0f This paragraph used to say the OPPOSITE \u2014 \u0027not yet used for deduplication ... replaying the same key WILL execute the request again\u0027 \u2014 which was true when written and became false when SHAT-2096 wired internal/idempotency; the document did not follow.", "schema": { "type": "string" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string" }, "rules": { "type": "array", "items": { "type": "object" } } } } } } },
        "responses": { "201": { "description": "Created policy", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Policy" } } } } }
      }
    },
    "/v1/authorizations": {
      "get": {
        "summary": "List authorizations",
        "operationId": "listAuthorizations",
        "tags": ["Authorizations"],
        "parameters": [
          { "name": "agent_id", "in": "query", "schema": { "type": "string" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 100 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } }
        ],
        "responses": { "200": { "description": "Authorization list", "content": { "application/json": { "schema": { "type": "object", "properties": { "authorizations": { "type": "array", "items": { "$ref": "#/components/schemas/Authorization" } } } } } } } }
      }
    },
    "/v1/approvals/pending": {
      "get": {
        "summary": "List pending approvals",
        "operationId": "listPendingApprovals",
        "tags": ["Approvals"],
        "responses": { "200": { "description": "Pending approvals", "content": { "application/json": { "schema": { "type": "object", "properties": { "pending": { "type": "array", "items": { "$ref": "#/components/schemas/PendingApproval" } } } } } } } }
      }
    },
    "/v1/me/approvals/{id}/approve": {
      "post": {
        "summary": "Approve an approval the platform is waiting on",
        "description": "Answers a pending approval by the APPROVAL's id, not an authorization's. The answer is recorded before this returns and the purchase it was about is driven synchronously, so purchase_status reports where that purchase ended up rather than asserting success. A body naming a different amount is refused with 400: approving a different amount is a separate feature.",
        "operationId": "approveUserApproval",
        "tags": ["Approvals"],
        "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ],
        "responses": {
          "200": { "description": "Recorded, and the purchase advanced as far as it could", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "approved" }, "purchase_status": { "type": "string", "example": "card_ready" } } } } } },
          "400": { "description": "The body named an amount; approve or deny the amount as asked" },
          "401": { "description": "Unauthorized" },
          "409": { "description": "The approval is no longer open" }
        }
      }
    },
    "/v1/me/approvals/{id}/deny": {
      "post": {
        "summary": "Deny an approval the platform is waiting on",
        "operationId": "denyUserApproval",
        "tags": ["Approvals"],
        "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ],
        "responses": {
          "200": { "description": "Recorded, and the purchase advanced as far as it could", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "denied" }, "purchase_status": { "type": "string", "example": "blocked" } } } } } },
          "401": { "description": "Unauthorized" },
          "409": { "description": "The approval is no longer open" }
        }
      }
    },
    "/v1/approvals/stream": {
      "get": {
        "summary": "SSE stream of pending approvals",
        "operationId": "streamApprovals",
        "tags": ["Approvals"],
        "responses": { "200": { "description": "Server-Sent Events stream", "content": { "text/event-stream": { "schema": { "type": "string" } } } } }
      }
    },
    "/v1/api-keys": {
      "get": {
        "summary": "List API keys",
        "operationId": "listAPIKeys",
        "tags": ["API Keys"],
        "responses": { "200": { "description": "API key list", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/APIKey" } } } } } }
      },
      "post": {
        "summary": "Create API key",
        "operationId": "createAPIKey",
        "tags": ["API Keys"],
        "parameters": [{ "name": "Idempotency-Key", "in": "header", "required": true, "description": "Required: an absent or empty header is rejected with 422. THE VALUE IS ACTED ON: a repeat with the same key and the same body returns the STORED response and runs nothing (that replay carries an Idempotent-Replay: true response header); the same key with a DIFFERENT body answers 409; a request still in flight under this key answers 409 rather than waiting. A key is remembered for 24 hours, and retry logic MAY rely on a repeat with an identical key being a no-op. \u26a0\ufe0f This paragraph used to say the OPPOSITE \u2014 \u0027not yet used for deduplication ... replaying the same key WILL execute the request again\u0027 \u2014 which was true when written and became false when SHAT-2096 wired internal/idempotency; the document did not follow.", "schema": { "type": "string" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["name", "environment"], "properties": { "name": { "type": "string" }, "environment": { "type": "string", "enum": ["sandbox", "live"], "description": "Which world the key is for. REQUIRED and has no default: a request that omits it is rejected with 400. Until 2026-08-30 this field was absent from this schema while the server treated its absence as live, so a client written strictly to this document received a production secret (SHAT-2746)." }, "scopes": { "type": "array", "items": { "type": "string" }, "description": "Capabilities the key may exercise. Omitted means the full grant (asterisk), which is what a key has always carried and what integrations written before scopes expect. Name the capabilities to get a narrower key." } } } } } },
        "responses": { "201": { "description": "New key (key_value shown once)", "content": { "application/json": { "schema": { "allOf": [{ "$ref": "#/components/schemas/APIKey" }, { "type": "object", "properties": { "key_value": { "type": "string" } } }] } } } }, "400": { "description": "name or environment missing, or environment is not 'sandbox' or 'live'" } }
      }
    },
    "/v1/api-keys/{id}": {
      "delete": {
        "summary": "Revoke API key",
        "operationId": "revokeAPIKey",
        "tags": ["API Keys"],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "OK" } }
      }
    },
    "/v1/webhooks/endpoint": {
      "get": {
        "summary": "Get webhook endpoint",
        "operationId": "getWebhookEndpoint",
        "tags": ["Webhooks"],
        "responses": { "200": { "description": "Endpoint config", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEndpoint" } } } } }
      },
      "put": {
        "summary": "Set webhook endpoint",
        "operationId": "upsertWebhookEndpoint",
        "tags": ["Webhooks"],
        "parameters": [{ "name": "Idempotency-Key", "in": "header", "required": true, "description": "Required: an absent or empty header is rejected with 422. THE VALUE IS ACTED ON: a repeat with the same key and the same body returns the STORED response and runs nothing (that replay carries an Idempotent-Replay: true response header); the same key with a DIFFERENT body answers 409; a request still in flight under this key answers 409 rather than waiting. A key is remembered for 24 hours, and retry logic MAY rely on a repeat with an identical key being a no-op. \u26a0\ufe0f This paragraph used to say the OPPOSITE \u2014 \u0027not yet used for deduplication ... replaying the same key WILL execute the request again\u0027 \u2014 which was true when written and became false when SHAT-2096 wired internal/idempotency; the document did not follow.", "schema": { "type": "string" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEndpoint" } } } },
        "responses": { "200": { "description": "OK" } }
      },
      "delete": {
        "summary": "Delete webhook endpoint",
        "operationId": "deleteWebhookEndpoint",
        "tags": ["Webhooks"],
        "responses": { "200": { "description": "OK" } }
      }
    },
    "/v1/cards/{id}": {
      "get": {
        "summary": "Get card",
        "operationId": "getCard",
        "tags": ["Cards"],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "Card detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Card" } } } } }
      },
      "delete": {
        "summary": "Terminate card",
        "operationId": "terminateCard",
        "tags": ["Cards"],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "Card terminated" } }
      }
    },
    "/v1/publisher/me": {
      "get": {
        "summary": "Get publisher profile",
        "operationId": "getPublisherProfile",
        "tags": ["Publisher"],
        "responses": { "200": { "description": "Publisher profile" } }
      }
    },
    "/v1/publisher/profile": {
      "patch": {
        "summary": "Update publisher profile",
        "operationId": "patchPublisherProfile",
        "tags": ["Publisher"],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "webhook_url": { "type": "string" } } } } } },
        "responses": { "200": { "description": "Updated" } }
      }
    },
    "/v1/override-tokens/{id}": {
      "delete": {
        "summary": "Revoke override token",
        "operationId": "revokeOverrideToken",
        "tags": ["Override Tokens"],
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "Revoked" } }
      }
    },
    "/v1/override-tokens": {
      "get": {
        "summary": "List override tokens",
        "operationId": "listOverrideTokens",
        "tags": ["Override Tokens"],
        "responses": { "200": { "description": "Token list", "content": { "application/json": { "schema": { "type": "object", "properties": { "tokens": { "type": "array", "items": { "$ref": "#/components/schemas/OverrideToken" } } } } } } } }
      },
      "post": {
        "summary": "Create override token",
        "operationId": "createOverrideToken",
        "tags": ["Override Tokens"],
        "parameters": [{ "name": "Idempotency-Key", "in": "header", "required": true, "description": "Required: an absent or empty header is rejected with 422. THE VALUE IS ACTED ON: a repeat with the same key and the same body returns the STORED response and runs nothing (that replay carries an Idempotent-Replay: true response header); the same key with a DIFFERENT body answers 409; a request still in flight under this key answers 409 rather than waiting. A key is remembered for 24 hours, and retry logic MAY rely on a repeat with an identical key being a no-op. \u26a0\ufe0f This paragraph used to say the OPPOSITE \u2014 \u0027not yet used for deduplication ... replaying the same key WILL execute the request again\u0027 \u2014 which was true when written and became false when SHAT-2096 wired internal/idempotency; the document did not follow.", "schema": { "type": "string" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "agent_id": { "type": "string" }, "max_amount": { "type": "integer" }, "bypass_rules": { "type": "array", "items": { "type": "string" } }, "expires_in_minutes": { "type": "integer", "default": 30 } } } } } },
        "responses": { "201": { "description": "Token (raw_token shown once)", "content": { "application/json": { "schema": { "allOf": [{ "$ref": "#/components/schemas/OverrideToken" }, { "type": "object", "properties": { "raw_token": { "type": "string" } } }] } } } } }
      }
    }
  }
}