{
  "openapi": "3.1.0",
  "info": {
    "title": "Dial Milo Agent API",
    "version": "2.0.0",
    "description": "Endpoints an AI agent (Claude Code, Cursor, Codex, Meta Muse, scripts) can call on behalf of a business owner: set up AI phone receptionists, build and run outbound campaigns, upload player or customer lists, wire Customer.io, and read calls, leads and appointments. MCP clients should connect to https://dialmilo.com/api/mcp (Streamable HTTP, OAuth sign-in); its tools wrap these endpoints. Every token carries scopes (read, setup, records, campaigns, campaigns.launch, integrations); each operation lists the one it needs in x-required-scope. A missing scope returns 403 scope_required. Money and account steps (buying a number, top-up, team, tokens) are owner-only and return 403 owner_action_required. See https://dialmilo.com/agent.md for the flow, https://dialmilo.com/agents for connecting, and https://dialmilo.com/muse.md for the Meta Muse connector brief."
  },
  "servers": [
    {
      "url": "https://dialmilo.com/api"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "oauth2": []
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "A scoped token, minted by the account owner at https://dialmilo.com/settings (Settings → AI agents & API), or issued by the OAuth flow. Send as Authorization: Bearer <token>."
      },
      "oauth2": {
        "type": "oauth2",
        "description": "Authorization code with PKCE (S256), public clients, dynamic client registration. Issues the same scoped tokens.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://dialmilo.com/oauth/authorize",
            "tokenUrl": "https://dialmilo.com/api/oauth/token",
            "scopes": {
              "read": "Read everything",
              "setup": "Set up receptionists & knowledge",
              "records": "Edit leads, contacts & appointments",
              "campaigns": "Build campaigns & upload player lists",
              "campaigns.launch": "Launch & pause campaigns (spends money)",
              "integrations": "Connect CRMs, Notion, Sheets & webhooks"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "scope_required",
              "owner_action_required"
            ],
            "description": "Machine-readable reason."
          },
          "scope": {
            "type": "string",
            "enum": [
              "read",
              "setup",
              "records",
              "campaigns",
              "campaigns.launch",
              "integrations"
            ],
            "description": "Present on scope_required: the scope the token is missing."
          },
          "message": {
            "type": "string"
          }
        },
        "required": [
          "error"
        ]
      }
    },
    "responses": {
      "Forbidden": {
        "description": "scope_required: the token lacks the scope in `scope`; ask the owner to reconnect with it. owner_action_required: an owner-only step; hand back to the owner.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  },
  "paths": {
    "/me": {
      "get": {
        "summary": "Who am I / account status",
        "description": "Returns the account this token belongs to: name, email, whether the email is verified, current credit balance in cents, and trust level. Call this first to confirm the token works and to see the balance before recommending a top-up (a top-up is an owner action).",
        "responses": {
          "200": {
            "description": "Account summary"
          }
        },
        "x-required-scope": "read"
      }
    },
    "/onboarding/analyze-website": {
      "post": {
        "summary": "Draft a receptionist from a business website",
        "description": "Give a business URL and Dial Milo reads the site and drafts a greeting and system prompt (the receptionist's script). Use this to bootstrap step 2 instead of writing the script from scratch, then refine with PATCH /receptionists/{id}.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "The business's website URL."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A website analysis id; poll /onboarding/website-analysis/{id} for the draft."
          }
        },
        "x-required-scope": "setup"
      }
    },
    "/receptionists": {
      "get": {
        "summary": "List receptionists",
        "description": "The business's receptionists. Most accounts have one.",
        "responses": {
          "200": {
            "description": "List"
          }
        },
        "x-required-scope": "read"
      },
      "post": {
        "summary": "Create a receptionist",
        "description": "Create a receptionist with a greeting (the first line callers hear), a system_prompt (the script: what it says, what it asks callers for, when to take a message), a voice, and languages.",
        "responses": {
          "200": {
            "description": "Created receptionist"
          }
        },
        "x-required-scope": "setup"
      }
    },
    "/receptionists/{id}": {
      "patch": {
        "summary": "Update a receptionist's greeting, script, voice, or languages",
        "description": "Refine the receptionist. Fields: greeting (max 500 chars), system_prompt (max 5000), voice, languages (array). This is the main configuration call.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Updated receptionist"
          }
        },
        "x-required-scope": "setup"
      }
    },
    "/receptionists/{id}/tool-overrides": {
      "put": {
        "summary": "Turn tools on or off for this receptionist",
        "description": "Enable/disable tools: capture_lead, take_voicemail, book_appointment (needs a connected Google Calendar), send_sms and notify_owner (need the owner's mobile in Settings), transfer_call (needs the owner's mobile). Use GET /readiness to see which are configured and tested.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Updated tool config"
          }
        },
        "x-required-scope": "setup"
      }
    },
    "/knowledge": {
      "get": {
        "summary": "List uploaded knowledge documents",
        "responses": {
          "200": {
            "description": "List"
          }
        },
        "x-required-scope": "read"
      },
      "post": {
        "summary": "Upload a knowledge document",
        "description": "Upload a price list, FAQ, hours, or policy document. The receptionist answers callers from these instead of guessing.",
        "responses": {
          "200": {
            "description": "Created document (processing)"
          }
        },
        "x-required-scope": "setup"
      }
    },
    "/readiness": {
      "get": {
        "summary": "Go-live checklist",
        "description": "For each enabled tool: whether it is configured (its dependency is set — e.g. a forwarding number for transfers) and whether it has been tested (produced a successful result on a call). Use this as your status report to the owner: what is ready, and what still needs them.",
        "parameters": [
          {
            "name": "receptionist_id",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Per-tool readiness"
          }
        },
        "x-required-scope": "read"
      }
    },
    "/test-call/start": {
      "post": {
        "summary": "Start a browser test call",
        "description": "Starts a test call to the receptionist. Note: a test call is placed from a browser with a microphone, so an agent typically cannot complete the audio itself. Prefer the outbound demo (POST /onboarding/phone-verify/start → check → POST /onboarding/outbound-demo), which rings the owner's phone; or recommend the owner run the test call in the dashboard Playground, then read the transcript back via GET /calls/{id}.",
        "responses": {
          "200": {
            "description": "Call id + websocket url"
          }
        },
        "x-required-scope": "setup"
      }
    },
    "/calls/{id}": {
      "get": {
        "summary": "Read a call transcript and what the receptionist did",
        "description": "The transcript, the tool actions taken (leads captured, bookings), the outcome, and any AI setup tips. Use this to report how a test call went.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Call detail"
          }
        },
        "x-required-scope": "read"
      }
    },
    "/onboarding/website-analysis/{id}": {
      "get": {
        "summary": "Poll a website analysis for the drafted greeting and script",
        "description": "Returns the analysis status and, once done, the drafted greeting, system_prompt, and a business summary. Poll every few seconds until status is complete, then use the draft in POST /receptionists or PATCH /receptionists/{id}.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Analysis status and draft"
          }
        },
        "x-required-scope": "setup"
      }
    },
    "/onboarding/phone-verify/start": {
      "post": {
        "summary": "Text a verification code to the owner's mobile",
        "description": "Step 1 of the outbound demo (the test an agent can run without a microphone). Sends a 6-digit code by SMS to the owner's US mobile. Body: to_e164 (US number in +1XXXXXXXXXX form). Returns verification_id. Ask the owner to read you the code.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "to_e164"
                ],
                "properties": {
                  "to_e164": {
                    "type": "string",
                    "description": "The owner's US mobile, E.164, e.g. +14155550123."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "verification_id and expires_at"
          },
          "429": {
            "description": "Too many codes sent; wait an hour."
          }
        },
        "x-required-scope": "setup"
      }
    },
    "/onboarding/phone-verify/check": {
      "post": {
        "summary": "Confirm the verification code",
        "description": "Step 2 of the outbound demo. Body: verification_id and the 6-digit code the owner received. On success the verification_id can be used for POST /onboarding/outbound-demo.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "verification_id",
                  "code"
                ],
                "properties": {
                  "verification_id": {
                    "type": "integer"
                  },
                  "code": {
                    "type": "string",
                    "description": "Six digits."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Verified"
          },
          "422": {
            "description": "Wrong or expired code"
          }
        },
        "x-required-scope": "setup"
      }
    },
    "/onboarding/outbound-demo": {
      "post": {
        "summary": "Have Milo call the owner's phone",
        "description": "Step 3 of the outbound demo. Milo rings the verified number, greets the owner as the configured receptionist, and talks for up to 60 seconds. One demo per account, ever; charges $1 from balance once answered; US numbers only; needs a receptionist. Body: verification_id. Returns call_id. Poll GET /onboarding/outbound-demo/{id}, then read the transcript with GET /calls/{id}. If the owner has already used the demo, they can buy a number and call it instead (owner step).",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "verification_id"
                ],
                "properties": {
                  "verification_id": {
                    "type": "integer"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "call_id; the phone is ringing"
          },
          "402": {
            "description": "Balance under $1; the owner tops up in the dashboard"
          },
          "429": {
            "description": "Demo already used on this account"
          }
        },
        "x-required-scope": "setup"
      }
    },
    "/onboarding/outbound-demo/{id}": {
      "get": {
        "summary": "Check the demo call's state",
        "description": "state is ringing, live, ended, billed, or failed. Once ended, GET /calls/{id} has the transcript.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "state, duration_sec, end_reason"
          }
        },
        "x-required-scope": "setup"
      }
    },
    "/calls": {
      "get": {
        "summary": "List recent calls",
        "description": "The 100 most recent calls, newest first: caller_name, source, state, started_at, duration_sec, cost_total_cents, end_reason, outcome, and ai_summary. Filter by started_at yourself for \"today\". Use GET /calls/{id} for a transcript.",
        "responses": {
          "200": {
            "description": "Call list"
          }
        },
        "x-required-scope": "read"
      }
    },
    "/leads": {
      "get": {
        "summary": "List captured leads and voicemails",
        "description": "The 200 most recent leads, newest first. kind is lead or voicemail. Fields: name, phone, email, callback_number, service_address, intent, topic, urgency, message, status, notes, call_id, created_at. This is what the owner asks about most: who called and what they wanted.",
        "responses": {
          "200": {
            "description": "Lead list"
          }
        },
        "x-required-scope": "read"
      }
    },
    "/leads/{id}": {
      "get": {
        "summary": "Read one lead",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Lead"
          }
        },
        "x-required-scope": "read"
      },
      "patch": {
        "summary": "Update a lead's status or notes",
        "description": "Mark a lead handled or add a note after the owner calls back. Fields: status, notes.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Updated lead"
          }
        },
        "x-required-scope": "records"
      }
    },
    "/appointments": {
      "get": {
        "summary": "List appointments",
        "description": "Bookings Milo made (and any the owner added). Query: from and to (ISO dates, on scheduled_for), status. Fields: caller_name, caller_email, scheduled_for, duration_minutes, kind, status, notes, source, call_id.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Appointment list"
          }
        },
        "x-required-scope": "read"
      },
      "post": {
        "summary": "Add an appointment",
        "description": "Create a booking on the owner's behalf. Fields: caller_name, scheduled_for (ISO), duration_minutes, kind, notes.",
        "responses": {
          "200": {
            "description": "Created appointment"
          }
        },
        "x-required-scope": "records"
      }
    },
    "/numbers": {
      "get": {
        "summary": "List the owner's phone numbers",
        "description": "Each number with its receptionist, status, business_hours, and after_hours_behavior. Read-only for an agent; buying, forwarding, or changing a number is an owner step.",
        "responses": {
          "200": {
            "description": "Number list"
          }
        },
        "x-required-scope": "read"
      },
      "post": {
        "summary": "Buy a phone number (OWNER ONLY)",
        "description": "Owner action — returns 403 owner_action_required for an agent token. Buying a number spends money. Ask the owner to buy or forward a number in the dashboard.",
        "responses": {
          "403": {
            "description": "owner_action_required"
          }
        }
      }
    },
    "/agent-settings": {
      "get": {
        "summary": "Agent spend limit and today's agent spend",
        "description": "agent_daily_spend_cap_cents is the account-wide daily limit (UTC day) on calls from campaigns a token switched on; agent_spent_today_cents is what those calls have cost today, including calls still in progress. When the limit is reached, agent-launched campaigns stop starting new calls.",
        "x-required-scope": "read",
        "responses": {
          "200": {
            "description": "{ agent_daily_spend_cap_cents, agent_spent_today_cents }"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "patch": {
        "summary": "Change the agent spend limit (OWNER ONLY)",
        "description": "Owner action — returns 403 owner_action_required for any token.",
        "responses": {
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/outbound-campaigns": {
      "get": {
        "summary": "List outbound campaigns",
        "description": "Every campaign with its kind, enabled flag, schedule, budgets and counts.",
        "x-required-scope": "read",
        "responses": {
          "200": {
            "description": "Campaign list"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "summary": "Create a campaign",
        "description": "Create it with enabled: false and launch later. enabled: true needs campaigns.launch. Use kind \"engagement\" for win-backs and retention. Milo quotes only the offers and terms given here.",
        "x-required-scope": "campaigns",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "kind",
                  "enabled",
                  "script_template"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100
                  },
                  "kind": {
                    "type": "string",
                    "enum": [
                      "engagement",
                      "manual",
                      "reminder",
                      "follow_up",
                      "lead_callback",
                      "order_update",
                      "appointment_confirmation",
                      "custom_event"
                    ]
                  },
                  "enabled": {
                    "type": "boolean",
                    "description": "Leave false. true needs campaigns.launch."
                  },
                  "script_template": {
                    "type": "string",
                    "maxLength": 6000,
                    "description": "What Milo says and asks. Context columns from the player list can be used."
                  },
                  "opening_template": {
                    "type": "string",
                    "maxLength": 300
                  },
                  "offers": {
                    "type": "array",
                    "maxItems": 20,
                    "items": {
                      "type": "object",
                      "required": [
                        "code",
                        "title",
                        "terms"
                      ],
                      "properties": {
                        "code": {
                          "type": "string"
                        },
                        "title": {
                          "type": "string"
                        },
                        "terms": {
                          "type": "string"
                        },
                        "valid_until": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "receptionist_id": {
                    "type": "integer"
                  },
                  "safer_gambling_message": {
                    "type": "string",
                    "maxLength": 600
                  },
                  "allowed_hours_start": {
                    "type": "string",
                    "description": "HH:MM, in the player's local time."
                  },
                  "allowed_hours_end": {
                    "type": "string",
                    "description": "HH:MM"
                  },
                  "allowed_days": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "retry_policy": {
                    "type": "object",
                    "properties": {
                      "max_attempts": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 5
                      },
                      "gap_minutes": {
                        "type": "integer",
                        "minimum": 30
                      }
                    }
                  },
                  "budget_cents": {
                    "type": "integer",
                    "minimum": 100
                  },
                  "daily_budget_cents": {
                    "type": "integer",
                    "minimum": 100
                  },
                  "max_concurrent_calls": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 50
                  },
                  "starts_at": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "ends_at": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created campaign"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "description": "Validation error"
          }
        }
      }
    },
    "/outbound-campaigns/{id}": {
      "get": {
        "summary": "Read a campaign",
        "x-required-scope": "read",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Campaign"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "patch": {
        "summary": "Update, launch or pause a campaign",
        "description": "Partial update with the same fields as create. Setting enabled to true (launch) or false (pause) needs campaigns.launch; other fields need campaigns. Calls from a campaign a token launched count against the agent spend limits.",
        "x-required-scope": "campaigns",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Updated campaign"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "description": "Validation error"
          }
        }
      }
    },
    "/outbound-campaigns/{id}/stats": {
      "get": {
        "summary": "Campaign results",
        "description": "Funnel (dialled, answered, conversations, offers accepted, conversions), outcomes, cost, and live state. Optional batch_id query to scope to one upload.",
        "x-required-scope": "read",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "batch_id",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Stats"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/outbound-campaigns/{id}/batches": {
      "get": {
        "summary": "List uploaded player lists",
        "x-required-scope": "read",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Batches with accepted/rejected counts"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "summary": "Upload a player or customer list (CSV)",
        "description": "Multipart upload. Columns: phone (required, international format), name, and any others, which become context for the script. Bad numbers, duplicates, premium ranges and do-not-call entries are rejected and listed. Send dry_run=1 first to preview counts without queuing anyone.",
        "x-required-scope": "campaigns",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "CSV, max 5 MB."
                  },
                  "name": {
                    "type": "string",
                    "maxLength": 120
                  },
                  "dry_run": {
                    "type": "boolean"
                  },
                  "start_at": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "spacing_minutes": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 120
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted/rejected counts and problems"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "description": "No phone column or unreadable file"
          }
        }
      }
    },
    "/outbound-campaigns/{id}/trigger-token": {
      "post": {
        "summary": "Mint (or rotate) the campaign trigger URL",
        "description": "Returns { data: { trigger_url } }. A CRM journey (Customer.io, Braze, Optimove, in-house) POSTs players to it as JSON — one object, an array, or {\"players\":[…]} (max 500): {\"phone\":\"+447700900123\",\"name\":\"James\",\"player_id\":\"123\",\"external_id\":\"evt-1\",\"language\":\"en\",\"offer_code\":\"FS50\",\"at\":\"2026-10-20T18:00:00Z\",\"context\":{\"segment\":\"lapsed 30d\"}}. The URL is the credential; rotating invalidates the old one. Posting to it starts calls, so minting needs campaigns.launch.",
        "x-required-scope": "campaigns.launch",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "trigger_url"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "delete": {
        "summary": "Revoke the trigger URL",
        "x-required-scope": "campaigns.launch",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "trigger_url: null"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/integrations/customerio": {
      "get": {
        "summary": "Customer.io connection status",
        "description": "connected, masked site id, region, id_field, last push, last error.",
        "x-required-scope": "read",
        "responses": {
          "200": {
            "description": "Status"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "put": {
        "summary": "Connect Customer.io",
        "description": "Body: site_id, api_key (a Tracking API key, not an App API key), region (us or eu), optional id_field (the player attribute Milo matches on, default cio_id). Credentials are checked with Customer.io before saving. Call outcomes are then sent back as events.",
        "x-required-scope": "integrations",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "site_id",
                  "api_key",
                  "region"
                ],
                "properties": {
                  "site_id": {
                    "type": "string"
                  },
                  "api_key": {
                    "type": "string"
                  },
                  "region": {
                    "type": "string",
                    "enum": [
                      "us",
                      "eu"
                    ]
                  },
                  "id_field": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Status"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "description": "Customer.io rejected the credentials"
          }
        }
      },
      "delete": {
        "summary": "Disconnect Customer.io",
        "x-required-scope": "integrations",
        "responses": {
          "200": {
            "description": "Status"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/billing/checkout": {
      "post": {
        "summary": "Top up credit (OWNER ONLY)",
        "description": "Owner action — returns 403 owner_action_required for an agent token. Ask the owner to top up in the dashboard.",
        "responses": {
          "403": {
            "description": "owner_action_required"
          }
        }
      }
    }
  }
}