openapi: 3.1.0
info:
  title: posting-api
  version: "1.0.0"
  description: Multi-tenant social posting API. Variants-first. Sandbox first. Host https://api.variantpost.com.
servers:
  - url: "{origin}"
    description: API host (https://api.variantpost.com). Compose remains http://localhost:8080 for local sk_test_.
    variables:
      origin:
        default: https://api.variantpost.com
  - url: http://localhost:8080
    description: Local compose (sk_test_ sandbox).
tags:
  - name: Health
  - name: Connect
  - name: Media
  - name: Posts
  - name: Webhooks
  - name: EndUsers
  - name: Keys
  - name: Legal
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sk_live_ or sk_test_
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, retryable, request_id]
          properties:
            code: { type: string }
            message: { type: string }
            param: { type: string }
            retryable: { type: boolean }
            request_id: { type: string }
    ConnectSession:
      type: object
      properties:
        session_id: { type: string }
        url: { type: string }
        expires_at: { type: string, format: date-time }
    PostAccepted:
      type: object
      properties:
        id: { type: string }
        status: { type: string, example: accepted }
        variants:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              job_id: { type: string }
              status: { type: string }
    ApiKey:
      type: object
      required: [id, mode, scopes, created_at, revoked_at]
      properties:
        id: { type: string, example: ek_… }
        mode: { type: string, enum: [test, live] }
        scopes:
          type: array
          items: { type: string }
        created_at: { type: string, format: date-time }
        revoked_at: { type: string, format: date-time, nullable: true }
    ApiKeyCreated:
      type: object
      required: [id, mode, scopes, key, created_at]
      properties:
        id: { type: string }
        mode: { type: string, enum: [test, live] }
        scopes:
          type: array
          items: { type: string }
        key: { type: string, description: Raw secret shown at creation }
        created_at: { type: string, format: date-time }
    Usage:
      type: object
      required: [from, to, deliveries_succeeded, billed_deliveries, transcode_seconds, stored_gb_month, by_network]
      properties:
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        deliveries_succeeded: { type: integer }
        billed_deliveries: { type: integer }
        transcode_seconds: { type: number }
        stored_gb_month: { type: number }
        by_network:
          type: object
          additionalProperties: { type: integer }
    Event:
      type: object
      properties:
        id: { type: string }
        type: { type: string }
        created_at: { type: string, format: date-time }
        data: { type: object }
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      schema:
        type: string
        maxLength: 64
        pattern: '^[A-Za-z0-9._-]+$'
  responses:
    Error:
      description: Error envelope
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
security:
  - bearerAuth: []
paths:
  /healthz:
    get:
      tags: [Health]
      security: []
      summary: Liveness. 200 even when DATABASE_URL is unset (db=false).
      responses:
        "200":
          description: "{ ok, db }"
  /v1/keys:
    get:
      tags: [Keys]
      summary: List active API keys; raw secrets and hashes are never returned.
      responses:
        "200":
          description: Active keys
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Error" }
    post:
      tags: [Keys]
      summary: Create an API key; the raw secret is shown once. Any valid tenant key may manage keys in v1.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode: { type: string, enum: [test, live] }
                scopes:
                  type: array
                  items: { type: string }
      responses:
        "201":
          description: Created key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyCreated" }
        "400": { $ref: "#/components/responses/Error" }
  /v1/keys/{id}:
    delete:
      tags: [Keys]
      summary: Soft-revoke an API key; the current key cannot be revoked when it is the tenant's last active key.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                required: [id, status]
                properties:
                  id: { type: string }
                  status: { type: string, enum: [revoked] }
        "404": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/connect/{network}/callback:
    get:
      tags: [Connect]
      security: []
      summary: OAuth redirect. No trailing slash. GET ?code=&state=. Unauthenticated. Live exchange for configured LinkedIn/X/YouTube/TikTok and Meta app sessions.
      parameters:
        - in: path
          name: network
          required: true
          schema:
            type: string
            enum: [instagram, facebook, threads, linkedin, x, youtube, tiktok, mastodon]
        - in: query
          name: code
          schema: { type: string }
        - in: query
          name: state
          required: true
          schema: { type: string }
      responses:
        "200": { description: JSON { connection_id, url } when Accept is application/json }
        "302": { description: Redirect to session success_url }
        "400": { $ref: "#/components/responses/Error" }
        "501": { description: network_oauth_not_configured (live / missing secret; code not exchanged) }
        "503": { description: DATABASE_URL unset }
  /v1/connect/{network}/sessions:
    post:
      tags: [Connect]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - in: path
          name: network
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [end_user_id, success_url, cancel_url]
              properties:
                end_user_id: { type: string }
                success_url: { type: string }
                cancel_url: { type: string }
                scopes:
                  type: array
                  items: { type: string }
                instance_url:
                  type: string
                  description: Required for live Mastodon (https instance base URL)
      responses:
        "201":
          description: Session created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConnectSession'
        "422":
          $ref: '#/components/responses/Error'
        "501":
          description: network_oauth_not_configured (live OAuth apps not set up)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "503": { description: DATABASE_URL unset, code overloaded }
  /v1/connect/bluesky:
    post:
      tags: [Connect]
      summary: Credential connect (handle + app password → ATProto createSession)
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [end_user_id, identifier, app_password]
              properties:
                end_user_id: { type: string }
                identifier: { type: string, description: Bluesky handle or email }
                app_password: { type: string }
                pds_url: { type: string, description: Optional PDS override (default BLUESKY_PDS_URL) }
      responses:
        "201": { description: "{ connection_id, network, remote_account_id }" }
        "400": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /v1/connect/telegram:
    post:
      tags: [Connect]
      summary: Bot-token connect (bot_token + chat_id for channels/groups)
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [end_user_id, bot_token, chat_id]
              properties:
                end_user_id: { type: string }
                bot_token: { type: string }
                chat_id: { type: string, description: Channel username (@x) or numeric chat id }
      responses:
        "201": { description: "{ connection_id, network, remote_account_id }" }
        "400": { $ref: "#/components/responses/Error" }
        "502": { $ref: "#/components/responses/Error" }
  /v1/connections:
    get:
      tags: [Connect]
      parameters:
        - in: query
          name: end_user_id
          schema: { type: string }
      responses:
        "200":
          description: Connection list
  /v1/connections/{id}:
    get:
      tags: [Connect]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Connection }
    delete:
      tags: [Connect]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Revoked }
  /v1/connections/{id}/refresh:
    post:
      tags: [Connect]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "202": { description: Refresh accepted }

  /v1/uploads/{id}/content:
    get:
      tags: [Media]
      security: []
      summary: Short-lived tokenized public media fetch used by provider APIs.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: query
          name: token
          required: true
          schema: { type: string }
      responses:
        "200": { description: Media bytes }
        "404": { description: Media unavailable }

  /v1/media:
    post:
      tags: [Media]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [end_user_id, filename, content_type, byte_size]
              properties:
                end_user_id: { type: string }
                filename: { type: string }
                content_type: { type: string }
                byte_size: { type: integer }
      responses:
        "201": { description: Presigned PUT }
  /v1/media/{id}:
    get:
      tags: [Media]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Media status pending|ready|rejected }
  /v1/uploads/{id}:
    put:
      tags: [Media]
      security: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: query
          name: token
          required: true
          schema: { type: string }
      responses:
        "200": { description: Stored }
  /v1/posts:
    get:
      tags: [Posts]
      parameters:
        - in: query
          name: end_user_id
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100 }
      responses:
        "200": { description: Tenant-scoped posts newest first }
    post:
      tags: [Posts]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [end_user_id]
              properties:
                end_user_id: { type: string }
                scheduled_at: { type: string, nullable: true }
                auto_adapt: { type: boolean }
                networks:
                  type: array
                  items: { type: string }
                media_ids:
                  type: array
                  items: { type: string }
                caption: { type: string }
                variants:
                  type: array
                  items:
                    type: object
                    properties:
                      connection_id: { type: string }
                      kind: { type: string }
                      caption: { type: string }
                      media_ids:
                        type: array
                        items: { type: string }
                      alt_texts:
                        type: array
                        items: { type: string }
                      first_comment: { type: string, nullable: true }
                      cover_frame_ms: { type: integer }
                      language: { type: string }
                      extra: { type: object }
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostAccepted'
  /v1/posts/{id}:
    get:
      tags: [Posts]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Post }
  /v1/posts/{id}/cancel:
    post:
      tags: [Posts]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "202": { description: Cancel accepted }
  /v1/posts/{id}/jobs:
    get:
      tags: [Posts]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Jobs }
  /v1/jobs/{id}:
    get:
      tags: [Posts]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Job }

  /v1/deliveries:
    get:
      tags: [Posts]
      parameters:
        - in: query
          name: end_user_id
          schema: { type: string }
        - in: query
          name: post_id
          schema: { type: string }
      responses:
        "200": { description: Tenant-scoped delivery list }
  /v1/jobs/{id}/retry:
    post:
      tags: [Posts]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "202": { description: Failed job re-queued }
  /v1/deliveries/{id}:
    get:
      tags: [Posts]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Delivery }
  /v1/webhooks:
    post:
      tags: [Webhooks]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string }
                events:
                  type: array
                  items: { type: string }
                secret: { type: string }
      responses:
        "201": { description: Endpoint created; secret shown once }
    get:
      tags: [Webhooks]
      responses:
        "200": { description: Endpoints }
  /v1/webhooks/{id}:
    patch:
      tags: [Webhooks]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Updated }
    delete:
      tags: [Webhooks]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Deleted }
  /v1/webhooks/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200": { description: New secret shown once }
  /v1/events:
    get:
      tags: [Webhooks]
      parameters:
        - in: query
          name: after
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer }
      responses:
        "200": { description: Event cursor }
  /v1/usage:
    get:
      tags: [Webhooks]
      summary: Tenant usage totals for a time window (no time series).
      parameters:
        - in: query
          name: from
          description: Inclusive start (ISO-8601). Default epoch.
          schema: { type: string, format: date-time }
        - in: query
          name: to
          description: Inclusive end (ISO-8601). Default now.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Flat totals + by_network. Sandbox keys bill 0.
          content:
            application/json:
              schema:
                type: object
                required: [from, to, deliveries_succeeded, billed_deliveries, transcode_seconds, stored_gb_month, by_network]
                properties:
                  from: { type: string, format: date-time }
                  to: { type: string, format: date-time }
                  deliveries_succeeded: { type: integer }
                  billed_deliveries: { type: integer, description: "0 for sk_test_; else deliveries_succeeded" }
                  transcode_seconds: { type: integer, description: "Always 0 until metering is wired" }
                  stored_gb_month: { type: number, description: "Always 0 until metering is wired" }
                  by_network:
                    type: object
                    additionalProperties: { type: integer }
              example:
                from: "1970-01-01T00:00:00.000Z"
                to: "2026-09-07T06:00:00.000Z"
                deliveries_succeeded: 12
                billed_deliveries: 12
                transcode_seconds: 0
                stored_gb_month: 0
                by_network: { instagram: 4, linkedin: 3, x: 5 }
  /v1/end-users/{end_user_id}/delete:
    post:
      tags: [EndUsers]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - in: path
          name: end_user_id
          required: true
          schema: { type: string }
      responses:
        "202": { description: Deletion accepted }
  /v1/end-users/{end_user_id}/export:
    get:
      tags: [EndUsers]
      parameters:
        - in: path
          name: end_user_id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Export }
  /legal/privacy:
    get:
      tags: [Legal]
      security: []
      responses:
        "200": { description: Privacy HTML }
  /legal/terms:
    get:
      tags: [Legal]
      security: []
      responses:
        "200": { description: Terms HTML }
  /legal/status:
    get:
      tags: [Legal]
      security: []
      responses:
        "200": { description: Status JSON }
  /legal/meta/data-deletion:
    post:
      tags: [Legal]
      security: []
      requestBody:
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                signed_request: { type: string }
      responses:
        "200": { description: "{ url, confirmation_code }" }
        "503": { description: DATABASE_URL unset }
        "401": { description: legal_bad_signature }
  /legal/meta/data-deletion/status/{code}:
    get:
      tags: [Legal]
      security: []
      parameters:
        - in: path
          name: code
          required: true
          schema: { type: string }
      responses:
        "200": { description: Human-readable status }
  /legal/meta/deauthorize:
    post:
      tags: [Legal]
      security: []
      responses:
        "200": { description: Empty }
        "503": { description: DATABASE_URL unset }
