{
  "openapi": "3.0.0",
  "info": {
    "title": "Ensoras API",
    "version": "1.0.0",
    "description": "Programmatic access to your project tickets: list and read tickets with their messages and attachments, and post replies. Authenticate using a project API key generated from the Developers page in your project settings."
  },
  "servers": [
    {
      "url": "https://app.ensoras.com/api/v1",
      "description": "Production"
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Project API key generated from the Developers page. Prefixed with `ensk_`."
      }
    },
    "schemas": {
      "Ticket": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique ticket identifier",
            "example": "01968adc-9f22-7b3d-b8a2-4e7c1d5f0a3b"
          },
          "title": {
            "type": "string",
            "description": "Ticket title",
            "example": "Unable to process my refund"
          },
          "description": {
            "type": "string",
            "nullable": true,
            "description": "Ticket description",
            "example": "I requested a refund 5 days ago and it still has not been processed."
          },
          "status": {
            "type": "string",
            "description": "Current ticket status",
            "enum": [
              "awaiting-ensoras",
              "awaiting-approval",
              "awaiting-agent",
              "awaiting-customer",
              "resolved",
              "closed"
            ],
            "example": "awaiting-customer"
          },
          "priority": {
            "type": "string",
            "description": "Ticket priority level",
            "enum": ["low", "medium", "high", "urgent"],
            "example": "medium"
          },
          "type": {
            "type": "string",
            "description": "Whether the ticket is handled by Ensoras or a human agent",
            "enum": ["ensoras", "human"],
            "example": "ensoras"
          },
          "channel": {
            "type": "string",
            "description": "Channel the ticket originated from",
            "enum": ["widget", "email"],
            "example": "email"
          },
          "customer_email": {
            "type": "string",
            "format": "email",
            "description": "Customer email address",
            "example": "jane@example.com"
          },
          "customer_name": {
            "type": "string",
            "nullable": true,
            "description": "Customer name",
            "example": "Jane Doe"
          },
          "assigned_to": {
            "type": "string",
            "nullable": true,
            "description": "Name of the assigned agent, if any",
            "example": "John Smith"
          },
          "team": {
            "type": "string",
            "nullable": true,
            "description": "Name of the assigned team, if any",
            "example": "Billing Support"
          },
          "tags": {
            "type": "array",
            "description": "List of tag names",
            "items": {
              "type": "string"
            },
            "example": ["billing", "refund"]
          },
          "messages_count": {
            "type": "integer",
            "description": "Total number of messages in the ticket",
            "example": 4
          },
          "scheduled_messages_count": {
            "type": "integer",
            "description": "Replies queued to send later (status `scheduled`)",
            "example": 1
          },
          "next_scheduled_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When the earliest queued reply goes out (ISO 8601), or null when nothing is scheduled",
            "example": "2025-12-02T09:00:00+00:00"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the ticket was created (ISO 8601)",
            "example": "2025-12-01T14:30:00+00:00"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the ticket was last updated (ISO 8601)",
            "example": "2025-12-02T09:15:00+00:00"
          }
        }
      },
      "TicketWithMessages": {
        "allOf": [
          { "$ref": "#/components/schemas/Ticket" },
          {
            "type": "object",
            "properties": {
              "messages": {
                "type": "array",
                "description": "Sent messages in chronological order (oldest first). Drafts and pending messages are excluded.",
                "items": { "$ref": "#/components/schemas/Message" }
              }
            }
          }
        ]
      },
      "Message": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique message identifier",
            "example": "01968adc-a143-7d9e-9b52-3f8e2a6c0d4e"
          },
          "role": {
            "type": "string",
            "description": "Who sent the message",
            "enum": ["user", "assistant", "agent", "system"],
            "example": "user"
          },
          "status": {
            "type": "string",
            "description": "Delivery status. Messages returned by Get ticket are always `sent`. A reply posted with `status=draft` comes back as `draft` until it is approved in the ticket view; a reply posted with `scheduled_at` comes back as `scheduled` until it goes out.",
            "enum": ["sent", "draft", "scheduled"],
            "example": "sent"
          },
          "message": {
            "type": "string",
            "nullable": true,
            "description": "Message content",
            "example": "I requested a refund 5 days ago and it still has not been processed."
          },
          "agent_name": {
            "type": "string",
            "nullable": true,
            "description": "Name of the agent who sent the message (for assistant/agent roles)",
            "example": "Ensora"
          },
          "is_manual_reply": {
            "type": "boolean",
            "description": "Whether this message was manually sent by a human agent",
            "example": false
          },
          "attachments": {
            "type": "array",
            "description": "Files and images attached to the message, in upload order",
            "items": { "$ref": "#/components/schemas/Attachment" }
          },
          "scheduled_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When a scheduled reply will be sent (ISO 8601). Null for replies that were not scheduled.",
            "example": null
          },
          "sent_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When the message was sent (ISO 8601)",
            "example": "2025-12-01T14:30:00+00:00"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the message was created (ISO 8601)",
            "example": "2025-12-01T14:30:00+00:00"
          }
        }
      },
      "Attachment": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique attachment identifier",
            "example": "01968adc-b2e1-7f40-8c3a-5d9e1b7a2c6f"
          },
          "filename": {
            "type": "string",
            "description": "Original file name",
            "example": "damaged-box.jpg"
          },
          "mime_type": {
            "type": "string",
            "description": "File type",
            "enum": ["image/jpeg", "image/png", "image/webp", "image/gif", "application/pdf"],
            "example": "image/jpeg"
          },
          "size_bytes": {
            "type": "integer",
            "description": "File size in bytes",
            "example": 184320
          },
          "width": {
            "type": "integer",
            "nullable": true,
            "description": "Image width in pixels (null for PDFs)",
            "example": 1280
          },
          "height": {
            "type": "integer",
            "nullable": true,
            "description": "Image height in pixels (null for PDFs)",
            "example": 960
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Signed link that serves the file. Images open inline, PDFs always download. Expires at `url_expires_at`; fetch the ticket again for a fresh link.",
            "example": "https://app.ensoras.com/attachments/01968adc-b2e1-7f40-8c3a-5d9e1b7a2c6f/damaged-box.jpg?expires=1764600600&signature=..."
          },
          "download_url": {
            "type": "string",
            "format": "uri",
            "description": "Signed link that always downloads the file. Expires at `url_expires_at`.",
            "example": "https://app.ensoras.com/attachments/01968adc-b2e1-7f40-8c3a-5d9e1b7a2c6f/damaged-box.jpg?download=1&expires=1764600600&signature=..."
          },
          "url_expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "When `url` and `download_url` stop working (60 minutes after the response)",
            "example": "2025-12-01T15:30:00+00:00"
          }
        }
      },
      "PaginationMeta": {
        "type": "object",
        "properties": {
          "current_page": {
            "type": "integer",
            "example": 1
          },
          "from": {
            "type": "integer",
            "nullable": true,
            "example": 1
          },
          "last_page": {
            "type": "integer",
            "example": 3
          },
          "per_page": {
            "type": "integer",
            "example": 25
          },
          "to": {
            "type": "integer",
            "nullable": true,
            "example": 25
          },
          "total": {
            "type": "integer",
            "example": 72
          },
          "path": {
            "type": "string",
            "example": "https://app.ensoras.com/api/v1/tickets"
          }
        }
      },
      "PaginationLinks": {
        "type": "object",
        "properties": {
          "first": {
            "type": "string",
            "nullable": true,
            "example": "https://app.ensoras.com/api/v1/tickets?page=1"
          },
          "last": {
            "type": "string",
            "nullable": true,
            "example": "https://app.ensoras.com/api/v1/tickets?page=3"
          },
          "prev": {
            "type": "string",
            "nullable": true,
            "example": null
          },
          "next": {
            "type": "string",
            "nullable": true,
            "example": "https://app.ensoras.com/api/v1/tickets?page=2"
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string",
            "example": "Unauthorized"
          }
        }
      },
      "ValidationError": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string",
            "example": "The message field is required when attachments is not present."
          },
          "errors": {
            "type": "object",
            "description": "Validation messages keyed by field name",
            "additionalProperties": {
              "type": "array",
              "items": { "type": "string" }
            },
            "example": {
              "message": ["The message field is required when attachments is not present."]
            }
          }
        }
      }
    },
    "parameters": {
      "PerPage": {
        "name": "per_page",
        "in": "query",
        "description": "Number of results per page (default: 25, max: 100)",
        "schema": {
          "type": "integer",
          "default": 25,
          "minimum": 1,
          "maximum": 100
        }
      },
      "Page": {
        "name": "page",
        "in": "query",
        "description": "Page number",
        "schema": {
          "type": "integer",
          "default": 1,
          "minimum": 1
        }
      }
    }
  },
  "security": [
    { "bearerAuth": [] }
  ],
  "tags": [
    {
      "name": "Tickets",
      "description": "Read your project tickets and messages, and reply to them."
    }
  ],
  "paths": {
    "/tickets": {
      "get": {
        "tags": ["Tickets"],
        "summary": "List tickets",
        "description": "Returns a paginated list of tickets for your project. Results are sorted by creation date (newest first). Use query parameters to filter results.",
        "operationId": "listTickets",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "Filter by ticket status",
            "schema": {
              "type": "string",
              "enum": [
                "awaiting-ensoras",
                "awaiting-approval",
                "awaiting-agent",
                "awaiting-customer",
                "resolved",
                "closed"
              ]
            }
          },
          {
            "name": "priority",
            "in": "query",
            "description": "Filter by ticket priority",
            "schema": {
              "type": "string",
              "enum": ["low", "medium", "high", "urgent"]
            }
          },
          {
            "name": "channel",
            "in": "query",
            "description": "Filter by ticket channel",
            "schema": {
              "type": "string",
              "enum": ["widget", "email"]
            }
          },
          {
            "name": "customer_email",
            "in": "query",
            "description": "Filter by customer email address. The whole address must match, but the comparison ignores case.",
            "schema": {
              "type": "string",
              "format": "email"
            }
          },
          {
            "name": "customer_name",
            "in": "query",
            "description": "Filter by customer name. Matches any part of the name and ignores case, so \"coop\" finds \"Alice Cooper\".",
            "schema": {
              "type": "string"
            }
          },
          { "$ref": "#/components/parameters/PerPage" },
          { "$ref": "#/components/parameters/Page" }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of tickets",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/Ticket" }
                    },
                    "links": { "$ref": "#/components/schemas/PaginationLinks" },
                    "meta": { "$ref": "#/components/schemas/PaginationMeta" }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (60 requests per minute)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          }
        }
      }
    },
    "/tickets/{uuid}": {
      "get": {
        "tags": ["Tickets"],
        "summary": "Get ticket",
        "description": "Returns a single ticket with its messages. Only sent messages are included (drafts, pending, and rejected messages are excluded). Messages are ordered oldest first. Each message lists its attachments with signed links that stay valid for 60 minutes.",
        "operationId": "getTicket",
        "parameters": [
          {
            "name": "uuid",
            "in": "path",
            "required": true,
            "description": "The ticket ID (UUID)",
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "01968adc-9f22-7b3d-b8a2-4e7c1d5f0a3b"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ticket with messages",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "$ref": "#/components/schemas/TicketWithMessages" }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "404": {
            "description": "Ticket not found or belongs to another project",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (60 requests per minute)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          }
        }
      }
    },
    "/tickets/{uuid}/messages": {
      "post": {
        "tags": ["Tickets"],
        "summary": "Reply to ticket",
        "description": "Posts a reply on the ticket as a human agent. By default the reply goes to the customer right away, on the ticket's channel (chat widget or email), and any Ensoras draft still awaiting approval is rejected. Send `status=draft` to hold the reply for approval instead: it appears in the ticket view with Approve and Reject buttons, the ticket moves to `awaiting-approval`, and nothing reaches the customer until someone approves it. A ticket holds one reply awaiting approval at a time.\n\nSend `scheduled_at` to deliver the reply later (up to 48 hours ahead). It is stored as `scheduled` and goes out to the customer at that time.\n\nSend `multipart/form-data` to include files (up to 5, 20 MB each, JPEG, PNG, WebP, GIF, or PDF). Text-only replies can also be sent as JSON.",
        "operationId": "replyToTicket",
        "parameters": [
          {
            "name": "uuid",
            "in": "path",
            "required": true,
            "description": "The ticket ID (UUID)",
            "schema": {
              "type": "string",
              "format": "uuid",
              "example": "01968adc-9f22-7b3d-b8a2-4e7c1d5f0a3b"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "message": {
                    "type": "string",
                    "maxLength": 20000,
                    "description": "Reply text. Required unless at least one attachment is sent.",
                    "example": "Your refund was issued today. It should reach your card within 3 to 5 business days."
                  },
                  "status": {
                    "type": "string",
                    "enum": ["sent", "draft"],
                    "default": "sent",
                    "description": "`sent` delivers the reply now. `draft` holds it for approval in the ticket view."
                  },
                  "agent_name": {
                    "type": "string",
                    "maxLength": 255,
                    "description": "Name of the person replying, shown as the sender in the ticket view. Shown as \"Agent\" when omitted. Email replies still go out from your support address.",
                    "example": "Maria"
                  },
                  "scheduled_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Send the reply later instead of now (ISO 8601, include the timezone offset). Must be in the future and at most 48 hours ahead. Not allowed with `status=draft`.",
                    "example": "2025-12-02T09:00:00+02:00"
                  },
                  "attachments[]": {
                    "type": "array",
                    "maxItems": 5,
                    "description": "Files to attach (JPEG, PNG, WebP, GIF, or PDF, up to 20 MB each)",
                    "items": {
                      "type": "string",
                      "format": "binary"
                    }
                  }
                }
              }
            },
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["message"],
                "properties": {
                  "message": {
                    "type": "string",
                    "maxLength": 20000,
                    "example": "Your refund was issued today. It should reach your card within 3 to 5 business days."
                  },
                  "status": {
                    "type": "string",
                    "enum": ["sent", "draft"],
                    "default": "sent"
                  },
                  "agent_name": {
                    "type": "string",
                    "maxLength": 255,
                    "example": "Maria"
                  },
                  "scheduled_at": {
                    "type": "string",
                    "format": "date-time",
                    "example": "2025-12-02T09:00:00+02:00"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Reply created (sent, held as a draft, or scheduled)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "$ref": "#/components/schemas/Message" }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "404": {
            "description": "Ticket not found or belongs to another project",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "409": {
            "description": "Sent with `status=draft` while the ticket already has a reply awaiting approval. Approve or reject that reply in the ticket view first.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "422": {
            "description": "Invalid input, or a file was rejected",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ValidationError" }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (60 requests per minute)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          }
        }
      }
    },
    "/tickets/{uuid}/messages/{messageUuid}": {
      "patch": {
        "tags": [
          "Tickets"
        ],
        "summary": "Edit draft reply",
        "operationId": "editDraftReply",
        "description": "Replaces the text of a reply that is waiting for approval, whether Ensoras drafted it or it was posted with `status=draft`. The reply stays a draft and is still approved or rejected in the ticket view; approving it sends the edited text. Only drafts can be edited: sent, scheduled, rejected replies, replies already being approved, and Ensoras drafts still waiting on tool approval return 409.",
        "parameters": [
          {
            "name": "uuid",
            "in": "path",
            "required": true,
            "description": "The ticket ID (UUID)",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "messageUuid",
            "in": "path",
            "required": true,
            "description": "The message ID (UUID) from the reply response",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "message"
                ],
                "properties": {
                  "message": {
                    "type": "string",
                    "maxLength": 20000,
                    "description": "New reply text. May be empty only when the draft has attachments.",
                    "example": "Your refund was issued today. It should reach your card shortly."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Draft updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Message"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Ticket or message not found, or it belongs to another project",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The message is not a draft awaiting approval",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Invalid input",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (60 requests per minute)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/tickets/{uuid}/resolve": {
      "post": {
        "tags": [
          "Tickets"
        ],
        "summary": "Resolve ticket",
        "operationId": "resolveTicket",
        "description": "Marks the ticket `resolved`, the same state Ensoras uses when it judges a conversation done. It counts toward your resolution stats, and if the customer writes again the ticket reopens. Resolving an already resolved ticket changes nothing. A closed ticket returns 409.",
        "parameters": [
          {
            "name": "uuid",
            "in": "path",
            "required": true,
            "description": "The ticket ID (UUID)",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ticket resolved",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Ticket"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Ticket not found or belongs to another project",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The ticket is already closed",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (60 requests per minute)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  }
}
