{
  "components": {
    "securitySchemes": {
      "key": {
        "in": "header",
        "name": "X-API-Key",
        "type": "apiKey"
      }
    }
  },
  "info": {
    "title": "Sales Sequencer API",
    "version": "1",
    "description": "Every Sales Sequencer action over HTTP. Each call runs as the API key's creator, with that user's role, in the key's workspace. Auth: X-API-Key header."
  },
  "openapi": "3.0.3",
  "paths": {
    "/analytics": {
      "get": {
        "description": "Daily and hourly sends/replies plus breakdowns by campaign, mailbox and domain. Dates are IST days. 'from' and 'to' take effect only when BOTH are given, otherwise the range preset applies (default 14d). The range is capped at 365 days and the end at today. A bad date returns 400 {error}. Cached about 20 seconds.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_analytics",
        "parameters": [
          {
            "description": "Preset: today, yesterday, 7d, 14d (default), 30d, 90d.",
            "in": "query",
            "name": "range",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Start date YYYY-MM-DD (needs 'to').",
            "in": "query",
            "name": "from",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "End date YYYY-MM-DD (needs 'from').",
            "in": "query",
            "name": "to",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Limit to one campaign id.",
            "in": "query",
            "name": "campaign",
            "required": false,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {range:{preset, from, to, days}, campaign, totals:{sent, reached, failed, replies, positive, sent_per_day, reply_per_1k, emails_per_positive}, lifetime:{reached, bounced, pending, bounce_rate}, series, hours:[{hour,sent,replies}], stages, by_campaign, by_mailbox (top 200), by_domain, campaigns}."
          }
        },
        "summary": "Analytics for a date range",
        "tags": [
          "Analytics"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/campaigns/list": {
      "get": {
        "description": "All campaigns in the workspace, archived ones included (status \"archived\"), pinned first then newest first, with per-campaign counts and rates. Replies exclude automated/out-of-office. bounce_rate = bounced / reached (distinct leads sent to), the same number the web app shows; it counts real recipient bounces only. sender_bounced / sender_bounce_rate count leads whose delivery failed on our side (status sender_blocked) right now; sender_blocked_total / sender_blocked_total_rate count every our-side block ever (never decreases, survives re-queues), over the same reached. Pass pinned=1 for pinned campaigns only.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_campaigns",
        "parameters": [
          {
            "description": "1 = only pinned campaigns.",
            "in": "query",
            "name": "pinned",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {campaigns:[{id, name, status, created_at, pinned, steps, mailboxes, leads, pending, reached, emails_sent, sent_today, replied, auto_replies, positive, bounced, reply_rate, bounce_rate, sender_bounced, sender_bounce_rate, sender_blocked_total, sender_blocked_total_rate, emails_per_positive, expected_additional_positive, progress}]}."
          }
        },
        "summary": "List campaigns with stats",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/campaigns/new": {
      "post": {
        "description": "Creates a draft campaign in the current workspace. Sends nothing. Refuses an empty name (flash 'Campaign name is required', no campaign created). A non-numeric or negative min_gap_minutes silently falls back to 15.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_new_campaign",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "body": {
                    "description": "First-email body (legacy single-email field).",
                    "type": "string"
                  },
                  "min_gap_minutes": {
                    "description": "Minimum minutes between sends from one mailbox. Default 15.",
                    "type": "integer"
                  },
                  "name": {
                    "description": "Campaign name.",
                    "type": "string"
                  },
                  "subject": {
                    "description": "First-email subject (legacy single-email field).",
                    "type": "string"
                  }
                },
                "required": [
                  "name"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the new campaign (status draft). The new campaign id is in the redirect URL."
          }
        },
        "summary": "Create a campaign",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/archive": {
      "post": {
        "description": "Sets status to archived: it never sends, cannot be launched, and the campaigns page lists it under Archived only. Leads, sends and replies are kept. Works from any status.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_archive_campaign",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "redirect_to": {
                    "description": "Send 'list' to redirect to the campaign list (UI only).",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; flash 'Campaign archived'."
          }
        },
        "summary": "Archive a campaign",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/backoff-exempt": {
      "post": {
        "description": "Sets exempt_from_backoff. When true, this campaign's contacts are left out of the warm-up bounce back-off (the 6-hourly check that rewinds a warming mailbox's ramp when its 14-day bounce rate is 5% or more over at least 10 sends): its bounces and its sends count in neither the rate nor the sample. Sends, contacts and statuses are not touched. Omitting the field or sending false stores false. New and cloned campaigns start at false. 404 if the campaign is not in the workspace.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_set_backoff_exempt",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "exempt_from_backoff": {
                    "description": "true to exempt this campaign, false to count its bounces again.",
                    "type": "boolean"
                  }
                },
                "required": [
                  "exempt_from_backoff"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; flash 'exempt_from_backoff = true' or 'exempt_from_backoff = false' (the value now stored)."
          }
        },
        "summary": "Exempt a campaign from the bounce back-off",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/clone": {
      "post": {
        "description": "Creates a draft copy named '<name> (copy)' with the same sequence, schedule, cc/bcc and mailbox assignment. Leads are NOT copied. Sends nothing.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_clone_campaign",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redirect to the new campaign's sequence tab (new id in the URL)."
          }
        },
        "summary": "Clone a campaign",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/detail": {
      "get": {
        "description": "Settings, sequence, stats, 30-day series, stage counts and assigned mailboxes. Cached up to 60 seconds per workspace (cache is dropped after campaign writes). 404 JSON if not in the workspace.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_campaign_detail",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {campaign:{id, name, status, created_at, settings:{min_gap_minutes, max_gap_minutes, send_hour_start, send_hour_end, send_tz_offset, send_tz_label, send_days, send_day_labels, cc, bcc}, sequence:[{wait, variants:[{subject, body}]}]}, stats:{total, sent, replied, bounced, failed, pending, positive, emails_sent, reached, reply_rate, bounce_rate, emails_per_positive, expected_additional_positive, sent_today, replied_status, auto_replies}, series:[{date, label, dow, sent, replies, positive}], stages:{<stage>: n}, mailboxes:{assigned, count, rows:[{id, email, status, enabled, daily_limit, sent_today, campaign_sent}]}}."
          }
        },
        "summary": "Get one campaign in full",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/campaigns/{campaign_id}/launch": {
      "post": {
        "description": "Sets status to active so the scheduler starts sending real email to the campaign's pending contacts. Refuses with flash 'Cannot launch: no pending contacts. Upload leads first.' when there are none (status unchanged).\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "campaigns_launch_campaign",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "redirect_to": {
                    "description": "Send 'list' to redirect to the campaign list instead of the detail page (UI only).",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; flash 'Campaign launched' or the refusal message."
          }
        },
        "summary": "Launch a campaign",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": true,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/leads/csv": {
      "post": {
        "tags": [
          "Leads"
        ],
        "summary": "Add leads from a CSV file",
        "operationId": "api_add_leads_csv",
        "description": "Bulk-adds leads to a campaign from a CSV: a multipart 'file' field, or raw CSV bytes as the body. The email column is found by its header (email, email_address or e-mail). Every other column becomes a personalization variable, header slugified, so a \"Company Name\" column becomes {{company_name}}. No dedupe. New leads start as pending. Returns a count, not ids. Errors are capped at 100; errors_truncated and total_errors appear when there were more. 404 if the campaign isn't in the workspace. Any valid key for the workspace can call this; there's no role check on it.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "parameters": [
          {
            "name": "campaign_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "The CSV file."
                  }
                },
                "required": [
                  "file"
                ]
              }
            },
            "text/csv": {
              "schema": {
                "type": "string"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "JSON {created, errors:[{row, email, error}], errors_truncated?, total_errors?}."
          }
        },
        "x-min-role": "view",
        "x-destructive": false
      }
    },
    "/campaigns/{campaign_id}/leads/list": {
      "get": {
        "description": "Paged, filterable lead list for one campaign. 404 JSON if the campaign is not in the workspace. (Prefixed with /list because POST /campaigns/<id>/leads already exists.)\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_campaign_leads",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "description": "Contact status: pending, sent, replied, bounced, failed.",
            "in": "query",
            "name": "status",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Reply stage key, or 'positive' for all positive stages. Stages: interested, meeting_request, not_interested, objection, pricing_question, more_info_needed, timing_issue, wrong_person, out_of_office, automated, unsubscribe, unclassified.",
            "in": "query",
            "name": "stage",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Substring search on email and lead data.",
            "in": "query",
            "name": "q",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Page size, 10-200. Default 50.",
            "in": "query",
            "name": "size",
            "required": false,
            "schema": {
              "type": "integer"
            }
          },
          {
            "description": "Page number, from 1. Default 1.",
            "in": "query",
            "name": "page",
            "required": false,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {leads:[{id, email, name, company, title, status, stage:{key,label,tone,positive}, seq_step, sent_at, next_at, mailbox, has_thread}], total, page, pages, size, counts:{<status>: n}, all}."
          }
        },
        "summary": "List a campaign's leads",
        "tags": [
          "Leads"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/campaigns/{campaign_id}/leads/requeue-sender-blocked": {
      "post": {
        "description": "Every lead in the campaign with status sender_blocked (a failed delivery caused by our sending side: the provider refused to send, our SPF/DKIM/DMARC failed, or our IP/domain reputation was blocked) goes back to pending: one sequence step back so the failed step is sent again, due now, bounce fields and sender_originated cleared. Real recipient bounces (status bounced) are not touched. Does not launch or resume the campaign. 404 if the campaign is not in the workspace.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_requeue_sender_blocked",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redirect; flash 'requeued = N' (how many leads went back to pending)."
          }
        },
        "summary": "Re-queue leads blocked on our side",
        "tags": [
          "Leads"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/leads/{contact_id}/preview": {
      "get": {
        "description": "Returns JSON (not HTML). Renders the lead's next email (personalization, then spintax) for its current sequence step. A/B variants and spintax are chosen randomly, so repeat calls can differ. Read only. 404 if the campaign or lead is not in the workspace.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_preview_lead",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "path",
            "name": "contact_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {contact:{email, status, data}, subject, body, step, total_steps, mailboxes:[{id, email}]} where mailboxes are the ones available to send a test from."
          }
        },
        "summary": "Preview what a lead would receive next",
        "tags": [
          "Leads"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/campaigns/{campaign_id}/leads/{contact_id}/test-send": {
      "post": {
        "description": "Sends the exact subject/body supplied to the given address from the chosen mailbox. Sends REAL email. Writes no SendLog row, does not touch mailbox counters or the lead's status. Errors (JSON): 400 missing/invalid mailbox_id or recipient, 404 mailbox not in workspace, 502 send failed or mailbox auth failed.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "campaigns_test_send_lead",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "path",
            "name": "contact_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "body": {
                    "description": "Body to send as-is.",
                    "type": "string"
                  },
                  "mailbox_id": {
                    "description": "Mailbox to send from (same workspace).",
                    "type": "integer"
                  },
                  "subject": {
                    "description": "Subject to send as-is (no re-rendering).",
                    "type": "string"
                  },
                  "to": {
                    "description": "Recipient email (must contain @).",
                    "type": "string"
                  }
                },
                "required": [
                  "to",
                  "mailbox_id"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON {ok: true, sent_from: <mailbox email>} or {ok: false, error}."
          }
        },
        "summary": "Send a one-off test email for a lead",
        "tags": [
          "Leads"
        ],
        "x-destructive": true,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/mailboxes/add": {
      "post": {
        "description": "Assigns the given mailboxes to the campaign. Ids from other workspaces and already-assigned ids are ignored. With no valid ids it flashes 'No mailboxes selected'. Note: a campaign with no assigned mailboxes sends from every active mailbox in the workspace, so assigning narrows the sender pool.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_add_mailboxes",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "mailbox_ids": {
                    "description": "Repeatable. Mailbox ids to assign.",
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  }
                },
                "required": [
                  "mailbox_ids"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the mailboxes tab; flash 'Added N mailboxes'."
          }
        },
        "summary": "Assign mailboxes to a campaign",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/mailboxes/remove": {
      "post": {
        "description": "Removes one mailbox from the campaign's assignment (the mailbox itself is kept). If it was the last assigned mailbox the campaign falls back to all active workspace mailboxes. A missing or non-numeric mailbox_id does nothing.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_remove_mailbox",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "mailbox_id": {
                    "description": "Mailbox id to unassign.",
                    "type": "integer"
                  }
                },
                "required": [
                  "mailbox_id"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the mailboxes tab; flash 'Mailbox removed'."
          }
        },
        "summary": "Unassign a mailbox from a campaign",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/pause": {
      "post": {
        "description": "Sets status to paused so no further emails are sent. Works from any status. Nothing is deleted.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_pause_campaign",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "redirect_to": {
                    "description": "Send 'list' to redirect to the campaign list (UI only).",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; flash 'Campaign paused'."
          }
        },
        "summary": "Pause a campaign",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/pin": {
      "post": {
        "description": "Sets pinned. Pinned campaigns are listed first on the campaigns page and in GET /campaigns/list (newest first within pinned, then everything else newest first). The pin is workspace-wide, not per user. Omitting the field or sending false unpins. Nothing else about the campaign changes. 404 if the campaign is not in the workspace.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_set_pinned",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "pinned": {
                    "description": "true to pin, false to unpin.",
                    "type": "boolean"
                  }
                },
                "required": [
                  "pinned"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; flash 'pinned = true' or 'pinned = false' (the value now stored)."
          }
        },
        "summary": "Pin or unpin a campaign",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/sequence": {
      "post": {
        "description": "Replaces the whole multi-step sequence (every step, each with A/B variants). Preferred input is sequence_json; if it is missing or not a JSON list, the flat repeatable subject/body/wait fields are used instead (one variant per step). A positive wait on step 2+ becomes a wait step of that many days; the first step's wait is ignored. Refuses with flash 'Write at least one email before saving.' when every body is blank (nothing saved).\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_save_sequence",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "body": {
                    "description": "Fallback, repeatable, one per step. Used only when sequence_json is absent.",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "sequence_json": {
                    "description": "JSON list: [{\"wait\": <days before this step>, \"variants\": [{\"subject\": \"...\", \"body\": \"...\"}]}].",
                    "type": "string"
                  },
                  "subject": {
                    "description": "Fallback, repeatable, one per step. Used only when sequence_json is absent.",
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "wait": {
                    "description": "Fallback, repeatable, days to wait before each step after the first.",
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the sequence tab; flash 'Sequence saved'."
          }
        },
        "summary": "Save the email sequence",
        "tags": [
          "Sequence"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/settings": {
      "post": {
        "description": "Overwrites the campaign's sending schedule. Fields not sent are CLEARED, not kept: send_hour_start/end and send_tz_offset become null, send_days becomes every day, max_gap_minutes becomes null, cc and bcc become null. Only min_gap_minutes is kept when omitted or negative. The hour window is both-or-neither: if either hour is missing, both are cleared. 404 if the campaign is not in the workspace.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_save_settings",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "bcc": {
                    "description": "BCC address added to every send. Omit to clear.",
                    "type": "string"
                  },
                  "cc": {
                    "description": "CC address added to every send. Omit to clear.",
                    "type": "string"
                  },
                  "max_gap_minutes": {
                    "description": "Maximum gap in minutes (>= 0). Omit to clear.",
                    "type": "integer"
                  },
                  "min_gap_minutes": {
                    "description": "Minimum minutes between sends from one mailbox (>= 0).",
                    "type": "integer"
                  },
                  "send_days": {
                    "description": "Repeatable. Weekday numbers 0-6 (Mon=0, Sun=6). Empty means every day.",
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "send_hour_end": {
                    "description": "Window end hour in the campaign timezone.",
                    "type": "integer"
                  },
                  "send_hour_start": {
                    "description": "Window start hour (0-23) in the campaign timezone.",
                    "type": "integer"
                  },
                  "send_tz_offset": {
                    "description": "Timezone as minutes east of UTC (e.g. 330 = IST, -300 = ET, 0 = UTC).",
                    "type": "integer"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the campaign settings tab; flash 'Settings saved'."
          }
        },
        "summary": "Save sending schedule",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/campaigns/{campaign_id}/unarchive": {
      "post": {
        "description": "Moves an archived campaign back to paused (it never resumes sending by itself; launch it to send again). No change if it is not archived.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "campaigns_unarchive_campaign",
        "parameters": [
          {
            "in": "path",
            "name": "campaign_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "redirect_to": {
                    "description": "Send 'list' to redirect to the campaign list (UI only).",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; flash 'Campaign unarchived...'."
          }
        },
        "summary": "Unarchive a campaign",
        "tags": [
          "Campaigns"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/crm": {
      "get": {
        "description": "Kanban of replied leads, one column per stage, max 60 cards per column (count is the true total). Cached about 20 seconds.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_crm",
        "parameters": [
          {
            "description": "Limit to one campaign id.",
            "in": "query",
            "name": "campaign",
            "required": false,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {columns:[{key, label, tone, positive, count, cards:[{id, email, name, company, title, campaign, replied_at}]}], campaigns:[{id,name}]}."
          }
        },
        "summary": "Get the reply pipeline board",
        "tags": [
          "Pipeline"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/crm/move": {
      "post": {
        "description": "Sets a contact's lead stage (board column). 400 (no flash) if the stage is not valid or contact_id is missing/zero; 404 if the contact is not in the workspace.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "crm_move",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "contact_id": {
                    "description": "Contact (lead) id.",
                    "type": "integer"
                  },
                  "stage": {
                    "description": "One of: interested, meeting_request, not_interested, objection, pricing_question, more_info_needed, timing_issue, wrong_person, out_of_office, automated, unsubscribe, unclassified.",
                    "type": "string"
                  }
                },
                "required": [
                  "contact_id",
                  "stage"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the CRM board (no flash)."
          }
        },
        "summary": "Move a lead to a pipeline stage",
        "tags": [
          "Pipeline"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/dashboard": {
      "get": {
        "description": "Workspace totals, today vs yesterday, mailbox fleet, unread counts, stage counts, 14-day series, latest positive replies and up to 6 campaigns. Days are IST. Cached about 20 seconds. Returns {empty: true} when the user has no workspace.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_dashboard",
        "parameters": [],
        "responses": {
          "200": {
            "description": "JSON {empty, today, sends_today, sends_yesterday, sends_yesterday_same_time, replies_today, positive_today, totals:{leads, pending, emails_sent, reached, replied, positive, bounced, reply_rate, positive_rate, bounce_rate, emails_per_positive}, mailboxes:{total, active, needs_reconnect, sending_on, live, capacity_today, at_cap}, unread, unread_positive, stage_counts, series, recent_positive, campaigns}."
          }
        },
        "summary": "Dashboard summary",
        "tags": [
          "Analytics"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/domains/list": {
      "get": {
        "description": "Per-domain rollup of mailboxes, sending, DNS score, warmup health, blacklist and redirect state. Cached about 20 seconds.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_domains",
        "parameters": [],
        "responses": {
          "200": {
            "description": "JSON {domains:[{domain, mailboxes, active, needs_reconnect, sending_on, sent_today, capacity_today, kind, dns_score, warmup_health, inbox_pct, spam_pct, blacklisted, redirects_to}]}."
          }
        },
        "summary": "Domain health overview",
        "tags": [
          "Domains"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/domains/refresh": {
      "post": {
        "description": "Starts the blacklist and redirect checks in a background thread (about 15 seconds). Any logged-in user may call it (no role check beyond login). Results appear in GET /domains/list.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_domains_refresh",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Redirect; flash 'Refreshing domain checks in the background...'"
          }
        },
        "summary": "Refresh domain checks now",
        "tags": [
          "Domains"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/inbox/reminder/{reminder_id}/done": {
      "post": {
        "description": "Marks the reminder done. 404 if not in the workspace.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "inbox_reminder_done",
        "parameters": [
          {
            "in": "path",
            "name": "reminder_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redirect to the reminder's thread (no flash)."
          }
        },
        "summary": "Mark a reminder done",
        "tags": [
          "Inbox"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/inbox/stages": {
      "get": {
        "tags": [
          "Inbox"
        ],
        "summary": "List reply stages",
        "operationId": "api_inbox_stages",
        "description": "Every lead_stage key, in the order the CRM board shows them, with its label and whether it counts as positive.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "parameters": [],
        "responses": {
          "200": {
            "description": "JSON {stages:[{key, label, is_positive}]}."
          }
        },
        "x-min-role": "view",
        "x-destructive": false
      }
    },
    "/inbox/thread": {
      "get": {
        "description": "Full thread by id passed as a query parameter (SMTP thread ids contain special characters). SIDE EFFECT: opening a thread marks it read. 404 JSON if the id is blank or unknown.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_inbox_thread",
        "parameters": [
          {
            "description": "Thread id.",
            "in": "query",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {thread_id, mailbox, reply_to, can_send, subject, unread, messages:[{id, from, to, subject, body, at, outbound}], tags, reminders:[{id, at, note, done}], draft, contact:{id, email, name, company, title, status, stage}, campaign:{id,name}}."
          }
        },
        "summary": "Get one thread with messages",
        "tags": [
          "Inbox"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/inbox/thread/{thread_id}/draft": {
      "post": {
        "description": "Stores (or overwrites) the single draft reply body for the thread. Sends nothing.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "inbox_save_draft",
        "parameters": [
          {
            "in": "path",
            "name": "thread_id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "body": {
                    "description": "Draft text. Blank saves an empty draft.",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the thread; flash 'Draft saved.'"
          }
        },
        "summary": "Save a reply draft",
        "tags": [
          "Inbox"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/inbox/thread/{thread_id}/forward": {
      "post": {
        "description": "Sends a REAL email (new thread, subject 'Fwd: ...') containing the optional note plus the latest message body, from the thread's mailbox. Refuses when to is empty or the mailbox cannot send. 404 if the thread is not in the workspace.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "inbox_forward",
        "parameters": [
          {
            "in": "path",
            "name": "thread_id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "note": {
                    "description": "Text placed above the forwarded message.",
                    "type": "string"
                  },
                  "to": {
                    "description": "Recipient address.",
                    "type": "string"
                  }
                },
                "required": [
                  "to"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the thread; flash 'Forwarded.' or 'Could not forward: ...'."
          }
        },
        "summary": "Forward a thread's latest message",
        "tags": [
          "Inbox"
        ],
        "x-destructive": true,
        "x-min-role": "edit"
      }
    },
    "/inbox/thread/{thread_id}/remind": {
      "post": {
        "description": "Adds a reminder. remind_at is parsed with ISO 8601; a blank or unparseable value is stored as no date (no error).\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "inbox_set_reminder",
        "parameters": [
          {
            "in": "path",
            "name": "thread_id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "note": {
                    "description": "Reminder note.",
                    "type": "string"
                  },
                  "remind_at": {
                    "description": "ISO 8601 datetime, e.g. 2026-11-01T09:00.",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the thread (no flash)."
          }
        },
        "summary": "Set a follow-up reminder on a thread",
        "tags": [
          "Inbox"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/inbox/thread/{thread_id}/reply": {
      "post": {
        "description": "Sends a REAL email from the thread's mailbox (Gmail API or SMTP), logs it as an outbound message, marks the thread read and deletes the saved draft. If cc or bcc is set, the prior thread messages are quoted below the body. Refuses (flash 'Could not send: ...') when to or body is empty, the thread has no mailbox, the mailbox cannot send, or auth fails (mailbox is then set to needs_reconnect). 404 if the thread is not in the workspace.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "inbox_reply",
        "parameters": [
          {
            "in": "path",
            "name": "thread_id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "bcc": {
                    "description": "BCC address(es).",
                    "type": "string"
                  },
                  "body": {
                    "description": "Message body (must not be blank).",
                    "type": "string"
                  },
                  "cc": {
                    "description": "CC address(es).",
                    "type": "string"
                  },
                  "subject": {
                    "description": "Subject. Default 'Re:'.",
                    "type": "string"
                  },
                  "to": {
                    "description": "Recipient address.",
                    "type": "string"
                  }
                },
                "required": [
                  "to",
                  "body"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the thread; flash 'Reply sent.' or the error."
          }
        },
        "summary": "Reply to a thread",
        "tags": [
          "Inbox"
        ],
        "x-destructive": true,
        "x-min-role": "edit"
      }
    },
    "/inbox/thread/{thread_id}/stage": {
      "post": {
        "description": "Manually sets the matched contact's reply stage. Always allowed (overrides the classifier). Does not fire the campaign_reply webhook. Refuses with flash when the stage is invalid or the thread is not matched to a campaign contact.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "inbox_set_stage",
        "parameters": [
          {
            "in": "path",
            "name": "thread_id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "stage": {
                    "description": "One of: interested, meeting_request, not_interested, objection, pricing_question, more_info_needed, timing_issue, wrong_person, out_of_office, automated, unsubscribe, unclassified.",
                    "type": "string"
                  }
                },
                "required": [
                  "stage"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the thread; flash 'Marked as <label>.' or the refusal."
          }
        },
        "summary": "Set a thread's reply stage",
        "tags": [
          "Inbox"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/inbox/thread/{thread_id}/tag": {
      "post": {
        "description": "Adds a tag (max 64 chars) to the thread. Duplicate or empty tags do nothing.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "inbox_add_tag",
        "parameters": [
          {
            "in": "path",
            "name": "thread_id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "tag": {
                    "description": "Tag text.",
                    "type": "string"
                  }
                },
                "required": [
                  "tag"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the thread (no flash)."
          }
        },
        "summary": "Tag a thread",
        "tags": [
          "Inbox"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/inbox/thread/{thread_id}/untag": {
      "post": {
        "description": "Removes the exact tag from the thread. A tag that is not there does nothing.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "inbox_remove_tag",
        "parameters": [
          {
            "in": "path",
            "name": "thread_id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "tag": {
                    "description": "Exact tag text.",
                    "type": "string"
                  }
                },
                "required": [
                  "tag"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the thread (no flash)."
          }
        },
        "summary": "Remove a tag from a thread",
        "tags": [
          "Inbox"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/inbox/threads": {
      "get": {
        "description": "Conversation list, newest first, capped at 500 (truncated flag). Defaults show positive replies from campaigns with automated replies hidden. Boolean flags are true only when the value is exactly '1'. Counts are computed before the stage filter.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_inbox",
        "parameters": [
          {
            "description": "Stage key, 'positive' (default) or 'all'. Stages: interested, meeting_request, not_interested, objection, pricing_question, more_info_needed, timing_issue, wrong_person, out_of_office, automated, unsubscribe, unclassified.",
            "in": "query",
            "name": "stage",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "'1' (default) = campaign replies only; any other value includes non-campaign threads.",
            "in": "query",
            "name": "campaign_only",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "'1' (default) hides automated/out-of-office; any other value shows them.",
            "in": "query",
            "name": "exclude_auto",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "'1' = unread only. Default off.",
            "in": "query",
            "name": "unread",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "'1' = threads with an open reminder. Default off.",
            "in": "query",
            "name": "reminders",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Substring on sender, subject, snippet, contact email.",
            "in": "query",
            "name": "q",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Exact sending mailbox email.",
            "in": "query",
            "name": "mailbox",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Exact thread tag.",
            "in": "query",
            "name": "tag",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Campaign id.",
            "in": "query",
            "name": "campaign",
            "required": false,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {items:[{thread_id, prospect_email, name, company, title, subject, snippet, last_at, age_days, unread, messages, needs_reply, stage, campaign:{id,name}, mailbox, tags, has_reminder, is_auto}], truncated, filters, counts:{by_stage, positive, all, unread, unread_all, exact}, tags, mailboxes}."
          }
        },
        "summary": "List inbox threads",
        "tags": [
          "Inbox"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/inbox/{thread_id}/messages": {
      "get": {
        "tags": [
          "Inbox"
        ],
        "summary": "Get a thread's messages",
        "operationId": "api_thread_messages",
        "description": "Every message in one reply thread, oldest first. 404 if the thread isn't in the workspace.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "parameters": [
          {
            "name": "thread_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {thread_id, subject, messages:[{from, to, direction, subject, body, received_at}]}."
          }
        },
        "x-min-role": "view",
        "x-destructive": false
      }
    },
    "/leads/{contact_id}": {
      "get": {
        "description": "Full detail of one lead: custom fields, send history and linked inbox thread. 404 JSON if not in the workspace.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_lead_detail",
        "parameters": [
          {
            "in": "path",
            "name": "contact_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {id, email, name, company, title, status, stage, error, seq_step, sent_at, next_at, created_at, campaign:{id,name}, mailbox, fields:[{key,value}], sends:[{at, from, subject, status, detail}], thread_id}."
          }
        },
        "summary": "Get one lead",
        "tags": [
          "Leads"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/mailboxes/bulk": {
      "post": {
        "description": "Applies action to the listed mailbox ids (ids outside the workspace are silently ignored). Actions: enable_sending, disable_sending, add_tag (needs tag), remove_tag (needs tag), set_name (needs first_name and/or last_name), remove (DELETES the mailboxes), enable_warmup, disable_warmup, warmup_links_on, warmup_links_off, set_warmup_target (needs value, min 1), set_daily_limit (needs value, min 0). No ids or an unknown action flashes a hint and changes nothing.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "mailboxes_bulk",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "action": {
                    "description": "One of the actions listed in the description.",
                    "type": "string"
                  },
                  "first_name": {
                    "description": "For set_name.",
                    "type": "string"
                  },
                  "ids": {
                    "description": "Repeatable. Mailbox ids.",
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "last_name": {
                    "description": "For set_name.",
                    "type": "string"
                  },
                  "tag": {
                    "description": "Tag for add_tag / remove_tag.",
                    "type": "string"
                  },
                  "value": {
                    "description": "For set_warmup_target and set_daily_limit.",
                    "type": "integer"
                  }
                },
                "required": [
                  "action",
                  "ids"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect back; flash like 'Turned sending on for N mailboxes.'"
          }
        },
        "summary": "Apply one action to many mailboxes",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": true,
        "x-min-role": "edit"
      }
    },
    "/mailboxes/connect-microsoft-tenant": {
      "post": {
        "description": "Connects Outlook / Microsoft 365 mailboxes without a per-mailbox sign-in. The tenant's admin must first approve the Up app's application permissions (Mail.Send, Mail.ReadWrite, User.Read.All) once, via https://login.microsoftonline.com/<tenant>/v2.0/adminconsent?client_id=<app id>&scope=https://graph.microsoft.com/.default&redirect_uri=https://app.salessequencer.com/oauth/microsoft/callback. Every address is checked with a real Graph call (reads its inbox folder, sends nothing) before it is saved; failures are returned and never saved. Existing mailboxes with the same address in the workspace are switched to this tenant connection and set active. New mailboxes: kind microsoft, warm-up pool off, campaign ramp starts today. Shared mailboxes work. Super-admin only, because a consented tenant opens every mailbox in it. At most 100 emails per call.\n\n**Minimum role:** `superadmin` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_connect_microsoft_tenant",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "emails": {
                    "description": "Mailbox addresses, separated by newlines, commas or spaces (max 100).",
                    "type": "string"
                  },
                  "first_name": {
                    "description": "Sender first name for new mailboxes (kept on existing ones that have one).",
                    "type": "string"
                  },
                  "last_name": {
                    "description": "Sender last name for new mailboxes (kept on existing ones that have one).",
                    "type": "string"
                  },
                  "tenant": {
                    "description": "Tenant id (GUID) or domain, e.g. contoso.onmicrosoft.com.",
                    "type": "string"
                  }
                },
                "required": [
                  "tenant",
                  "emails"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON {ok, connected, reconnected, failed:[{email, reason}]}. 400 when tenant or emails is missing or more than 100 emails are sent."
          }
        },
        "summary": "Connect Outlook mailboxes of a whole Microsoft tenant",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": false,
        "x-min-role": "superadmin"
      }
    },
    "/mailboxes/connect-smtp": {
      "post": {
        "description": "Logs in to the SMTP server first (no email is sent) and only saves the mailbox if the login works. If the email already exists in the workspace it is overwritten with the new credentials and set active; otherwise a new SMTP mailbox is created with warmup pool off. Refuses: invalid email, missing host or password, failed login (flash with the reason). Also verifies an IMAP login (default: same host, 993 SSL, same credentials) and refuses to save if it fails.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_connect_smtp",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "email": {
                    "description": "Mailbox address (lowercased).",
                    "type": "string"
                  },
                  "first_name": {
                    "description": "Sender first name.",
                    "type": "string"
                  },
                  "imap_host": {
                    "description": "IMAP host for replies/bounces. Default: the SMTP host.",
                    "type": "string"
                  },
                  "imap_password": {
                    "description": "IMAP password. Default: the SMTP password.",
                    "type": "string"
                  },
                  "imap_port": {
                    "description": "IMAP port. Default 993.",
                    "type": "integer"
                  },
                  "imap_security": {
                    "description": "ssl, starttls or none. Default ssl.",
                    "type": "string"
                  },
                  "imap_username": {
                    "description": "IMAP login. Default: the SMTP login.",
                    "type": "string"
                  },
                  "last_name": {
                    "description": "Sender last name.",
                    "type": "string"
                  },
                  "smtp_host": {
                    "description": "SMTP server host.",
                    "type": "string"
                  },
                  "smtp_password": {
                    "description": "SMTP password (stored encrypted).",
                    "type": "string"
                  },
                  "smtp_port": {
                    "description": "Port. Default 587.",
                    "type": "integer"
                  },
                  "smtp_security": {
                    "description": "ssl, starttls or none. Default starttls; anything else becomes starttls.",
                    "type": "string"
                  },
                  "smtp_username": {
                    "description": "Login name. Defaults to the email.",
                    "type": "string"
                  }
                },
                "required": [
                  "email",
                  "smtp_host",
                  "smtp_password"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the mailbox list; flash 'Connected <email> over SMTP.' or 'Reconnected ...'."
          }
        },
        "summary": "Connect a mailbox over SMTP",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/mailboxes/list": {
      "get": {
        "description": "All mailboxes in the workspace with today's send count, filters and facets. Facets are computed before filters.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_mailboxes",
        "parameters": [
          {
            "description": "Substring on email or sender name.",
            "in": "query",
            "name": "q",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "active or needs_reconnect.",
            "in": "query",
            "name": "status",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "on or off (sending enabled).",
            "in": "query",
            "name": "sending",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Exact tag.",
            "in": "query",
            "name": "tag",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Exact domain (lowercased).",
            "in": "query",
            "name": "domain",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "email (default), status, sent_today, daily_limit, last_sent_at, dns_score, created_at.",
            "in": "query",
            "name": "sort",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "'desc' for descending. Anything else ascending.",
            "in": "query",
            "name": "dir",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Page size, 10-1000. Default 100.",
            "in": "query",
            "name": "size",
            "required": false,
            "schema": {
              "type": "integer"
            }
          },
          {
            "description": "Page number from 1.",
            "in": "query",
            "name": "page",
            "required": false,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {rows:[{id, email, domain, name, kind, status, enabled, daily_limit, sent_today, last_sent_at, last_error, tags, dns_score, warmup_health, created_at, campaigns}], total, page, pages, size, facets:{total, active, needs_reconnect, sending_on, sending_off, sent_today, capacity_today, tags, domains}}."
          }
        },
        "summary": "List mailboxes (filtered, paged)",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/mailboxes/reconnect-all": {
      "post": {
        "description": "Runs the bulk reconnect over all needs_reconnect mailboxes in the current workspace.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_reconnect_all",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Redirect; flash 'Reconnected N mailboxes.' plus counts of dead (need manual reconnect) and temporary errors."
          }
        },
        "summary": "Try to reconnect every broken mailbox",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/mailboxes/{mailbox_id}": {
      "get": {
        "description": "Mailbox row plus 14-day send series, recent sends, recent errors, campaigns using it, recent replies, DNS check. 404 JSON if not in the workspace.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_mailbox_detail",
        "parameters": [
          {
            "in": "path",
            "name": "mailbox_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {mailbox:{...list row fields, smtp_host, imap_host, signature, dns, dns_checked_at}, series:[{date,label,sent}], recent_sends:[{at,to,subject}], errors:[{at,to,detail}], campaigns:[{id,name,status}], replies:[{thread_id,from,subject,snippet,at}]}."
          }
        },
        "summary": "Get one mailbox in full",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/mailboxes/{mailbox_id}/delete": {
      "post": {
        "description": "Permanently removes the mailbox. Contacts that referenced it as sender are detached (mailbox_id set to null) first. Unknown id flashes 'Mailbox not found.'; a mailbox in another workspace is 404.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "mailboxes_delete",
        "parameters": [
          {
            "in": "path",
            "name": "mailbox_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redirect to the mailbox list; flash 'Removed <email>.'"
          }
        },
        "summary": "Delete a mailbox",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": true,
        "x-min-role": "edit"
      }
    },
    "/mailboxes/{mailbox_id}/dns-recheck": {
      "post": {
        "description": "Clears the mailbox's last DNS check time so the background job re-checks its DNS soon. Nothing is returned synchronously.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_dns_recheck",
        "parameters": [
          {
            "in": "path",
            "name": "mailbox_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redirect; flash 'DNS recheck queued, results refresh shortly.'"
          }
        },
        "summary": "Queue a DNS recheck",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/mailboxes/{mailbox_id}/reconnect": {
      "post": {
        "description": "Re-tests the stored token (Google) or SMTP login. Outcomes: reconnected (status back to active), dead, or temporary error. For a dead Google mailbox it redirects to /mailboxes/connect, which is a browser-only OAuth consent flow and cannot be completed through the API. A dead SMTP mailbox flashes the rejection reason and needs POST /mailboxes/connect-smtp with the right password.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_reconnect",
        "parameters": [
          {
            "in": "path",
            "name": "mailbox_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redirect; flash says reconnected, login rejected, or temporary error. Poll the mailbox for its new status."
          }
        },
        "summary": "Try to reconnect a mailbox",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/mailboxes/{mailbox_id}/settings": {
      "get": {
        "description": "The fields POST /mailboxes/<id>/settings edits, plus warmup toggles and SMTP details (no password). 404 JSON if not in the workspace.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_admin_mailbox_settings",
        "parameters": [
          {
            "in": "path",
            "name": "mailbox_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {id, email, kind, first_name, last_name, signature, daily_limit, warmup_limit, warmup_daily_target, warmup_pool_enabled, warmup_links_enabled, smtp_host, smtp_port, smtp_security, smtp_username}."
          }
        },
        "summary": "Get a mailbox's editable settings",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      },
      "post": {
        "description": "Updates limits, sender identity, burst rate and fast bounce polling. Every field is optional: an omitted field is left as it is. A daily_limit that is not a whole number refuses the call (flash 'Daily limit must be a whole number.') and saves NOTHING; negative becomes 0. sends_per_tick (1-50, default 1) is how many campaign sends one 30s send tick may make from this mailbox; each send still respects the daily cap and the campaign's min/max gap, so set the campaign gap to 0 for a real burst. fast_poll (IMAP mailboxes) moves the mailbox into the 10s bounce lane (INBOX + Junk, full DSN parser); send the string \"1\" to turn it on and \"0\" to turn it off (JSON false is dropped by the bridge and leaves it unchanged). first_name, last_name and signature are only touched when the field is present in the form; sending an empty value clears it. 404 if the mailbox is in another workspace; unknown id flashes 'Mailbox not found.'\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_settings",
        "parameters": [
          {
            "in": "path",
            "name": "mailbox_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "daily_limit": {
                    "description": "Campaign emails per day (>= 0). Omit to leave unchanged.",
                    "type": "integer"
                  },
                  "fast_poll": {
                    "description": "\"1\" = poll in the 10s bounce lane (IMAP mailboxes), \"0\" = back to the slow IMAP pass.",
                    "type": "string"
                  },
                  "first_name": {
                    "description": "Sender first name. Empty string clears.",
                    "type": "string"
                  },
                  "last_name": {
                    "description": "Sender last name. Empty string clears.",
                    "type": "string"
                  },
                  "next": {
                    "description": "Relative path to redirect to afterwards (UI only).",
                    "type": "string"
                  },
                  "sends_per_tick": {
                    "description": "Campaign sends per 30s tick from this mailbox, 1-50 (default 1).",
                    "type": "integer"
                  },
                  "signature": {
                    "description": "Per-inbox signature. Empty string clears.",
                    "type": "string"
                  },
                  "warmup_daily_target": {
                    "description": "Warmup emails per day (min 1). Ignored when blank.",
                    "type": "integer"
                  },
                  "warmup_limit": {
                    "description": "Warmup emails limit (>= 0). Ignored when blank.",
                    "type": "integer"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; flash 'Updated <email>.'"
          }
        },
        "summary": "Update mailbox settings",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/mailboxes/{mailbox_id}/tags": {
      "post": {
        "description": "Adds or removes one tag. Tags are cleaned (quotes and backslashes dropped, max 40 chars, max 20 tags per mailbox). An empty tag after cleaning does nothing.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_mailbox_tags",
        "parameters": [
          {
            "in": "path",
            "name": "mailbox_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "op": {
                    "description": "add or remove. Default add.",
                    "type": "string"
                  },
                  "tag": {
                    "description": "Tag text.",
                    "type": "string"
                  }
                },
                "required": [
                  "tag"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect back to the referring page (no flash)."
          }
        },
        "summary": "Add or remove a tag on a mailbox",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/mailboxes/{mailbox_id}/toggle-sending": {
      "post": {
        "description": "Sets or flips Mailbox.enabled (whether campaigns may send from it). Unrecognised or missing value flips the current state. 404 JSON if not found.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_toggle_sending",
        "parameters": [
          {
            "in": "path",
            "name": "mailbox_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "enabled": {
                    "description": "1/true/on to enable, 0/false/off to disable. Anything else toggles.",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON {ok: true, enabled: <bool>} or {ok: false, error: 'not_found'}."
          }
        },
        "summary": "Turn campaign sending on or off for a mailbox",
        "tags": [
          "Mailboxes"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/mailboxes/{mailbox_id}/toggle-warmup": {
      "post": {
        "description": "Sets or flips warmup_pool_enabled (joins or leaves the workspace warmup pool, which sends warmup emails between pool mailboxes). First enable stamps the pool start date. Unrecognised or missing value flips the state.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_toggle_warmup",
        "parameters": [
          {
            "in": "path",
            "name": "mailbox_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "enabled": {
                    "description": "1/true/on or 0/false/off. Anything else toggles.",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON {ok: true, enabled: <bool>} or 404 {ok: false, error: 'not_found'}."
          }
        },
        "summary": "Turn warmup on or off for a mailbox",
        "tags": [
          "Warmup"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/mailboxes/{mailbox_id}/toggle-warmup-links": {
      "post": {
        "description": "Sets or flips warmup_links_enabled (warmup emails include links from the warmup link library). Unrecognised or missing value flips the state.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_toggle_warmup_links",
        "parameters": [
          {
            "in": "path",
            "name": "mailbox_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "enabled": {
                    "description": "1/true/on or 0/false/off. Anything else toggles.",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON {ok: true, enabled: <bool>} or 404 {ok: false, error: 'not_found'}."
          }
        },
        "summary": "Turn link warmup on or off for a mailbox",
        "tags": [
          "Warmup"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/me": {
      "get": {
        "description": "Current user, active workspace, role and capability flags. Side effect: creates the session CSRF token if missing.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_me",
        "parameters": [],
        "responses": {
          "200": {
            "description": "JSON {user:{id, email, display_name, avatar_url}, workspace:{id,name,slug}, workspaces:[{id,name,slug}], role, can_edit, can_admin, is_superadmin, csrf, stages:[{key,label,tone,positive}], positive_stages, server_time, timezone, preview}."
          }
        },
        "summary": "Who am I",
        "tags": [
          "Account"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/placement": {
      "get": {
        "description": "The workspace's seed inboxes, the last 20 placement tests with per-seed results (landed_in: inbox, promotions, updates, other_tab, spam, other, missing, pending, send_failed, error; spf/dkim/dmarc verdicts) and a summary (inbox_pct counts inbox plus Gmail tabs over judged seeds), plus active mailboxes.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "placement_placement_get",
        "parameters": [],
        "responses": {
          "200": {
            "description": "JSON {wait_minutes, seeds:[...], tests:[{id, mailbox_email, status, summary, results:[...]}], mailboxes:[...]}."
          }
        },
        "summary": "Inbox placement: seeds, recent tests, mailboxes",
        "tags": [
          "Testing"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/placement/run": {
      "post": {
        "description": "For each mailbox in mailbox_ids, sends ONE real email to every enabled seed (never to itself), spun from the Testing template unless subject/body are given, with a reference code on the last line. Seeds are read every 30s; anything not found after 15 minutes is missing. Test sends are not campaign sends (no send_log rows). All mailboxes in one call form one run: every test gets run_id = the first test id.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "placement_placement_run",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "body": {
                    "description": "Override body (spintax ok).",
                    "type": "string"
                  },
                  "mailbox_ids": {
                    "description": "Mailbox ids to test (repeat the field, up to 20).",
                    "items": {
                      "type": "string"
                    },
                    "type": "array"
                  },
                  "subject": {
                    "description": "Override subject (spintax ok).",
                    "type": "string"
                  }
                },
                "required": [
                  "mailbox_ids"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON {ok, messages, test_ids, run_id}."
          }
        },
        "summary": "Run an inbox placement test",
        "tags": [
          "Testing"
        ],
        "x-destructive": true,
        "x-min-role": "edit"
      }
    },
    "/placement/seeds/add": {
      "post": {
        "description": "kind=imap adds a standalone seed (IMAP login is verified before saving; use an app password for Gmail/Yahoo/Outlook personal accounts). kind=mailbox makes a connected mailbox a seed (Gmail API, or its IMAP for SMTP mailboxes). provider is a free label; guessed from the domain when omitted.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "placement_placement_seed_add",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "email": {
                    "description": "Seed address (kind=imap).",
                    "type": "string"
                  },
                  "imap_host": {
                    "description": "IMAP host (kind=imap).",
                    "type": "string"
                  },
                  "imap_password": {
                    "description": "IMAP password or app password (kind=imap).",
                    "type": "string"
                  },
                  "imap_port": {
                    "description": "Default 993.",
                    "type": "integer"
                  },
                  "imap_security": {
                    "description": "ssl (default), starttls or none.",
                    "type": "string"
                  },
                  "imap_username": {
                    "description": "Default: the email.",
                    "type": "string"
                  },
                  "kind": {
                    "description": "imap or mailbox.",
                    "type": "string"
                  },
                  "mailbox_id": {
                    "description": "Connected mailbox to use (kind=mailbox).",
                    "type": "integer"
                  },
                  "provider": {
                    "description": "Label such as Gmail, Outlook, Yahoo.",
                    "type": "string"
                  }
                },
                "required": [
                  "kind"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON {ok, messages}."
          }
        },
        "summary": "Add a seed inbox",
        "tags": [
          "Testing"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/placement/seeds/{seed_id}/remove": {
      "post": {
        "description": "Stops using this seed in new tests. Past results stay.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "placement_placement_seed_remove",
        "parameters": [
          {
            "in": "path",
            "name": "seed_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {ok, messages}."
          }
        },
        "summary": "Remove a seed inbox",
        "tags": [
          "Testing"
        ],
        "x-destructive": true,
        "x-min-role": "edit"
      }
    },
    "/profile/avatar": {
      "post": {
        "description": "Center-crops the image to a 256px square PNG and replaces the user's avatar. Accepts .jpg, .jpeg, .png, .webp, .gif, max 6000x6000 pixels (and the app's upload size cap). Refuses with a flash on no file, wrong type, empty file, oversize dimensions or unreadable image.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "profile_upload_avatar",
        "parameters": [],
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "properties": {
                  "avatar": {
                    "description": "Image file.",
                    "format": "binary",
                    "type": "string"
                  }
                },
                "required": [
                  "avatar"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; flash 'Profile picture updated.' The avatar is then served at /static/avatars/user_<id>.png."
          }
        },
        "summary": "Upload my profile picture",
        "tags": [
          "Profile"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/profile/name": {
      "post": {
        "description": "Sets the logged-in user's display name. A blank value clears it.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "profile_update_name",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "display_name": {
                    "description": "New display name. Blank clears.",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; flash 'Name updated.'"
          }
        },
        "summary": "Change my display name",
        "tags": [
          "Profile"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/profile/password": {
      "post": {
        "description": "Changes the logged-in user's password. Refuses (flash): new password under 6 characters, new and confirm differ, current password wrong or none set.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "profile_update_password",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "confirm_password": {
                    "description": "Must equal new_password.",
                    "type": "string"
                  },
                  "current_password": {
                    "description": "Current password.",
                    "type": "string"
                  },
                  "new_password": {
                    "description": "New password, at least 6 characters.",
                    "type": "string"
                  }
                },
                "required": [
                  "current_password",
                  "new_password",
                  "confirm_password"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; flash 'Password changed.'"
          }
        },
        "summary": "Change my password",
        "tags": [
          "Profile"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/settings": {
      "get": {
        "description": "Lists the current workspace's members with roles, its API keys (label, prefix, created, last used; never the key itself) and its webhooks with their last delivery status. Admins only; others get allowed: false.\n\n**Minimum role:** `admin` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_settings",
        "parameters": [],
        "responses": {
          "200": {
            "description": "{allowed, members: [{email, name, role, since}], api_keys: [{id, label, prefix, created_at, last_used_at}], webhooks: [{id, url, event, enabled, label, last_triggered_at, last_status, last_error}]}"
          }
        },
        "summary": "Workspace settings: members, API keys, webhooks",
        "tags": [
          "Team & workspaces"
        ],
        "x-destructive": false,
        "x-min-role": "admin"
      }
    },
    "/settings/webhooks/create": {
      "post": {
        "description": "Subscribes a URL to the campaign_reply event (the only event; fixed in code). New campaign replies are POSTed to it from now on. Refuses (flash) unless url starts with http:// or https://.\n\n**Minimum role:** `admin` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "workspaces_create_webhook",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "label": {
                    "description": "Label.",
                    "type": "string"
                  },
                  "url": {
                    "description": "http(s) URL to receive the POST.",
                    "type": "string"
                  }
                },
                "required": [
                  "url"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; flash 'Webhook created...'. The new id is not returned."
          }
        },
        "summary": "Create a webhook",
        "tags": [
          "Webhooks"
        ],
        "x-destructive": false,
        "x-min-role": "admin"
      }
    },
    "/settings/webhooks/{hook_id}/delete": {
      "post": {
        "description": "Permanently deletes the subscription. 404 if not in the current workspace.\n\n**Minimum role:** `admin` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "workspaces_delete_webhook",
        "parameters": [
          {
            "in": "path",
            "name": "hook_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redirect; flash 'Webhook removed.'"
          }
        },
        "summary": "Delete a webhook",
        "tags": [
          "Webhooks"
        ],
        "x-destructive": true,
        "x-min-role": "admin"
      }
    },
    "/settings/webhooks/{hook_id}/toggle": {
      "post": {
        "description": "Flips the webhook's enabled flag (no way to set an explicit state). 404 if not in the current workspace.\n\n**Minimum role:** `admin` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "workspaces_toggle_webhook",
        "parameters": [
          {
            "in": "path",
            "name": "hook_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redirect; flash 'Webhook enabled.' or 'Webhook disabled.'"
          }
        },
        "summary": "Enable or disable a webhook",
        "tags": [
          "Webhooks"
        ],
        "x-destructive": false,
        "x-min-role": "admin"
      }
    },
    "/testing": {
      "get": {
        "description": "Saved test template, active mailboxes, relay options and the 60 most recent test sends. Creates the default template if none exists.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_testing_testing_get",
        "parameters": [],
        "responses": {
          "200": {
            "description": "JSON {template:{from_first, from_last, subject, body, to_address, mailbox_id}, mailboxes:[{id, email, first_name, last_name}], relays:[{key,label}], dedicated_ip, max_recipients, recent:[{id, at, from, to, subject, status, detail}]}."
          }
        },
        "summary": "Get the test-send page data",
        "tags": [
          "Testing"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/testing/send": {
      "post": {
        "description": "Saves the workspace test template (sender name, subject, body, recipients, mailbox), then sends REAL emails from the chosen active mailbox via Gmail. Spintax {a|b} is applied per recipient and {{sender_name}} is replaced with the From name. Recipients are split on comma, semicolon, whitespace, de-duplicated, capped at 100. One recipient is sent synchronously and the result returned; several are queued in a background thread and results show under 'recent' in GET /testing. Refusals (400 JSON, ok false): no valid recipient, blank subject, blank body, mailbox missing, not in the workspace or not active. Note the template is saved before validation, even if the send is refused.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "spa_testing_testing_send",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "body": {
                    "description": "Body (spintax allowed).",
                    "type": "string"
                  },
                  "first_name": {
                    "description": "From first name. Defaults to the mailbox's.",
                    "type": "string"
                  },
                  "last_name": {
                    "description": "From last name. Defaults to the mailbox's.",
                    "type": "string"
                  },
                  "mailbox_id": {
                    "description": "Active mailbox to send from.",
                    "type": "integer"
                  },
                  "relay": {
                    "description": "Egress box: contabo, ovh or hetzner. Anything else uses the default relay.",
                    "type": "string"
                  },
                  "subject": {
                    "description": "Subject (spintax allowed).",
                    "type": "string"
                  },
                  "to_address": {
                    "description": "Recipient(s), comma, space or newline separated (max 100).",
                    "type": "string"
                  }
                },
                "required": [
                  "to_address",
                  "mailbox_id",
                  "subject",
                  "body"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON {ok, queued, messages:[{category, message}]}. queued is 0 for a single synchronous send, otherwise the recipient count."
          }
        },
        "summary": "Send test emails",
        "tags": [
          "Testing"
        ],
        "x-destructive": true,
        "x-min-role": "edit"
      }
    },
    "/warmup-links": {
      "get": {
        "description": "The workspace's warmup link library.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_admin_warmup_links",
        "parameters": [],
        "responses": {
          "200": {
            "description": "JSON {links:[{id, url, label, active}]}."
          }
        },
        "summary": "List warmup links",
        "tags": [
          "Warmup"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/warmup/links/add": {
      "post": {
        "description": "Adds an active link to the workspace's warmup link library. Refuses (flash 'Enter a valid http(s) link.') unless url starts with http:// or https:// and is at most 2000 chars.\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "mailboxes_warmup_link_add",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "label": {
                    "description": "Label, truncated to 120 chars.",
                    "type": "string"
                  },
                  "url": {
                    "description": "http(s) URL, max 2000 chars.",
                    "type": "string"
                  }
                },
                "required": [
                  "url"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect back; flash 'Warmup link added.'"
          }
        },
        "summary": "Add a warmup link",
        "tags": [
          "Warmup"
        ],
        "x-destructive": false,
        "x-min-role": "edit"
      }
    },
    "/warmup/links/{link_id}/remove": {
      "post": {
        "description": "Deletes the link from the workspace library. An id that does not exist or belongs to another workspace does nothing (no error).\n\n**Minimum role:** `edit` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "mailboxes_warmup_link_remove",
        "parameters": [
          {
            "in": "path",
            "name": "link_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redirect back; flash 'Warmup link removed.' when it existed."
          }
        },
        "summary": "Remove a warmup link",
        "tags": [
          "Warmup"
        ],
        "x-destructive": true,
        "x-min-role": "edit"
      }
    },
    "/workspaces": {
      "get": {
        "description": "Every workspace the user can reach (super-admin sees all) with counts.\n\n**Minimum role:** `view` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_admin_workspaces",
        "parameters": [],
        "responses": {
          "200": {
            "description": "JSON {workspaces:[{id, name, slug, created_at, role, current, members, mailboxes, campaigns}], can_create}."
          }
        },
        "summary": "List accessible workspaces",
        "tags": [
          "Team & workspaces"
        ],
        "x-destructive": false,
        "x-min-role": "view"
      }
    },
    "/workspaces/new": {
      "post": {
        "description": "Creates a workspace with a unique slug derived from the name and a default test template. Super-admin only. Empty name refuses with a flash. The creator is not added as a member by this code.\n\n**Minimum role:** `superadmin` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "workspaces_new_workspace",
        "parameters": [],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "name": {
                    "description": "Workspace name.",
                    "type": "string"
                  }
                },
                "required": [
                  "name"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the new workspace's team page (new id in the URL)."
          }
        },
        "summary": "Create a workspace",
        "tags": [
          "Team & workspaces"
        ],
        "x-destructive": false,
        "x-min-role": "superadmin"
      }
    },
    "/workspaces/{ws_id}/api-keys": {
      "post": {
        "description": "Mints a workspace API key (sk_...). Only its hash is stored. The raw key appears exactly once, inside the flash message text; it cannot be recovered later. Admin of that workspace or super-admin. 404 if the workspace does not exist.\n\n**Minimum role:** `admin` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "workspaces_create_api_key",
        "parameters": [
          {
            "in": "path",
            "name": "ws_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "label": {
                    "description": "Label for the key.",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect; the raw key is only in the flash message 'New API key created, copy it now (shown once only): sk_...'."
          }
        },
        "summary": "Create an API key",
        "tags": [
          "API keys"
        ],
        "x-destructive": false,
        "x-min-role": "admin"
      }
    },
    "/workspaces/{ws_id}/api-keys/{key_id}/revoke": {
      "post": {
        "description": "Marks the key revoked (it stops working immediately; not undoable). 404 if the key is not in this workspace. Admin of that workspace or super-admin.\n\n**Minimum role:** `admin` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "workspaces_revoke_api_key",
        "parameters": [
          {
            "in": "path",
            "name": "ws_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "path",
            "name": "key_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redirect; flash 'API key revoked.'"
          }
        },
        "summary": "Revoke an API key",
        "tags": [
          "API keys"
        ],
        "x-destructive": true,
        "x-min-role": "admin"
      }
    },
    "/workspaces/{ws_id}/team": {
      "get": {
        "description": "Members with the membership ids the write routes need. Admin of THAT workspace or super-admin, else 403 JSON. 404 if the workspace does not exist.\n\n**Minimum role:** `admin` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "spa_admin_team",
        "parameters": [
          {
            "in": "path",
            "name": "ws_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "JSON {workspace:{id,name,slug}, members:[{membership_id, user_id, email, name, role, since, avatar_url, is_superadmin}], me}."
          }
        },
        "summary": "List a workspace's members",
        "tags": [
          "Team & workspaces"
        ],
        "x-destructive": false,
        "x-min-role": "admin"
      }
    },
    "/workspaces/{ws_id}/team/add": {
      "post": {
        "description": "Adds a member by email. If no user with that email exists, one is created with the given password (required then); if the user exists, the password is ignored. If already a member, their role is changed. Admin of THAT workspace or super-admin (403 otherwise). Refuses with flash: invalid email, invalid role, missing password for a new user. 404 if the workspace does not exist.\n\n**Minimum role:** `admin` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "workspaces_add_member",
        "parameters": [
          {
            "in": "path",
            "name": "ws_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "display_name": {
                    "description": "Display name (set only if the user has none).",
                    "type": "string"
                  },
                  "email": {
                    "description": "Teammate email (lowercased).",
                    "type": "string"
                  },
                  "password": {
                    "description": "Starting password. Required only when the user does not exist yet.",
                    "type": "string"
                  },
                  "role": {
                    "description": "admin, edit or view. Default view.",
                    "type": "string"
                  }
                },
                "required": [
                  "email"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the team page; flash 'Added <email> as <role>.' or 'Updated ...'."
          }
        },
        "summary": "Add or update a team member",
        "tags": [
          "Team & workspaces"
        ],
        "x-destructive": false,
        "x-min-role": "admin"
      }
    },
    "/workspaces/{ws_id}/team/remove": {
      "post": {
        "description": "Deletes the membership (the user account stays). Refuses to remove the only admin (flash). 400 if membership_id is missing, 404 if not in this workspace. Admin of that workspace or super-admin.\n\n**Minimum role:** `admin` (`x-min-role`). **Destructive:** `true` (`x-destructive`).",
        "operationId": "workspaces_remove_member",
        "parameters": [
          {
            "in": "path",
            "name": "ws_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "membership_id": {
                    "description": "Membership id.",
                    "type": "integer"
                  }
                },
                "required": [
                  "membership_id"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the team page; flash 'Member removed from this workspace.'"
          }
        },
        "summary": "Remove a member from a workspace",
        "tags": [
          "Team & workspaces"
        ],
        "x-destructive": true,
        "x-min-role": "admin"
      }
    },
    "/workspaces/{ws_id}/team/role": {
      "post": {
        "description": "Sets a membership's role. Refuses to demote the only admin (flash). 400 if membership_id is missing or not a number, 404 if the membership is not in this workspace. Admin of that workspace or super-admin.\n\n**Minimum role:** `admin` (`x-min-role`). **Destructive:** `false` (`x-destructive`).",
        "operationId": "workspaces_change_role",
        "parameters": [
          {
            "in": "path",
            "name": "ws_id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "membership_id": {
                    "description": "Membership id (from GET /workspaces/<id>/team).",
                    "type": "integer"
                  },
                  "role": {
                    "description": "admin, edit or view.",
                    "type": "string"
                  }
                },
                "required": [
                  "membership_id",
                  "role"
                ],
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect to the team page; flash 'Access updated.'"
          }
        },
        "summary": "Change a member's role",
        "tags": [
          "Team & workspaces"
        ],
        "x-destructive": false,
        "x-min-role": "admin"
      }
    }
  },
  "security": [
    {
      "key": []
    }
  ],
  "servers": [
    {
      "url": "https://api.salessequencer.com/api/v1"
    }
  ]
}
