# Opami public API, version 1 (TS-291 §19.1, §19.3, §19.4, §21; T-610).
#
# The single description of everything a custom app touches: the endpoints it calls on Opami (`paths`) and the
# endpoints it must implement for Opami to call (`webhooks`: the calendar operations of §19.3; the action endpoint
# rules of §19.2/§19.4 are in `x-opami-action-endpoints`). docs.opami.ai is generated from this file
# (web/docs-site), and tests/custom-apps/public-openapi.test.ts fails the build when the routes, schemas, limits or
# error codes differ from it. Any change here needs a dated entry in spec/openapi/CHANGELOG.md
# (scripts/public-api-gate.mjs); a breaking change needs a new major version and 90 days notice.
openapi: 3.1.0
info:
  title: Opami public API
  version: 1.0.0
  summary: Custom inbox sources, custom calendars and custom action apps.
  description: |
    Everything a custom app needs: the endpoints it calls on Opami and the endpoints it implements for Opami to
    call. Readable without an Opami account. A member creates the custom app and its API key in Opami's settings;
    the key is shown once.
  contact:
    name: Opami
    url: https://docs.opami.ai
servers:
  - url: https://app.opami.ai
    description: Production
tags:
  - name: Inbox source
    description: Send messages and files into the member's review inbox.
  - name: Calendar
    description: Push calendar changes to Opami.
security:
  - apiKey: []
paths:
  /v1/sources/{id}/messages:
    post:
      operationId: sendMessage
      tags: [Inbox source]
      summary: Send one message
      description: |
        One message, in the documented shape or in any JSON shape the source's confirmed field mapping can read.
        A payload in the documented shape is always taken as it is. The message enters the same pipeline as a
        Gmail message (filters, grouping, summary, learning).
      x-opami-idempotency: >-
        Idempotent by `external_id` per source. Sending the same `external_id` again answers 200 with the first
        message's id and `duplicate: true`, and writes nothing. Safe to retry after a timeout or a 503.
      x-opami-max-body-bytes: 262144
      x-opami-rate-limits: [source:ip, source:key]
      parameters:
        - $ref: "#/components/parameters/SourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SourceMessage"
            examples:
              documented:
                value:
                  external_id: A-1001
                  conversation_id: T-77
                  sent_at: "2026-10-27T09:00:00Z"
                  sender: { kind: email, value: ana@example.com, display: Ana Silva }
                  subject: Invoice 2026-114
                  text: Hi, please find the October invoice attached.
                  uploads: ["8d0f5b1e-3c1a-4c8e-9b7a-2f4e1d6c9a01"]
      responses:
        "202":
          description: Accepted.
          headers: { Cache-Control: { $ref: "#/components/headers/NoStore" } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MessageAccepted" }
        "200":
          description: This source already sent this `external_id`. Nothing was written.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MessageDuplicate" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "422":
          description: >-
            The body is not JSON (`invalid_json`), or it is neither in the documented shape nor readable by the
            confirmed mapping (`unmappable`). An unknown or already-used upload id is
            `{ field: "uploads", code: "invalid" }`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MappingError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /v1/sources/{id}/messages/{message_id}:
    get:
      operationId: getMessageStatus
      tags: [Inbox source]
      summary: Read a message's processing status
      x-opami-idempotency: A read; safe to repeat.
      x-opami-max-body-bytes: 0
      x-opami-rate-limits: [source:ip, source:key]
      parameters:
        - $ref: "#/components/parameters/SourceId"
        - name: message_id
          in: path
          required: true
          description: The `message_id` answered when the message was sent.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The message's status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MessageStatus" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404":
          description: This source sent no message with this id.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - properties: { code: { const: not_found } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /v1/sources/{id}/uploads:
    post:
      operationId: createUpload
      tags: [Inbox source]
      summary: Announce a file
      description: |
        Answers a signed upload URL. `PUT` the file's bytes to `upload_url`, then name `upload_id` in one message's
        `uploads`. An upload attaches to one message only.
      x-opami-idempotency: >-
        Not idempotent: every call creates a new upload. An upload never attached to a message is never shown.
      x-opami-max-body-bytes: 4096
      x-opami-rate-limits: [source:ip, source:key]
      parameters:
        - $ref: "#/components/parameters/SourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UploadRequest" }
            examples:
              pdf:
                value: { name: invoice-2026-114.pdf, mime: application/pdf, bytes: 48213 }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadCreated" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "422":
          description: Not a valid upload request (`invalid`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MappingError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /v1/sources/{id}/calendar-events:
    post:
      operationId: pushCalendarEvents
      tags: [Calendar]
      summary: Push changed calendar events
      description: |
        A push is a signal: the events are checked against the event format and the app's calendar is marked
        changed, so Opami reads it again rather than trusting an older copy. Same API key as the inbox source; the
        app must offer Calendar. Apps that cannot push are polled through `events.list` instead.
      x-opami-idempotency: Idempotent. Pushing the same events again only marks the calendar changed again.
      x-opami-max-body-bytes: 1048576
      x-opami-rate-limits: [source:ip, source:key]
      parameters:
        - $ref: "#/components/parameters/SourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CalendarPush" }
      responses:
        "202":
          description: Accepted.
          content:
            application/json:
              schema:
                type: object
                required: [accepted]
                properties: { accepted: { type: integer, minimum: 1, maximum: 500 } }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "409":
          description: The calendar has not passed its conformance test (`calendar_not_usable`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "422":
          description: Not JSON (`invalid_json`) or not in the event format (`invalid_events`), with field paths.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventsError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Unavailable" }

# The calendar endpoints a custom app implements (§19.3). The member enters a method and an HTTPS URL template on
# the app's registered host for each; `x-opami-placeholders` are the placeholders the URL must contain, each once,
# filled URL-encoded. Opami calls them through the locked-down executor with the app's credential.
webhooks:
  calendars.list:
    get:
      operationId: calendarsList
      summary: List calendars
      x-opami-need: read
      x-opami-placeholders: []
      x-opami-methods: [GET]
      responses:
        "200":
          description: The app's calendars.
          content:
            application/json:
              schema:
                type: object
                required: [calendars]
                properties:
                  calendars: { type: array, maxItems: 500, items: { $ref: "#/components/schemas/Calendar" } }
  freebusy.query:
    post:
      operationId: freebusyQuery
      summary: Busy intervals
      x-opami-need: read
      x-opami-placeholders: []
      x-opami-methods: [POST]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [calendar_ids, from, to]
              properties:
                calendar_ids: { type: array, items: { type: string } }
                from: { type: string, format: date-time }
                to: { type: string, format: date-time }
      responses:
        "200":
          description: Busy intervals only, per calendar asked about.
          content:
            application/json:
              schema:
                type: object
                required: [calendars]
                properties:
                  calendars:
                    type: object
                    additionalProperties:
                      type: array
                      maxItems: 5000
                      items:
                        type: object
                        required: [start, end]
                        properties:
                          start: { type: string, format: date-time }
                          end: { type: string, format: date-time }
  events.list:
    get:
      operationId: eventsList
      summary: Events in a window
      x-opami-need: read
      x-opami-placeholders: [calendar_id, from, to]
      x-opami-methods: [GET]
      responses:
        "200":
          description: Events of `{calendar_id}` between `{from}` and `{to}`.
          content:
            application/json:
              schema:
                type: object
                required: [events]
                properties:
                  events: { type: array, maxItems: 2500, items: { $ref: "#/components/schemas/Event" } }
  events.get:
    get:
      operationId: eventsGet
      summary: One event
      x-opami-need: read
      x-opami-placeholders: [calendar_id, event_id]
      x-opami-methods: [GET]
      responses:
        "200": { $ref: "#/components/responses/EventAnswer" }
        "404": { description: No such event. Required, not a made-up answer. }
  events.create:
    post:
      operationId: eventsCreate
      summary: Create an event
      x-opami-need: write
      x-opami-placeholders: [calendar_id]
      x-opami-methods: [POST, PUT]
      requestBody: { $ref: "#/components/requestBodies/EventWrite" }
      responses:
        "200": { $ref: "#/components/responses/EventAnswer" }
  events.update:
    patch:
      operationId: eventsUpdate
      summary: Change an event
      description: Only the fields sent change.
      x-opami-need: write
      x-opami-placeholders: [calendar_id, event_id]
      x-opami-methods: [PATCH, PUT, POST]
      requestBody: { $ref: "#/components/requestBodies/EventWrite" }
      responses:
        "200": { $ref: "#/components/responses/EventAnswer" }
  events.cancel:
    post:
      operationId: eventsCancel
      summary: Cancel an event
      description: The body is `{}`. Afterwards `events.get` answers the event with `status` cancelled, or 404/410.
      x-opami-need: write
      x-opami-placeholders: [calendar_id, event_id]
      x-opami-methods: [POST, DELETE, PATCH]
      responses:
        "200": { $ref: "#/components/responses/EventAnswer" }
  events.respond:
    post:
      operationId: eventsRespond
      summary: Answer an invitation (optional)
      x-opami-need: optional
      x-opami-placeholders: [calendar_id, event_id]
      x-opami-methods: [POST, PUT, PATCH]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [response]
              properties: { response: { enum: [accepted, declined, tentative] } }
      responses:
        "200": { $ref: "#/components/responses/EventAnswer" }

# How Opami reads an answer from a calendar endpoint (lib/custom-apps/calendar.ts statusError). Any other status
# outside 2xx is a permanent `invalid`; a 2xx body not in the format is `invalid` too.
x-opami-calendar-statuses:
  "401": reauth
  "403": reauth
  "404": not_found
  "409": precondition_failed
  "410": gone
  "412": precondition_failed
  "429": unavailable (retried)
  5xx: unavailable (retried)

# The rules for every endpoint Opami calls on a custom app: action operations (§19.2) and calendar endpoints (§19.3),
# enforced by the locked-down executor (§19.4, svc-custom-http). Limits are lib/custom-apps/store.ts CUSTOM_LIMITS.
x-opami-action-endpoints:
  operations: >-
    Imported from the app's own OpenAPI 3 document (JSON, at most 512 KiB and 100 operations, local
    #/components/schemas references only) or entered in a form: name, method, path, input and output schema,
    description and class. The class follows the method unless `x-opami-class` names one of read, record_write,
    outgoing_text or destructive. Writes are stored disabled; a read-only connection test must pass and every
    write shows a preview before it runs.
  transport: >-
    HTTPS on the base URL's registered host only, a public address (private networks and cloud metadata
    addresses are refused after DNS and after every redirect). Port 443.
  authentication: [api_key, bearer, basic]
  authentication_rules: >-
    The credential is stored in a vault, bound to the registered origin and added at call time: `api_key` as a
    header (default X-API-Key), `bearer` as `Authorization: Bearer`, `basic` as `Authorization: Basic`.
  limits:
    request_bytes: 262144
    response_bytes: 1048576
    duration_ms: 10000
    calls_per_min: 30
    concurrency: 2
  responses: >-
    An answer is untrusted data: parsed, never followed as an instruction, never logged. Return JSON.
  idempotency: >-
    Opami sends a write once per approved preview; a write that times out is not retried. Make writes safe to
    receive twice where you can (for example key them on the Opami request).

# Throttles on the endpoints above (lib/account/rate-limit.ts): [requests, window seconds].
x-opami-rate-limits:
  source:ip: { requests: 600, window_seconds: 60, per: client address }
  source:key: { requests: 120, window_seconds: 60, per: API key }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: "`Authorization: Bearer opk_…`: `opk_` plus 43 base64url characters. Never a session cookie."
  parameters:
    SourceId:
      name: id
      in: path
      required: true
      description: The custom app's id, shown with its API key.
      schema: { type: string, format: uuid }
  headers:
    NoStore:
      schema: { const: no-store }
  requestBodies:
    EventWrite:
      content:
        application/json:
          schema: { $ref: "#/components/schemas/EventWrite" }
  responses:
    EventAnswer:
      description: The event as it now is.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Event" }
    Unauthenticated:
      description: >-
        No key, a malformed, unknown or revoked key, a key of another app, a disabled app, an app without this
        capability, or an id that is not a uuid. All the same answer.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - properties: { code: { const: unauthenticated } }
    TooLarge:
      description: The body is over the endpoint's limit (`x-opami-max-body-bytes`).
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - properties: { code: { const: too_large } }
    RateLimited:
      description: Over the address or the key throttle. Wait `Retry-After` seconds.
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - properties: { code: { const: rate_limited } }
    Unavailable:
      description: Opami could not complete the request. Retry with backoff.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - properties: { code: { const: unavailable } }
  schemas:
    Error:
      type: object
      required: [error, code]
      properties:
        error: { type: string, description: An English sentence. }
        code:
          enum: [unauthenticated, too_large, rate_limited, unavailable, not_found, invalid_json, unmappable, invalid, invalid_events, calendar_not_usable]
    MappingError:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          required: [errors]
          properties:
            errors:
              type: array
              items:
                type: object
                required: [field, path, code]
                properties:
                  field: { type: string, description: The target field, such as `text` or `sender.value`. }
                  path: { type: [string, "null"], description: The path in your payload the mapping read it from. }
                  code: { enum: [missing, invalid, unmapped] }
    EventsError:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          required: [errors]
          properties:
            errors:
              type: array
              maxItems: 50
              items:
                type: object
                required: [path, code]
                properties:
                  path: { type: string }
                  code: { type: string }
    SourceMessage:
      type: object
      additionalProperties: false
      required: [external_id, sender, text]
      properties:
        external_id: { type: string, minLength: 1, maxLength: 512, description: Your id for the message; the idempotency key. }
        conversation_id: { type: string, minLength: 1, maxLength: 512, description: Groups messages into one thread. }
        sent_at: { type: string, format: date-time, description: With a time zone offset. Defaults to now. }
        sender:
          type: object
          description: Other keys in `sender` are ignored.
          required: [value]
          properties:
            kind: { enum: [email, phone, platform_user], default: platform_user }
            value: { type: string, minLength: 1, maxLength: 320 }
            display: { type: [string, "null"], maxLength: 320 }
        subject: { type: [string, "null"], maxLength: 998 }
        text: { type: string, minLength: 1 }
        html: { type: [string, "null"] }
        source_url: { type: [string, "null"], format: uri }
        uploads: { type: array, maxItems: 50, items: { type: string, format: uuid } }
    MessageAccepted:
      type: object
      required: [message_id, status]
      properties:
        message_id: { type: string, format: uuid }
        status: { const: received }
    MessageDuplicate:
      type: object
      required: [message_id, duplicate]
      properties:
        message_id: { type: string, format: uuid }
        duplicate: { const: true }
    MessageStatus:
      type: object
      required: [message_id, status]
      properties:
        message_id: { type: string, format: uuid }
        status:
          enum: [received, processing, done, failed]
          description: >-
            received: not picked up yet; processing: on its way to an item; done: on an item the member can see;
            failed: its job died or its item failed.
    UploadRequest:
      type: object
      additionalProperties: false
      required: [name, mime, bytes]
      properties:
        name: { type: string, minLength: 1, maxLength: 255 }
        mime: { type: string, maxLength: 255, pattern: "^[\\w.+-]+/[\\w.+-]+$" }
        bytes: { type: integer, minimum: 0, maximum: 26214400 }
    UploadCreated:
      type: object
      required: [upload_id, upload_url]
      properties:
        upload_id: { type: string, format: uuid }
        upload_url: { type: string, format: uri, description: A signed URL; `PUT` the file there. }
    Calendar:
      type: object
      required: [id, name]
      properties:
        id: { type: string, minLength: 1, maxLength: 1024 }
        name: { type: string, maxLength: 256 }
        primary: { type: boolean, default: false }
        read_only: { type: boolean, default: false }
        time_zone: { type: string, minLength: 1, maxLength: 64, default: UTC }
    Event:
      type: object
      description: Opami's event format. Fields outside it are ignored. `updated_at` must change with every change.
      required: [id, calendar_id, start, end, status, updated_at]
      properties:
        id: { type: string, minLength: 1, maxLength: 1024 }
        calendar_id: { type: string, minLength: 1, maxLength: 1024 }
        title: { type: string, maxLength: 1024, default: "" }
        start: { type: string, format: date-time }
        end: { type: string, format: date-time }
        all_day: { type: boolean, default: false }
        status: { enum: [confirmed, tentative, cancelled] }
        attendees:
          type: array
          maxItems: 500
          default: []
          items:
            type: object
            required: [email]
            properties:
              email: { type: string, maxLength: 320 }
              name: { type: [string, "null"], maxLength: 256 }
              response: { enum: [needsAction, accepted, declined, tentative], default: needsAction }
              self: { type: boolean, description: True on the attendee who is the calendar's owner. }
        location: { type: [string, "null"], maxLength: 2048 }
        description: { type: [string, "null"], maxLength: 65536 }
        updated_at: { type: string, format: date-time }
      example:
        id: evt_123
        calendar_id: team
        title: Call with Ana
        start: "2026-11-03T14:00:00+01:00"
        end: "2026-11-03T14:30:00+01:00"
        all_day: false
        status: confirmed
        attendees: [{ email: ana@example.com, name: Ana Silva, response: accepted }]
        location: null
        description: null
        updated_at: "2026-10-27T09:12:44Z"
    EventWrite:
      type: object
      description: What Opami sends to create or change an event. On update only the changed fields are present.
      properties:
        title: { type: string }
        start: { type: string, format: date-time }
        end: { type: string, format: date-time }
        time_zone: { type: string }
        location: { type: string }
        description: { type: string }
        attendees: { type: array, items: { type: object, required: [email], properties: { email: { type: string } } } }
        status: { enum: [confirmed, tentative] }
    CalendarPush:
      type: object
      required: [events]
      properties:
        events: { type: array, minItems: 1, maxItems: 500, items: { $ref: "#/components/schemas/Event" } }
