openapi: 3.1.0

# AURA MEETINGS — EXTERNAL API v1
#
# The machine-readable contract. `MEETINGS_EXTERNAL_API_CONTRACT_V1.md` carries
# the reasoning; this carries the shapes, and where they differ this file is
# what a client is generated from.
#
# THIS FILE EXISTS BECAUSE OF A REAL FAILURE. The first consumer was sent the
# routes, the event envelope, the webhook headers and the replay shape — but
# not the create request body. So they inferred a field name, shipped it, and
# it was wrong from the first commit. Nothing on their side could have found
# it: their tests exercise their own seam, not Aura's wire, and there was no
# endpoint yet to fail against. It surfaced only because something nearby
# changed.
#
# A published prose contract with an unpublished corner is an invitation to
# guess. Every request body, response body and error shape is here, so the
# answer to "what does Aura expect" is never inference.
#
# CUSTOMER-NEUTRAL. No consumer's name, product or identifiers appear in any
# path, field, header, event type or error code. A second customer must be able
# to read this without learning who the first one was.

info:
  title: Aura Meetings External API
  version: "1.0.0"
  summary: Create and follow real meetings, as an external service.
  description: |
    Aura Meetings as a meeting provider for other systems.

    A consumer authenticates with a per-consumer credential, provisions its own
    customers as tenants, gives each tenant a durable meeting identity, and
    creates meetings under that authority. Aura reports what happens as signed
    events, and exposes both a canonical snapshot and a replayable log so a
    lost delivery is always recoverable.

    **Responses are the bare objects described here.** There is no envelope:
    a success is the `Meeting`, `Tenant` or `EventPage` itself, and a failure
    is exactly `{ ok: false, code, reason }` and nothing else. Aura's internal
    API wraps its own responses; this boundary deliberately does not, and that
    is enforced rather than remembered.

    **Aura states facts, never conclusions.** Created, invited, joined,
    started, ended, nobody came. Never qualified, progressed, agreed or closed
    — commercial meaning belongs to the consumer.
  contact:
    name: Aura Platform
    url: https://meetings.auraplatform.org
  license:
    name: Proprietary — the hosted service. Client tooling is separately licensed.

servers:
  - url: https://meetings.auraplatform.org/v1/external
    description: Production. The only host; there is no per-consumer host.

security:
  - consumerCredential: []

tags:
  - name: Tenants
    description: A consumer's own customers, and their durable meeting identities.
  - name: Meetings
    description: Creating and following individual meetings.
  - name: Events
    description: Replay, for reconciling a gap.

paths:
  /tenants/{tenantRef}:
    get:
      tags: [Tenants]
      operationId: getTenant
      summary: Resolve a tenant and its meeting identities
      description: |
        Retrieval is the authority for tenancy; there are no tenancy events.
        Every event type is about one meeting, and `sequence` is contiguous per
        meeting, so an event belonging to no meeting would have no sequence to
        belong to.

        **Freshness:** re-resolve before handing an identity to a new
        relationship, and at least daily for identities already in circulation.
        A consumer that caches indefinitely will eventually hand out an address
        for a retired identity — which reaches the customer's counterparty as
        the customer's failure.
      parameters:
        - $ref: '#/components/parameters/TenantRef'
      responses:
        '200':
          description: The tenant as it stands now.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

        '500': { $ref: '#/components/responses/ProviderFault' }
  /tenants:
    post:
      tags: [Tenants]
      operationId: provisionTenant
      summary: Provision a tenant
      description: |
        Requires the `TENANTS_PROVISION` scope, which is deliberately separate
        from `MEETINGS_WRITE`: creating a meeting acts under an authority that
        already exists, while creating a tenant CREATES the authority.

        **Idempotent on `tenantRef`.** The same reference always resolves to the
        same tenant; a retry after a timeout does not produce a second
        customer. Identity never depends on `displayName`, which may change and
        may be shared.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProvisionTenantRequest' }
      responses:
        '200':
          description: The tenant, created or already existing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/Refusal' }

        '500': { $ref: '#/components/responses/ProviderFault' }
  /tenants/{tenantRef}/identities:
    post:
      tags: [Tenants]
      operationId: provisionMeetingIdentity
      summary: Provision a durable meeting identity for a tenant
      description: |
        Creates a new bookable address under the tenant's authority. Requires
        `TENANTS_PROVISION`.

        **An existing Aura object can never be adopted.** There is no field for
        an institution, workspace, user or profile id, because accepting one
        would be a cross-tenant takeover in the shape of a convenience.

        **A taken address is refused, not altered.** Returning a suffixed
        variant would invite a consumer to store it as the address it asked
        for, and hand that to a counterparty.

        **An address is immutable and is never reissued to another tenant.** A
        retired identity is deactivated, and its address stays permanently
        spoken for.
      parameters:
        - $ref: '#/components/parameters/TenantRef'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProvisionIdentityRequest' }
      responses:
        '201':
          description: The identity.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MeetingIdentity' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Refusal' }
        '422': { $ref: '#/components/responses/Refusal' }

        '500': { $ref: '#/components/responses/ProviderFault' }
  /meetings:
    post:
      tags: [Meetings]
      operationId: createMeeting
      summary: Create a meeting
      description: |
        Creates a real Aura meeting under a tenant's meeting identity. The
        identity is the participation contract: its assigned host is the host,
        and the invitees come from this request.

        **`idempotencyKey` is required.** The same key never produces a second
        meeting — a network retry is not a request for another meeting — and it
        is enforced by a unique index rather than by check-then-insert.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateMeetingRequest' }
      responses:
        '201':
          description: The meeting.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Meeting' }
        '200':
          description: |
            The meeting that this `idempotencyKey` already created. Identical
            body to a 201.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Meeting' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/Refusal' }

        '500': { $ref: '#/components/responses/ProviderFault' }
  /meetings/{externalId}:
    get:
      tags: [Meetings]
      operationId: getMeeting
      summary: The meeting's current truth
      description: |
        An event says something happened; this says what is true now. Without
        it, one lost delivery would leave a record permanently wrong with
        nothing to reconcile against.

        The snapshot and the event stream derive each participant state from
        the same source, so reconciliation cannot disagree with the events it
        is repairing.
      parameters:
        - $ref: '#/components/parameters/ExternalId'
      responses:
        '200':
          description: The meeting as it stands now.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Meeting' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

        '500': { $ref: '#/components/responses/ProviderFault' }
  /meetings/{externalId}/reschedule:
    post:
      tags: [Meetings]
      operationId: rescheduleMeeting
      summary: Move a meeting
      parameters:
        - $ref: '#/components/parameters/ExternalId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RescheduleRequest' }
      responses:
        '200':
          description: The updated meeting.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Meeting' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            `MEETING_NOT_MUTABLE` — already ended or cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }

        '500': { $ref: '#/components/responses/ProviderFault' }
  /meetings/{externalId}/cancel:
    post:
      tags: [Meetings]
      operationId: cancelMeeting
      summary: Call a meeting off
      description: |
        Cancelling is not the same fact as a participant declining an
        invitation, and Aura never reports one as the other.
      parameters:
        - $ref: '#/components/parameters/ExternalId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CancelRequest' }
      responses:
        '200':
          description: The cancelled meeting.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Meeting' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: '`MEETING_NOT_MUTABLE`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }

        '500': { $ref: '#/components/responses/ProviderFault' }
  /meetings/{externalId}/events:
    get:
      tags: [Events]
      operationId: replayEvents
      summary: Events after a cursor
      description: |
        Returns the STORED events — the same `eventId` and `sequence` the
        webhook carried, never a re-derivation. A regenerated id would make
        every replayed event record twice and turn reconciliation into
        corruption.

        **Follow `hasMore` to completion.** A single short page looks like a
        whole answer and would leave a gap nothing goes back for.
      parameters:
        - $ref: '#/components/parameters/ExternalId'
        - name: afterSequence
          in: query
          description: Exclusive. `0` or omitted returns from the beginning.
          schema: { type: integer, minimum: 0, default: 0 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        '200':
          description: A page of events, ascending by sequence.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

        '500': { $ref: '#/components/responses/ProviderFault' }
webhooks:
  meetingEvent:
    post:
      operationId: receiveMeetingEvent
      summary: A meeting fact, delivered to the consumer
      description: |
        **Verify the signature over the RAW BYTES, before parsing.** An
        unverified event is not an event.

        `X-Aura-Signature: t=<unix>,v1=<hex>` where the hex is
        `HMAC-SHA256(signingSecret, "<t>.<raw body>")`. The timestamp is bound
        INTO the signed material: a signature over the body alone verifies
        forever, so a captured delivery could be replayed indefinitely and
        every copy would check out. Reject a skew beyond your tolerance; 300s
        is suggested.

        **Delivery is at-least-once and ordered per meeting.** Duplicates are
        normal — deduplicate on `eventId`. A later `sequence` never overtakes
        an earlier undelivered one for the same meeting.

        **An unrecognised `type` must be ADMITTED, COUNTED and NOT
        INTERPRETED.** Rejecting it, or skipping it without counting it, breaks
        the contiguity of `sequence` — after which every later `MEETING_ENDED`
        for that meeting defers forever and no meeting on that stream ever
        reaches a terminal state again.

        Any `2xx` acknowledges. Anything else is retried 6 times with
        exponential backoff, after which the event remains available by replay.
      parameters:
        - name: X-Aura-Event-Id
          in: header
          schema: { type: string }
        - name: X-Aura-Meeting-Sequence
          in: header
          schema: { type: integer }
        - name: X-Aura-Event-Type
          in: header
          schema: { type: string }
        - name: X-Aura-Delivery-Attempt
          in: header
          schema: { type: integer }
        - name: X-Aura-Signature
          in: header
          required: true
          schema: { type: string, examples: ["t=1757181791,v1=9f2c..."] }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Event' }
      responses:
        '200': { description: Acknowledged. }
components:
  securitySchemes:
    consumerCredential:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <keyId>.<secret>`

        `keyId` is public and safe to log. `secret` is shown once at issuance
        and stored only as a hash. Scopes: `MEETINGS_READ`, `MEETINGS_WRITE`,
        `TENANTS_PROVISION`.

        There is no shared secret, no master token, and no privileged path for
        any consumer.

  parameters:
    TenantRef:
      name: tenantRef
      in: path
      required: true
      description: |
        The consumer's OWN identifier for its customer. Unique per consumer,
        not globally — two consumers may use the same string for different
        customers. Opaque to Aura, which never parses it.
      schema: { type: string, minLength: 1, maxLength: 200 }
    ExternalId:
      name: externalId
      in: path
      required: true
      description: Aura's stable identifier for the meeting.
      schema: { type: string }

  responses:
    Unauthorized:
      description: |
        `UNAUTHORIZED`. Deliberately indistinguishable across every cause —
        malformed header, unknown key, wrong secret, revoked or rotated
        credential, suspended consumer, missing scope. Distinguishing them
        would tell a caller which half of a guess was right.

        For a meeting the asking consumer created, this is distinguishable from
        `MEETING_NOT_FOUND`, so a permissions problem is never mistaken for a
        divergence alarm.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Refusal' }
    NotFound:
      description: |
        `MEETING_NOT_FOUND` — *this provider was asked, and holds no meeting
        with that id.* Not "unknown to anyone": another provider may hold it,
        and Aura has no way to know. If you route across several providers,
        "no provider is configured for this" is a state on YOUR side and Aura
        has no code for it — it can only answer for itself.

        A meeting belonging to another consumer answers the same way, so no
        consumer can probe another's existence.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Refusal' }
    ProviderFault:
      description: |
        `PROVIDER_UNAVAILABLE`. Something failed inside Aura. **This is not a
        fault in your request**, and it is said in those words in `reason` so
        that nobody goes looking through a correct integration for a defect
        that is not there.

        Documented because an undocumented failure mode is the same hazard as
        an undocumented field: a generated client meets a `code` its own type
        does not have, and a refusal arrives as a parse error. Retry with
        backoff; nothing was created.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Refusal' }
    Refusal:
      description: A refusal about the request.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Refusal' }

  schemas:
    Refusal:
      type: object
      required: [ok, code, reason]
      description: |
        A refusal is about the REQUEST, not about HTTP. A caller must never
        have to read a status code to learn that a meeting has already ended.
        The HTTP status stays honest; `code` is the machine-readable fact.

        Two disjoint sets. Meeting operations use the provider-interface codes;
        provisioning uses its own, because provisioning is not a meeting
        operation and forcing it through the meeting codes would report the
        wrong cause. In particular `PARTICIPANT_INVALID` is NOT the code for a
        taken address.
      properties:
        ok: { type: boolean, const: false }
        code:
          type: string
          enum:
            # Meeting operations. Every value here can actually be
            # returned; NOT_SUPPORTED and STALE_VERSION were removed on
            # 2026-09-06 because nothing ever emitted them, and a code a
            # consumer can never receive is a branch it writes for
            # nothing.
            - MEETING_NOT_FOUND
            - MEETING_NOT_MUTABLE
            - PARTICIPANT_INVALID
            - PROVIDER_UNAVAILABLE
            - UNAUTHORIZED
            # Provisioning operations.
            - TENANT_NOT_FOUND
            - TENANT_NOT_ACTIVE
            - ADDRESS_TAKEN
            - ADDRESS_INVALID
            - HOST_NOT_FOUND
            - IDENTITY_NOT_FOUND
        reason:
          type: string
          description: Truthful, plain, addressed to whoever must act on it.

    ProvisionTenantRequest:
      type: object
      required: [tenantRef, displayName]
      properties:
        tenantRef:
          type: string
          description: |
            The consumer's own identifier for the customer. The idempotency
            key for this operation.
        displayName:
          type: string
          description: A name an operator would recognise. Never an identifier.

    ProvisionIdentityRequest:
      type: object
      required: [address, assignedHostUserId]
      properties:
        address:
          type: string
          description: |
            The requested bookable address, e.g. `acme-sales`. Lowercased and
            hyphenated. Refused if taken — never silently altered.
          minLength: 3
          maxLength: 64
        assignedHostUserId:
          type: string
          description: The Aura member who will host meetings booked here.
        meetingTitle: { type: string }
        durationMinutes: { type: integer, default: 30 }
        timezone: { type: string, default: UTC }

    MeetingIdentity:
      type: object
      properties:
        identityId: { type: string }
        address: { type: string, examples: [acme-sales] }
        url:
          type: string
          format: uri
          description: Where a counterparty books. Immutable, never reissued.
        isActive:
          type: boolean
          description: |
            False means retired. It does NOT mean the address is free; a
            retired address is never given to another tenant.
        isDefault: { type: boolean }

    Tenant:
      type: object
      properties:
        tenantRef: { type: string }
        displayName: { type: string }
        status:
          type: string
          enum: [ACTIVE, SUSPENDED]
          description: |
            The TENANT's standing, separate from any identity's `isActive`. A
            tenant can be suspended while its identities remain intact, and an
            identity can be retired while the tenant continues.
        meetingIdentity:
          oneOf:
            - $ref: '#/components/schemas/MeetingIdentity'
            - type: 'null'
          description: The default identity, or null if none is provisioned.
        meetingIdentities:
          type: array
          items: { $ref: '#/components/schemas/MeetingIdentity' }
          description: |
            Every identity under this tenant. A tenant may hold several — sales,
            support, onboarding — with one default.
        retrievedAt: { type: string, format: date-time }

    CreateMeetingRequest:
      type: object
      required: [tenantRef, title, invitees, idempotencyKey]
      properties:
        tenantRef:
          type: string
          description: Which of the consumer's customers this meeting is for.
        identityAddress:
          type: string
          description: |
            Which meeting identity to use. Omit for the tenant's default.
        consumerMeetingRef:
          type: string
          description: |
            The consumer's own reference for this meeting, kept for its audit
            trail. Aura stores it and never parses it.
        title: { type: string }
        scheduledAt:
          oneOf:
            - { type: string, format: date-time }
            - type: 'null'
          description: |
            Omit or null to ask the counterparty to pick a time.
        durationMinutes: { type: integer, default: 30 }
        invitees:
          type: array
          minItems: 1
          items:
            type: object
            required: [email]
            properties:
              email: { type: string, format: email }
              displayName: { type: string }
        idempotencyKey:
          type: string
          description: |
            Required, and unique per consumer. The same key NEVER produces a
            second meeting; the original is returned instead.

    RescheduleRequest:
      type: object
      required: [scheduledAt, idempotencyKey]
      properties:
        scheduledAt: { type: string, format: date-time }
        idempotencyKey: { type: string }

    CancelRequest:
      type: object
      required: [idempotencyKey]
      properties:
        reason:
          type: string
          description: Kept as provenance. Never shown as Aura's own words.
        idempotencyKey: { type: string }

    Participant:
      type: object
      properties:
        participantId:
          type: string
          description: |
            A DURABLE external identity, not an Aura row id. It survives the
            person upgrading from guest to member, so one participant never
            appears to vanish and be replaced mid-meeting.
          examples: [mpx_9f2c1a...]
        email:
          oneOf:
            - { type: string, format: email }
            - type: 'null'
          description: |
            NON-NULL for participants created from caller-supplied invitees, so
            a consumer can correlate before it has ever seen a `participantId`.
            Null is possible for someone who arrived another way.
        displayName:
          oneOf:
            - { type: string }
            - type: 'null'
        state:
          type: string
          enum:
            - INVITED
            - INVITATION_ACCEPTED
            - INVITATION_DECLINED
            - JOINED
            - LEFT
            - NO_SHOW
          description: |
            Every value names the fact it is about. There is deliberately no
            bare `ACCEPTED`: accepting an invitation, an RSVP, and being
            admitted to a room are three different facts about three different
            objects, and Aura holds all of them separately.

            `INVITATION_ACCEPTED` is the invited party's OWN action. It is
            never the creation-time auto-acceptance of the host, and never an
            email-inferred match.
        occurredAt:
          oneOf:
            - { type: string, format: date-time }
            - type: 'null'
          description: When that state became true, per Aura.

    Meeting:
      type: object
      properties:
        providerKey: { type: string, const: AURA_MEETINGS }
        externalId: { type: string }
        tenantRef: { type: string }
        state:
          type: string
          enum: [SCHEDULED, ACTIVE, ENDED, CANCELLED]
          description: |
            There is no fifth value. An `externalId` is only ever issued for a
            meeting that is `SCHEDULED` or `ACTIVE`, so a consumer cannot hold
            a reference this endpoint will not describe.
        scheduledAt: { oneOf: [{ type: string, format: date-time }, { type: 'null' }] }
        startedAt: { oneOf: [{ type: string, format: date-time }, { type: 'null' }] }
        endedAt: { oneOf: [{ type: string, format: date-time }, { type: 'null' }] }
        joinUrl: { oneOf: [{ type: string, format: uri }, { type: 'null' }] }
        invitationUrl: { oneOf: [{ type: string, format: uri }, { type: 'null' }] }
        participants:
          type: array
          items: { $ref: '#/components/schemas/Participant' }
        retrievedAt:
          type: string
          format: date-time
          description: A cached view is not current truth.

    Event:
      type: object
      required: [providerKey, eventId, type, externalMeetingId, sequence, occurredAt]
      properties:
        providerKey: { type: string, const: AURA_MEETINGS }
        eventId:
          type: string
          description: |
            Provider-assigned, stable forever, unique. The whole idempotency
            story: identity comes from here rather than from payload contents,
            because two genuinely distinct events can carry identical bodies.
        type:
          type: string
          description: |
            An unrecognised value must be admitted, counted and not
            interpreted. See the webhook description.
          enum:
            - MEETING_CREATED
            - INVITATION_ISSUED
            - INVITATION_ACCEPTED
            - INVITATION_DECLINED
            - MEETING_RESCHEDULED
            - MEETING_CANCELLED
            - PARTICIPANT_JOINED
            - MEETING_STARTED
            - MEETING_ENDED
            - PARTICIPANT_NO_SHOW
        externalMeetingId: { type: string }
        sequence:
          type: integer
          minimum: 1
          description: |
            Monotonic and CONTIGUOUS FROM 1, per meeting. Contiguity is what
            makes gap detection arithmetic rather than inference — and the gap
            must be found BEFORE `MEETING_ENDED` is interpreted, because that
            verdict is terminal and there is no second look when the missing
            event finally lands.
        occurredAt:
          type: string
          format: date-time
          description: When it happened, per Aura. Never delivery time.
        participant:
          oneOf:
            - $ref: '#/components/schemas/Participant'
            - type: 'null'

    EventPage:
      type: object
      properties:
        meetingId: { type: string }
        events:
          type: array
          items: { $ref: '#/components/schemas/Event' }
        nextAfterSequence: { type: integer }
        hasMore:
          type: boolean
          description: Follow to completion. A short page is not a whole answer.
        retentionDays:
          type: integer
          description: |
            How far back replay reaches, published as a number rather than
            discovered by hitting the limit.
