{"openapi":"3.1.0","jsonSchemaDialect":"https://json-schema.org/draft/2020-12/schema","info":{"title":"Ticket-It Partner API","version":"1.1.0","description":"File and inspect support tickets on behalf of users authenticated by a trusted Partner. The Partner Key determines both the Partner and its single Organization; request bodies never select a tenant.\n\nInteractive docs: `/partner-api`. Machine-readable contract: `GET /api/partner/openapi`."},"servers":[{"url":"/","description":"Current Ticket-It deployment"}],"tags":[{"name":"Partner tickets","description":"Create and read tickets filed by the authenticated Partner."},{"name":"Portal handoff","description":"Create a short-lived, single-use customer portal handoff."}],"security":[{"PartnerBearerAuth":[]}],"paths":{"/api/partner/tickets":{"post":{"tags":["Partner tickets"],"summary":"File a ticket","description":"Creates a ticket by default. With thread=true, appends the message to this customer's active API ticket for this Partner. Replaying an externalId returns the existing ticket with HTTP 200.","operationId":"createPartnerTicket","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTicketRequest"},"examples":{"idempotentFiling":{"summary":"File a new idempotent request","value":{"email":"bob@corp.example","name":"Bob Smith","subject":"Invoice is wrong","message":"I was charged twice for my March invoice.","priority":"HIGH","externalId":"case-9182"}},"threadedMessage":{"summary":"Append to the active Partner thread","value":{"email":"bob@corp.example","message":"The duplicate charge is still showing.","thread":true}}}}}},"responses":{"200":{"description":"An externalId replay returned its existing ticket, or a threaded message was appended to an existing ticket.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTicketResponse"}}}},"201":{"description":"A new ticket was created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTicketResponse"}}}},"400":{"$ref":"#/components/responses/TicketRequestError"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalServerError"},"503":{"description":"A threaded message committed, but the ticket could not be read back. Check the ticket list before retrying the message, because repeating a threaded POST can append it twice.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["Partner tickets"],"summary":"List tickets filed by this Partner","description":"Returns only tickets filed by the authenticated Partner in its Organization. Results are ordered by updatedAt and then id, newest first.","operationId":"listPartnerTickets","parameters":[{"$ref":"#/components/parameters/CustomerEmail"},{"$ref":"#/components/parameters/ExternalId"},{"$ref":"#/components/parameters/TicketStatus"},{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/Limit"}],"responses":{"200":{"description":"A page of Partner-scoped tickets.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListTicketsResponse"}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/api/partner/tickets/{ticketId}":{"get":{"tags":["Partner tickets"],"summary":"Read one Partner ticket","description":"Returns one ticket filed by the authenticated Partner, including public messages with author attribution. Internal agent notes are never returned. Optional email query scopes the read to that customer; a mismatch is 404.","operationId":"getPartnerTicket","parameters":[{"name":"ticketId","in":"path","required":true,"description":"Ticket MongoDB ObjectId.","schema":{"type":"string","pattern":"^[0-9a-fA-F]{24}$","example":"667f1b2c9a1e4d3f2a1b2c3d"}},{"name":"email","in":"query","required":false,"description":"When supplied, the ticket must belong to this customer email. Mismatch returns 404 (not 403), so the response does not confirm that another customer's ticket exists. Surrounding whitespace is trimmed and the value is lowercased.","schema":{"type":"string","pattern":"^\\s*(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+\\-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}\\s*$","x-normalization":"trim+lowercase"}}],"responses":{"200":{"description":"The Partner-scoped ticket and its public messages.","content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["ticket"],"properties":{"ticket":{"$ref":"#/components/schemas/TicketWithMessages"}}}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/api/partner/tickets/{ticketId}/messages":{"post":{"tags":["Partner tickets"],"summary":"Reply on a specific Partner ticket","description":"Appends a customer message to the ticket identified by ticketId. The ticket must have been filed by this Partner. Optional externalId makes the reply idempotent: replaying the same key returns the existing ticket with HTTP 200 and does not append a second message. Optional email scopes the write to that customer (mismatch is 404). Unlike POST /api/partner/tickets with thread=true, this path never picks a different active ticket.","operationId":"replyPartnerTicket","parameters":[{"name":"ticketId","in":"path","required":true,"description":"Ticket MongoDB ObjectId.","schema":{"type":"string","pattern":"^[0-9a-fA-F]{24}$","example":"667f1b2c9a1e4d3f2a1b2c3d"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyTicketRequest"},"examples":{"simpleReply":{"summary":"Append a customer reply","value":{"message":"The duplicate charge is still showing."}},"idempotentReply":{"summary":"Idempotent reply","value":{"message":"The duplicate charge is still showing.","externalId":"reply-case-9182-2"}}}}}},"responses":{"200":{"description":"An externalId replay returned the existing ticket without appending again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyTicketResponse"}}}},"201":{"description":"The message was appended.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyTicketResponse"}}}},"400":{"$ref":"#/components/responses/TicketRequestError"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}},"/api/partner/portal-link":{"post":{"tags":["Portal handoff"],"summary":"Create a customer portal handoff","description":"Mints a fresh single-use, 15-minute Portal Handoff for a vouched, eligible intake-created customer account. Every unknown, unvouched, or ineligible email returns the same 404.","operationId":"createPartnerPortalLink","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalLinkRequest"},"example":{"email":"bob@corp.example"}}}},"responses":{"200":{"description":"A fresh Portal Handoff was minted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalLinkResponse"}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalServerError"}}}}},"components":{"securitySchemes":{"PartnerBearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"PartnerKey","description":"Partner Key in the form `pk_...`. The key resolves one Partner in one Organization."}},"parameters":{"CustomerEmail":{"name":"email","in":"query","description":"Filter by customer email. Surrounding whitespace is trimmed and the value is lowercased.","schema":{"type":"string","pattern":"^\\s*(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+\\-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}\\s*$","x-normalization":"trim+lowercase"}},"ExternalId":{"name":"externalId","in":"query","description":"Filter by this Partner's external request identifier. The value is trimmed before validation.","schema":{"type":"string","pattern":"^\\s*(?:\\S|\\S[\\s\\S]{0,198}\\S)\\s*$","x-normalization":"trim","x-trimmed-minLength":1,"x-trimmed-maxLength":200}},"TicketStatus":{"name":"status","in":"query","description":"Filter by ticket status.","schema":{"$ref":"#/components/schemas/TicketStatus"}},"Page":{"name":"page","in":"query","description":"One-based page number.","schema":{"type":"integer","minimum":1,"default":1}},"Limit":{"name":"limit","in":"query","description":"Maximum number of tickets returned.","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}},"responses":{"TicketRequestError":{"description":"The request body was not valid JSON, or the parsed body failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"malformedJson":{"value":{"error":"Request body must be valid JSON"}},"validationError":{"value":{"error":"Validation error — email: Invalid email address"}}}}}},"ValidationError":{"description":"The parsed JSON body or query parameters failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Validation error — email: Invalid email address"}}}},"Unauthorized":{"description":"The Partner Key is missing or invalid, revoked, belongs to a disabled Partner, or resolves to an inactive Organization. All cases have the same response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid partner credentials"}}}},"NotFound":{"description":"The resource does not exist or is not visible to this Partner. Portal-link eligibility refusals also use this response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Not found"}}}},"InternalServerError":{"description":"An unexpected server error occurred.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal server error"}}}}},"schemas":{"ErrorResponse":{"type":"object","additionalProperties":false,"required":["error"],"properties":{"error":{"type":"string"}}},"TicketStatus":{"type":"string","enum":["NEW","OPEN","IN_PROGRESS","PENDING","ON_HOLD","ESCALATED","RESOLVED","CLOSED"]},"TicketPriority":{"type":"string","enum":["LOW","MEDIUM","HIGH","URGENT"]},"Ticket":{"type":"object","additionalProperties":false,"required":["id","number","status","priority","subject","externalId","createdAt","updatedAt"],"properties":{"id":{"type":"string","pattern":"^[0-9a-fA-F]{24}$","example":"667f1b2c9a1e4d3f2a1b2c3d"},"number":{"type":"string","example":"TKT-2026-0341"},"status":{"$ref":"#/components/schemas/TicketStatus"},"priority":{"$ref":"#/components/schemas/TicketPriority"},"subject":{"type":"string","example":"Invoice is wrong"},"externalId":{"type":["string","null"],"example":"case-9182"},"createdAt":{"type":"string","format":"date-time","example":"2026-07-28T10:15:00.000Z"},"updatedAt":{"type":"string","format":"date-time","example":"2026-07-28T10:15:00.000Z"}}},"MessageAuthorType":{"type":"string","enum":["CUSTOMER","AGENT","SYSTEM"],"description":"Which side of the conversation wrote the message. type alone is enough to render a two-sided conversation UI."},"MessageAuthor":{"type":"object","additionalProperties":false,"required":["type","name"],"properties":{"type":{"$ref":"#/components/schemas/MessageAuthorType"},"name":{"type":["string","null"],"description":"Display name when available. May be null (e.g. agent names withheld by policy, or missing on the account).","example":"Priya R"}}},"TicketMessage":{"type":"object","additionalProperties":false,"required":["id","content","createdAt","author"],"properties":{"id":{"type":"string","pattern":"^[0-9a-fA-F]{24}$","example":"667f1b2c9a1e4d3f2a1b2c3e"},"content":{"type":"string","example":"I was charged twice for my March invoice."},"createdAt":{"type":"string","format":"date-time","example":"2026-07-28T10:15:00.000Z"},"author":{"$ref":"#/components/schemas/MessageAuthor"}}},"ReplyTicketRequest":{"type":"object","description":"Unknown fields are ignored. externalId is optional and makes the reply idempotent (stored on the Message, namespaced per Partner). email is optional ownership scope (same 404 semantics as GET ?email=).","required":["message"],"properties":{"message":{"type":"string","pattern":"^\\s*(?:\\S|\\S[\\s\\S]{0,19998}\\S)\\s*$","x-normalization":"trim","x-trimmed-minLength":1,"x-trimmed-maxLength":20000,"description":"Trimmed before length validation."},"externalId":{"type":"string","pattern":"^\\s*(?:\\S|\\S[\\s\\S]{0,198}\\S)\\s*$","x-normalization":"trim","x-trimmed-minLength":1,"x-trimmed-maxLength":200,"description":"Partner-scoped idempotency identifier for this reply request. Trimmed before length validation. Replaying returns the existing ticket with HTTP 200 and does not append a second message."},"email":{"type":"string","pattern":"^\\s*(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+\\-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}\\s*$","x-normalization":"trim+lowercase","description":"Optional ownership scope. When supplied, the ticket must belong to this customer email; mismatch returns 404 (not 403). Trimmed and lowercased before use."}}},"ReplyTicketResponse":{"type":"object","additionalProperties":false,"required":["ticket","messageId","isNew"],"properties":{"ticket":{"$ref":"#/components/schemas/Ticket"},"messageId":{"type":"string","pattern":"^[0-9a-fA-F]{24}$","description":"The Message id that was created, or the original Message id on an externalId replay.","example":"667f1b2c9a1e4d3f2a1b2c3e"},"isNew":{"type":"boolean","description":"True only when this request created a brand-new message. False on externalId replay."}}},"TicketWithMessages":{"type":"object","additionalProperties":false,"required":["id","number","status","priority","subject","externalId","createdAt","updatedAt","messages"],"properties":{"id":{"type":"string","pattern":"^[0-9a-fA-F]{24}$","example":"667f1b2c9a1e4d3f2a1b2c3d"},"number":{"type":"string","example":"TKT-2026-0341"},"status":{"$ref":"#/components/schemas/TicketStatus"},"priority":{"$ref":"#/components/schemas/TicketPriority"},"subject":{"type":"string","example":"Invoice is wrong"},"externalId":{"type":["string","null"],"example":"case-9182"},"createdAt":{"type":"string","format":"date-time","example":"2026-07-28T10:15:00.000Z"},"updatedAt":{"type":"string","format":"date-time","example":"2026-07-28T10:15:00.000Z"},"messages":{"type":"array","items":{"$ref":"#/components/schemas/TicketMessage"}}}},"CreateTicketRequest":{"type":"object","description":"Unknown fields are ignored. externalId cannot be combined with thread=true.","required":["email","message"],"properties":{"email":{"type":"string","pattern":"^\\s*(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+\\-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}\\s*$","x-normalization":"trim+lowercase","description":"Trimmed and lowercased before use."},"name":{"type":"string","pattern":"^\\s*(?:\\S|\\S[\\s\\S]{0,198}\\S)\\s*$","x-normalization":"trim","x-trimmed-minLength":1,"x-trimmed-maxLength":200,"description":"Trimmed before length validation."},"subject":{"type":"string","pattern":"^\\s*(?:\\S|\\S[\\s\\S]{0,298}\\S)\\s*$","x-normalization":"trim","x-trimmed-minLength":1,"x-trimmed-maxLength":300,"description":"Trimmed before length validation."},"message":{"type":"string","pattern":"^\\s*(?:\\S|\\S[\\s\\S]{0,19998}\\S)\\s*$","x-normalization":"trim","x-trimmed-minLength":1,"x-trimmed-maxLength":20000,"description":"Trimmed before length validation."},"priority":{"allOf":[{"$ref":"#/components/schemas/TicketPriority"}],"default":"MEDIUM"},"tags":{"type":"array","maxItems":20,"items":{"type":"string","pattern":"^\\s*(?:\\S|\\S[\\s\\S]{0,48}\\S)\\s*$","x-normalization":"trim","x-trimmed-minLength":1,"x-trimmed-maxLength":50,"description":"Trimmed before length validation."}},"category":{"type":"string","pattern":"^\\s*(?:\\S|\\S[\\s\\S]{0,98}\\S)\\s*$","x-normalization":"trim","x-trimmed-minLength":1,"x-trimmed-maxLength":100,"description":"Trimmed before length validation."},"externalId":{"type":"string","pattern":"^\\s*(?:\\S|\\S[\\s\\S]{0,198}\\S)\\s*$","x-normalization":"trim","x-trimmed-minLength":1,"x-trimmed-maxLength":200,"description":"Partner-scoped idempotency identifier for one filing request. Trimmed before length validation."},"thread":{"type":"boolean","default":false,"description":"Append to this customer's active API ticket for this Partner instead of always creating a new ticket."}},"not":{"description":"externalId and thread=true are mutually exclusive.","required":["externalId","thread"],"properties":{"thread":{"const":true}}}},"CreateTicketResponse":{"type":"object","additionalProperties":false,"required":["ticket","portalUrl","isNew"],"properties":{"ticket":{"$ref":"#/components/schemas/Ticket"},"portalUrl":{"type":["string","null"],"description":"Single-use 15-minute Portal Handoff URL, or null when no handoff could be minted. Ticket creation has already succeeded in either case.","example":"/auth/handoff?token=8f3c...&slug=acme"},"isNew":{"type":"boolean","description":"True only when this request created a brand-new ticket."}}},"ListTicketsResponse":{"type":"object","additionalProperties":false,"required":["tickets","metadata"],"properties":{"tickets":{"type":"array","items":{"$ref":"#/components/schemas/Ticket"}},"metadata":{"type":"object","additionalProperties":false,"required":["page","limit","total","hasMore"],"properties":{"page":{"type":"integer","minimum":1},"limit":{"type":"integer","minimum":1,"maximum":100},"total":{"type":"integer","minimum":0},"hasMore":{"type":"boolean"}}}}},"PortalLinkRequest":{"type":"object","required":["email"],"properties":{"email":{"type":"string","pattern":"^\\s*(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+\\-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}\\s*$","x-normalization":"trim+lowercase","description":"Trimmed and lowercased before lookup."}}},"PortalLinkResponse":{"type":"object","additionalProperties":false,"required":["portalUrl","expiresAt"],"properties":{"portalUrl":{"type":"string","example":"/auth/handoff?token=1a2b...&slug=acme"},"expiresAt":{"type":"string","format":"date-time","example":"2026-07-28T10:30:00.000Z"}}}}}}