{
  "openapi": "3.1.0",
  "info": {
    "title": "Uniport API",
    "version": "1.0.0",
    "summary": "One inbox for every SaaS: ticket intake and management.",
    "description": "Uniport has two surfaces.\n\n**Intake** (`/api/v1/intake`, `/api/v1/upload`) is what your products call to file a ticket. It authenticates with a *project key* (`upk_`), which is scoped to exactly one project and can only submit.\n\n**Management** (everything under `/api/v1` tagged `Management`) is what a CLI, an agent, or your own tooling calls to read and answer tickets. It authenticates with a *personal key* (`usk_`), which acts as you across every project you are a member of. Mint one at https://uniport.sh/settings/tokens.\n\nThe two key populations can never cross-authenticate: a `upk_` key is rejected everywhere except intake and upload, and a `usk_` key is rejected by intake. `/api/v1/whoami` accepts either and tells you which one you are holding.\n\nEvery error, on every route, is `{\"error\":{\"code\":\"…\",\"message\":\"…\"}}`. Switch on `code` — it is the stable part of the contract. `message` is written for humans and may be reworded at any time.",
    "contact": {
      "name": "Uniport",
      "url": "https://uniport.sh/docs"
    }
  },
  "externalDocs": {
    "description": "Guides, quickstart and copy-paste snippets",
    "url": "https://uniport.sh/docs"
  },
  "servers": [
    {
      "url": "https://uniport.sh",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Meta",
      "description": "Connectivity and key introspection."
    },
    {
      "name": "Intake",
      "description": "Filing tickets from your products. Project key (upk_)."
    },
    {
      "name": "Attachments",
      "description": "Signed upload handshake for files referenced by intake."
    },
    {
      "name": "Management",
      "description": "Reading and answering tickets, and managing projects and keys. Personal key (usk_) unless noted."
    }
  ],
  "security": [
    {
      "personalKey": []
    }
  ],
  "paths": {
    "/api/v1/ping": {
      "get": {
        "tags": [
          "Meta"
        ],
        "operationId": "ping",
        "summary": "Connectivity probe (no auth)",
        "description": "Answers \"am I talking to a Uniport API, and which version?\". Deliberately unauthenticated and does not touch the database — use `/api/health` if you want a probe that fails when Postgres is unreachable.",
        "security": [],
        "responses": {
          "200": {
            "description": "The API is reachable.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "service",
                    "version",
                    "time"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "service": {
                      "type": "string",
                      "const": "uniport"
                    },
                    "version": {
                      "type": "string",
                      "const": "1"
                    },
                    "time": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "service": "uniport",
                  "version": "1",
                  "time": "2026-07-28T09:15:00.000Z"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/whoami": {
      "get": {
        "tags": [
          "Meta",
          "Management"
        ],
        "operationId": "whoami",
        "summary": "Identify the presented key",
        "description": "Accepts either key type. A project key (`upk_`) resolves to its organization; a personal key (`usk_`) resolves to its user. `rate_limit` reports the limits actually enforced for that key, not a nominal default.",
        "security": [
          {
            "personalKey": []
          },
          {
            "projectKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Key identity and effective limits.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WhoAmI"
                },
                "examples": {
                  "personal": {
                    "summary": "Personal key (usk_)",
                    "value": {
                      "key_type": "personal",
                      "key_id": "0f4d1c2a-6d1b-4a7e-9a3c-8f6b2d5e1c90",
                      "organization": null,
                      "user": {
                        "id": "usr_9c1",
                        "email": "you@example.com"
                      },
                      "rate_limit": {
                        "max": 240,
                        "window_ms": 600000
                      }
                    }
                  },
                  "project": {
                    "summary": "Project key (upk_)",
                    "value": {
                      "key_type": "project",
                      "key_id": "3b1f7e88-2c04-4a11-b0d9-7e2a5c31f004",
                      "organization": {
                        "id": "org_7d2",
                        "slug": "bouncyboobs",
                        "name": "Bouncyboobs"
                      },
                      "user": null,
                      "rate_limit": {
                        "max": 20,
                        "window_ms": 600000
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/intake": {
      "post": {
        "tags": [
          "Intake"
        ],
        "operationId": "createTicket",
        "summary": "File a ticket",
        "description": "The one endpoint your products need. Creates (or reuses) a customer by email within the project the key belongs to, opens a ticket, and stores the first message.\n\nThe ticket is committed before notification email is attempted, so a Resend outage never costs you a bug report — and a 5xx from this endpoint always means nothing was written.\n\nRate limited twice: per client IP before authentication (120 / 10 min) and per project key after (20 / 10 min by default).",
        "security": [
          {
            "projectKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/IntakeRequest"
              },
              "examples": {
                "minimal": {
                  "summary": "Everything Uniport actually requires",
                  "value": {
                    "email": "jane@example.com",
                    "message": "The export button does nothing on Safari."
                  }
                },
                "full": {
                  "summary": "Contact form with context and an attachment",
                  "value": {
                    "name": "Jane Doe",
                    "email": "jane@example.com",
                    "subject": "Export is broken",
                    "message": "The export button does nothing on Safari 18.",
                    "attachments": [
                      {
                        "url": "https://abc123.public.blob.vercel-storage.com/screenshot-9f2c.png",
                        "filename": "screenshot.png",
                        "contentType": "image/png",
                        "sizeBytes": 184320
                      }
                    ],
                    "source": {
                      "page": "/pricing",
                      "plan": "pro",
                      "app_version": "2026.7.3"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Ticket created.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IntakeResponse"
                },
                "example": {
                  "ok": true,
                  "ticket_id": "1f9a3c6e-88b2-4f1d-9a17-2c5e7b0d4a33",
                  "short_code": "BOUNCY-A3F291E7",
                  "status_url": "https://uniport.sh/t/BOUNCY-A3F291E7?k=6b1d9f0c2e4a7b83c5d1e0f9a2b3c4d5"
                }
              }
            }
          },
          "400": {
            "description": "`invalid_json` (body was not JSON) or `invalid_request` (body failed validation; `details` carries the flattened field errors).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "invalid_request",
                    "message": "Request body failed validation.",
                    "details": {
                      "formErrors": [],
                      "fieldErrors": {
                        "email": [
                          "Invalid email address"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "`project_not_found` — the key authenticated, but the project it belongs to has been deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "project_not_found",
                    "message": "The project this API key belongs to no longer exists."
                  }
                }
              }
            }
          },
          "422": {
            "description": "`flagged_as_spam` — the message tripped the spam filter and no ticket was created. Do not retry the same body.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "flagged_as_spam",
                    "message": "This message was flagged by the spam filter."
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/upload": {
      "post": {
        "tags": [
          "Attachments"
        ],
        "operationId": "createUploadToken",
        "summary": "Mint a short-lived Vercel Blob upload token",
        "description": "Step one of the attachment handshake. Speaks the `@vercel/blob` client-upload protocol: post a `blob.generate-client-token` event and you get back a `clientToken` your client uses to `put()` the file straight to Vercel Blob, bypassing the 4.5 MB serverless body limit.\n\nThe resulting blob URL is then passed to `/api/v1/intake` in `attachments[]`. Intake only accepts `https://*.public.blob.vercel-storage.com` URLs, so a blob you did not upload through here will be rejected.\n\nAccepted types: PNG, JPEG, WebP, GIF, HEIC, PDF, plain text, ZIP. Ceiling: 25 MB per file, 10 attachments per ticket. Rate limited per project key on the same budget as intake — token minting is the expensive half of an upload.\n\nThe second event type, `blob.upload-completed`, is Vercel Blob calling back after the transfer finishes; Uniport records nothing from it (the attachment row is written by intake) and answers `{\"type\":\"blob.upload-completed\",\"response\":\"ok\"}`.",
        "security": [
          {
            "projectKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UploadRequest"
              },
              "examples": {
                "generateToken": {
                  "summary": "Ask for an upload token",
                  "value": {
                    "type": "blob.generate-client-token",
                    "payload": {
                      "pathname": "screenshot.png",
                      "multipart": false,
                      "clientPayload": null
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Upload token minted, or upload-completed callback acknowledged.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UploadResponse"
                },
                "example": {
                  "type": "blob.generate-client-token",
                  "clientToken": "vercel_blob_client_…"
                }
              }
            }
          },
          "400": {
            "description": "`invalid_json`, `invalid_request` (malformed client-upload payload), or `upload_failed` (the token could not be created for this file).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "413": {
            "description": "`file_too_large` — files must be 25 MB or smaller.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "file_too_large",
                    "message": "Files must be 25MB or smaller."
                  }
                }
              }
            }
          },
          "415": {
            "description": "`unsupported_content_type` — either the request was not `application/json`, or the file's media type is not on the allowed list.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "`upload_not_configured` — blob storage is unavailable or missing credentials on the server. Retry later; the ticket itself can still be filed without the attachment.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "upload_not_configured",
                    "message": "Attachment uploads are not available right now."
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/tickets": {
      "get": {
        "tags": [
          "Management"
        ],
        "operationId": "listTickets",
        "summary": "List tickets across your projects",
        "description": "Returns tickets from every project you are a member of, newest first. Keyset paginated: pass the previous response's `next_cursor` back as `cursor` until it comes back `null`. Offsets are not supported — a cursor is stable even while new tickets arrive.",
        "security": [
          {
            "personalKey": []
          }
        ],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "Filter by status. Defaults to `open`.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "open",
                "resolved",
                "all"
              ],
              "default": "open"
            }
          },
          {
            "name": "project",
            "in": "query",
            "description": "Restrict to one project, by slug.",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 64
            },
            "example": "bouncyboobs"
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Page size.",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Opaque keyset cursor from a previous response's `next_cursor`. Treat it as a blob; its encoding is not part of the contract.",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 256
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of tickets.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "tickets",
                    "next_cursor"
                  ],
                  "properties": {
                    "tickets": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/TicketSummary"
                      }
                    },
                    "next_cursor": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Pass as `cursor` for the next page. `null` on the last page."
                    }
                  }
                },
                "example": {
                  "tickets": [
                    {
                      "short_code": "BOUNCY-A3F291E7",
                      "subject": "Export is broken",
                      "status": "open",
                      "customer_name": "Jane Doe",
                      "customer_email": "jane@example.com",
                      "project_slug": "bouncyboobs",
                      "created_at": "2026-07-28T09:12:44.000Z",
                      "updated_at": "2026-07-28T09:12:44.000Z"
                    }
                  ],
                  "next_cursor": null
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/tickets/{code}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/TicketCode"
        }
      ],
      "get": {
        "tags": [
          "Management"
        ],
        "operationId": "getTicket",
        "summary": "Read one ticket with its full thread",
        "description": "Messages come back oldest first, each with its attachments. A ticket belonging to a project you are not a member of returns `not_found` — indistinguishable from a code that does not exist, by design.",
        "security": [
          {
            "personalKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "The ticket and its messages.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ticket"
                  ],
                  "properties": {
                    "ticket": {
                      "$ref": "#/components/schemas/TicketDetail"
                    }
                  }
                },
                "example": {
                  "ticket": {
                    "short_code": "BOUNCY-A3F291E7",
                    "subject": "Export is broken",
                    "status": "open",
                    "created_at": "2026-07-28T09:12:44.000Z",
                    "updated_at": "2026-07-28T09:40:02.000Z",
                    "resolved_at": null,
                    "project_slug": "bouncyboobs",
                    "customer": {
                      "name": "Jane Doe",
                      "email": "jane@example.com"
                    },
                    "messages": [
                      {
                        "author": "customer",
                        "author_name": "Jane Doe",
                        "body": "The export button does nothing on Safari 18.",
                        "created_at": "2026-07-28T09:12:44.000Z",
                        "attachments": [
                          {
                            "filename": "screenshot.png",
                            "content_type": "image/png",
                            "size_bytes": 184320,
                            "url": "https://abc123.public.blob.vercel-storage.com/screenshot-9f2c.png"
                          }
                        ]
                      },
                      {
                        "author": "agent",
                        "author_name": "Boris",
                        "body": "Fixed in 2026.7.4 — can you retry?",
                        "created_at": "2026-07-28T09:40:02.000Z",
                        "attachments": []
                      }
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "patch": {
        "tags": [
          "Management"
        ],
        "operationId": "setTicketStatus",
        "summary": "Open or resolve a ticket",
        "description": "Sets status without sending anything to the customer. Resolving stamps `resolved_at`; reopening clears it. Use the reply endpoint instead when the customer should hear about it.",
        "security": [
          {
            "personalKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "status"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "open",
                      "resolved"
                    ]
                  }
                }
              },
              "example": {
                "status": "resolved"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Status applied.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "status"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "open",
                        "resolved"
                      ]
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "status": "resolved"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/tickets/{code}/reply": {
      "parameters": [
        {
          "$ref": "#/components/parameters/TicketCode"
        }
      ],
      "post": {
        "tags": [
          "Management"
        ],
        "operationId": "replyToTicket",
        "summary": "Reply to the customer",
        "description": "Appends an agent message to the thread and emails the customer. The message is durable before the email is attempted: `email_sent: false` means the reply is recorded and the notification failed — do not retry the reply, or the customer gets it twice. Pass `resolve: true` to answer and close in one call.",
        "security": [
          {
            "personalKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "body"
                ],
                "properties": {
                  "body": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 10000,
                    "description": "Reply text, as the customer will read it."
                  },
                  "resolve": {
                    "type": "boolean",
                    "default": false,
                    "description": "Mark the ticket resolved as part of the same reply."
                  }
                }
              },
              "example": {
                "body": "Fixed in 2026.7.4 — can you retry?",
                "resolve": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Reply recorded.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "email_sent"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "email_sent": {
                      "type": "boolean",
                      "description": "Whether the notification email reached the provider. `false` does not mean the reply was lost."
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "email_sent": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/projects": {
      "get": {
        "tags": [
          "Management"
        ],
        "operationId": "listProjects",
        "summary": "List your projects",
        "description": "Every project you are a member of, with its current open-ticket count.",
        "security": [
          {
            "personalKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Your projects.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "projects"
                  ],
                  "properties": {
                    "projects": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Project"
                      }
                    }
                  }
                },
                "example": {
                  "projects": [
                    {
                      "id": "org_7d2",
                      "name": "Bouncyboobs",
                      "slug": "bouncyboobs",
                      "open_count": 3,
                      "created_at": "2026-04-18T01:22:10.000Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/projects/{id}/keys": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "get": {
        "tags": [
          "Management"
        ],
        "operationId": "listProjectKeys",
        "summary": "List a project's intake keys",
        "description": "Metadata only. The key material is stored as a SHA-256 hash and cannot be recovered — `start` (the first 12 characters) is all you get for identifying a key you already issued.",
        "security": [
          {
            "personalKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "The project's keys.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "keys"
                  ],
                  "properties": {
                    "keys": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ProjectKey"
                      }
                    }
                  }
                },
                "example": {
                  "keys": [
                    {
                      "id": "3b1f7e88-2c04-4a11-b0d9-7e2a5c31f004",
                      "name": "Default",
                      "start": "upk_Xh2Qa8vN",
                      "enabled": true,
                      "request_count": 412,
                      "last_request": "2026-07-28T09:12:44.000Z",
                      "created_at": "2026-04-18T01:22:11.000Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "Management"
        ],
        "operationId": "createProjectKey",
        "summary": "Mint a new intake key for a project",
        "description": "Returns the raw `upk_` key **once**. It is hashed on write and never retrievable again — store it in the calling app's secret env var before you close the response.",
        "security": [
          {
            "personalKey": []
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Label for the key. Defaults to \"Default\".",
                    "maxLength": 100,
                    "minLength": 1
                  }
                }
              },
              "example": {
                "name": "marketing-site"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Key created. This is the only time `key` is returned.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "id",
                    "name",
                    "start",
                    "key"
                  ],
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    },
                    "start": {
                      "type": "string"
                    },
                    "key": {
                      "type": "string",
                      "description": "The raw project key. Shown once, never again."
                    }
                  }
                },
                "example": {
                  "id": "9e0a4c31-77b5-4f0e-a1d2-6c8b93f0e215",
                  "name": "marketing-site",
                  "start": "upk_Xh2Qa8vN",
                  "key": "upk_Xh2Qa8vN3rLdT7pJmWcE1yZbQf5oR9kU"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/api/v1/keys/{id}": {
      "delete": {
        "tags": [
          "Management"
        ],
        "operationId": "revokeKey",
        "summary": "Revoke a key",
        "description": "Soft revoke: the row survives for audit, `enabled` flips to false and the key stops authenticating immediately. Works on project keys belonging to a project you are a member of, and on your own personal keys.",
        "security": [
          {
            "personalKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Key id (not the key itself).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Key revoked.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    }
                  }
                },
                "example": {
                  "ok": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "projectKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Project key, `upk_…`. Scoped to one project, submit-only: it works on `/api/v1/intake`, `/api/v1/upload` and `/api/v1/whoami`, and nowhere else. Mint one in Settings → Projects. It is a server-side secret — putting it in a browser bundle lets anyone file tickets as your app."
      },
      "personalKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Personal key, `usk_…`. Acts as you across every project you are a member of, for the management API. Mint one at https://uniport.sh/settings/tokens. It is rejected by intake."
      }
    },
    "parameters": {
      "TicketCode": {
        "name": "code",
        "in": "path",
        "required": true,
        "description": "Ticket short code, e.g. `BOUNCY-A3F291E7`. Case-insensitive.",
        "schema": {
          "type": "string"
        },
        "example": "BOUNCY-A3F291E7"
      },
      "ProjectId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Project (organization) id, as returned by `/api/v1/projects`.",
        "schema": {
          "type": "string"
        },
        "example": "org_7d2"
      }
    },
    "headers": {
      "RateLimitLimit": {
        "description": "Requests allowed in the current window.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitRemaining": {
        "description": "Requests left in the current window.",
        "schema": {
          "type": "integer"
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying.",
        "schema": {
          "type": "integer"
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "`missing_authorization` (no `Authorization: Bearer …` header) or `invalid_key` (wrong key type, revoked, or expired).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "missing": {
                "value": {
                  "error": {
                    "code": "missing_authorization",
                    "message": "Provide an Authorization: Bearer <project_key> header."
                  }
                }
              },
              "invalid": {
                "value": {
                  "error": {
                    "code": "invalid_key",
                    "message": "Project API key is invalid, disabled, or expired."
                  }
                }
              }
            }
          }
        }
      },
      "InvalidRequest": {
        "description": "`invalid_request` — the body or query failed validation, or was not parseable JSON. `details` carries the field errors when there are any. (Intake and upload report an unparseable body as `invalid_json`; the management API folds both cases into `invalid_request`.)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "invalid_request",
                "message": "Request body failed validation."
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "`not_found` — no such resource, or it belongs to a project you are not a member of. The two are deliberately indistinguishable.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "not_found"
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "`rate_limited` — too many requests. Wait `retry_after_seconds` (also in the `Retry-After` header) and retry. Windows are fixed, so the wait is bounded by the window length.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          },
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimitLimit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "rate_limited",
                "message": "Too many submissions for this project key."
              },
              "retry_after_seconds": 47
            }
          }
        }
      },
      "InternalError": {
        "description": "`internal_error` — something broke on our side. Nothing was written; retrying is safe.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "internal_error",
                "message": "Something went wrong."
              }
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "title": "Error",
        "description": "The one error envelope every endpoint uses. `error.code` is the stable contract; `error.message` is prose for humans.",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Machine-readable error code. Safe to switch on.",
                "examples": [
                  "missing_authorization",
                  "invalid_key",
                  "rate_limited",
                  "invalid_json",
                  "invalid_request",
                  "flagged_as_spam",
                  "project_not_found",
                  "not_found",
                  "unsupported_content_type",
                  "file_too_large",
                  "upload_not_configured",
                  "upload_failed",
                  "internal_error"
                ]
              },
              "message": {
                "type": "string",
                "description": "Human-readable explanation. May be reworded without notice."
              },
              "details": {
                "description": "Field-level validation errors, when the code is `invalid_request`."
              }
            }
          },
          "retry_after_seconds": {
            "type": "integer",
            "description": "Present on 429 responses, mirroring the `Retry-After` header for clients that read the body."
          }
        }
      },
      "IntakeRequest": {
        "type": "object",
        "title": "IntakeRequest",
        "required": [
          "email",
          "message"
        ],
        "additionalProperties": false,
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 320,
            "description": "Customer email. Lowercased and used to dedupe customers within the project."
          },
          "message": {
            "type": "string",
            "minLength": 1,
            "maxLength": 10000,
            "description": "The ticket body."
          },
          "name": {
            "type": "string",
            "maxLength": 200,
            "description": "Customer name. An empty string is accepted and stored as null — forms post blanks and a bug report should not be lost over one."
          },
          "subject": {
            "type": "string",
            "maxLength": 300,
            "description": "Ticket subject. Optional; blanks are normalised to null."
          },
          "attachments": {
            "type": "array",
            "maxItems": 10,
            "description": "Files already uploaded via `/api/v1/upload`. Field names here are camelCase, matching the upload SDK.",
            "items": {
              "type": "object",
              "required": [
                "url",
                "filename"
              ],
              "properties": {
                "url": {
                  "type": "string",
                  "format": "uri",
                  "maxLength": 2048,
                  "description": "Must be an `https://*.public.blob.vercel-storage.com` URL from `/api/v1/upload`. Anything else is rejected."
                },
                "filename": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 300
                },
                "contentType": {
                  "type": "string",
                  "maxLength": 120
                },
                "sizeBytes": {
                  "type": "integer",
                  "minimum": 0
                }
              }
            }
          },
          "source": {
            "type": "object",
            "additionalProperties": true,
            "description": "Free-form context stored with the ticket: page, plan, app version, user id — whatever helps you answer. Must serialize to under 4096 bytes."
          }
        }
      },
      "IntakeResponse": {
        "type": "object",
        "title": "IntakeResponse",
        "required": [
          "ok",
          "ticket_id",
          "short_code",
          "status_url"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "ticket_id": {
            "type": "string",
            "format": "uuid",
            "description": "Internal ticket id."
          },
          "short_code": {
            "type": "string",
            "description": "Human-quotable code, e.g. `BOUNCY-A3F291E7`. Show it in your success message.",
            "examples": [
              "BOUNCY-A3F291E7"
            ]
          },
          "status_url": {
            "type": "string",
            "format": "uri",
            "description": "Public thread page including its capability token. Anyone with this link can read and reply to the ticket, so send it to the customer and nobody else."
          }
        }
      },
      "UploadRequest": {
        "title": "UploadRequest",
        "description": "The `@vercel/blob` client-upload protocol body.",
        "oneOf": [
          {
            "type": "object",
            "title": "GenerateClientToken",
            "required": [
              "type",
              "payload"
            ],
            "properties": {
              "type": {
                "type": "string",
                "const": "blob.generate-client-token"
              },
              "payload": {
                "type": "object",
                "required": [
                  "pathname"
                ],
                "properties": {
                  "pathname": {
                    "type": "string",
                    "description": "Destination path including extension; a random suffix is appended server-side."
                  },
                  "multipart": {
                    "type": "boolean",
                    "description": "Whether the client will use a multipart upload."
                  },
                  "clientPayload": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          },
          {
            "type": "object",
            "title": "UploadCompleted",
            "description": "Sent by Vercel Blob, not by you.",
            "required": [
              "type",
              "payload"
            ],
            "properties": {
              "type": {
                "type": "string",
                "const": "blob.upload-completed"
              },
              "payload": {
                "type": "object",
                "properties": {
                  "blob": {
                    "type": "object",
                    "additionalProperties": true
                  },
                  "tokenPayload": {
                    "type": [
                      "string",
                      "null"
                    ]
                  }
                }
              }
            }
          }
        ]
      },
      "UploadResponse": {
        "title": "UploadResponse",
        "oneOf": [
          {
            "type": "object",
            "title": "ClientToken",
            "required": [
              "type",
              "clientToken"
            ],
            "properties": {
              "type": {
                "type": "string",
                "const": "blob.generate-client-token"
              },
              "clientToken": {
                "type": "string",
                "description": "Short-lived token. Pass it to `put(pathname, file, { access: 'public', token })` from `@vercel/blob/client`."
              }
            }
          },
          {
            "type": "object",
            "title": "UploadCompletedAck",
            "required": [
              "type",
              "response"
            ],
            "properties": {
              "type": {
                "type": "string",
                "const": "blob.upload-completed"
              },
              "response": {
                "type": "string",
                "const": "ok"
              }
            }
          }
        ]
      },
      "TicketSummary": {
        "type": "object",
        "title": "TicketSummary",
        "required": [
          "short_code",
          "subject",
          "status",
          "customer_name",
          "customer_email",
          "project_slug",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "short_code": {
            "type": "string",
            "examples": [
              "BOUNCY-A3F291E7"
            ]
          },
          "subject": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "resolved"
            ]
          },
          "customer_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "customer_email": {
            "type": "string",
            "format": "email"
          },
          "project_slug": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "TicketDetail": {
        "type": "object",
        "title": "TicketDetail",
        "required": [
          "short_code",
          "subject",
          "status",
          "created_at",
          "updated_at",
          "resolved_at",
          "project_slug",
          "customer",
          "messages"
        ],
        "properties": {
          "short_code": {
            "type": "string"
          },
          "subject": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "resolved"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "resolved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "project_slug": {
            "type": "string"
          },
          "customer": {
            "type": "object",
            "required": [
              "name",
              "email"
            ],
            "properties": {
              "name": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "email": {
                "type": "string",
                "format": "email"
              }
            }
          },
          "messages": {
            "type": "array",
            "description": "Oldest first.",
            "items": {
              "$ref": "#/components/schemas/Message"
            }
          }
        }
      },
      "Message": {
        "type": "object",
        "title": "Message",
        "required": [
          "author",
          "author_name",
          "body",
          "created_at",
          "attachments"
        ],
        "properties": {
          "author": {
            "type": "string",
            "enum": [
              "customer",
              "agent"
            ],
            "description": "Who wrote it. `customer` includes replies posted from the public thread page."
          },
          "author_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "body": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "attachments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Attachment"
            }
          }
        }
      },
      "Attachment": {
        "type": "object",
        "title": "Attachment",
        "required": [
          "filename",
          "content_type",
          "size_bytes",
          "url"
        ],
        "properties": {
          "filename": {
            "type": "string"
          },
          "content_type": {
            "type": [
              "string",
              "null"
            ]
          },
          "size_bytes": {
            "type": [
              "integer",
              "null"
            ]
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Vercel Blob URL. Public and unguessable; it is not covered by backups."
          }
        }
      },
      "Project": {
        "type": "object",
        "title": "Project",
        "required": [
          "id",
          "name",
          "slug",
          "open_count",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "description": "Also the prefix of every ticket short code in this project."
          },
          "open_count": {
            "type": "integer",
            "minimum": 0
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ProjectKey": {
        "type": "object",
        "title": "ProjectKey",
        "description": "Key metadata. The key material itself is never returned after minting.",
        "required": [
          "id",
          "name",
          "start",
          "enabled",
          "request_count",
          "last_request",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "start": {
            "type": "string",
            "description": "First 12 characters of the key, for identification.",
            "examples": [
              "upk_Xh2Qa8vN"
            ]
          },
          "enabled": {
            "type": "boolean",
            "description": "False once revoked. Revoked keys are kept for audit."
          },
          "request_count": {
            "type": "integer",
            "minimum": 0
          },
          "last_request": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WhoAmI": {
        "type": "object",
        "title": "WhoAmI",
        "required": [
          "key_type",
          "key_id",
          "organization",
          "user",
          "rate_limit"
        ],
        "properties": {
          "key_type": {
            "type": "string",
            "enum": [
              "project",
              "personal"
            ]
          },
          "key_id": {
            "type": "string"
          },
          "organization": {
            "type": [
              "object",
              "null"
            ],
            "description": "Set for project keys, null for personal keys.",
            "required": [
              "id",
              "slug",
              "name"
            ],
            "properties": {
              "id": {
                "type": "string"
              },
              "slug": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "user": {
            "type": [
              "object",
              "null"
            ],
            "description": "Set for personal keys, null for project keys.",
            "required": [
              "id",
              "email"
            ],
            "properties": {
              "id": {
                "type": "string"
              },
              "email": {
                "type": "string",
                "format": "email"
              }
            }
          },
          "rate_limit": {
            "type": "object",
            "required": [
              "max",
              "window_ms"
            ],
            "properties": {
              "max": {
                "type": "integer"
              },
              "window_ms": {
                "type": "integer"
              }
            }
          }
        }
      }
    }
  }
}
