openapi: 3.1.0
info:
  title: Webhook Relay API
  version: 0.5.0
  summary: Production-shaped webhook delivery infrastructure.
  description: |
    Webhook Relay accepts events from your application and fans them out to
    subscriber endpoints with HMAC signing, idempotency-key dedup,
    exponential-backoff retries, dead-letter handling, and per-attempt
    observability.

    This spec is the source of truth for the API. Controllers conform to it;
    SDKs are generated from it. Run `npx @stoplight/spectral-cli lint
    openapi/spec.yaml` to validate locally.
  contact:
    name: Philip Rehberger
    url: https://webhook-relay.dcsuniverse.com
  license:
    name: MIT
    identifier: MIT

servers:
  - url: https://api.webhook-relay.dcsuniverse.com
    description: Production
  - url: http://localhost:8000
    description: Local development

security:
  - ApiKeyAuth: []

tags:
  - name: Events
    description: Ingest, list, and retrieve events.
  - name: Subscriptions
    description: Manage delivery destinations and signing secrets.
  - name: Deliveries
    description: Per-attempt delivery log for observability and retry.
  - name: Dead Letters
    description: Deliveries that exhausted retries or were rejected outright. Inspect and replay.

paths:
  /v1/echo/stream:
    get:
      summary: Live Echo SSE stream
      description: |
        Server-Sent Events stream of delivery updates for the workspace
        identified by `?key=`. Each event has type `delivery` with a JSON
        payload of the delivery's current state. Stream caps at 60 seconds;
        reconnect for the next window.

        Browsers / EventSource clients should use the query string for the
        key since EventSource does not support custom Authorization headers.
      operationId: echoStream
      tags: [Deliveries]
      security: []
      parameters:
        - in: query
          name: key
          required: true
          schema:
            type: string
          description: Bearer-style API key (whk_sandbox_, whk_test_, or whk_live_).
      responses:
        "200":
          description: SSE stream of delivery updates.
          content:
            text/event-stream:
              schema:
                type: string

  /v1/healthz:
    get:
      summary: Liveness check
      description: Returns 200 if the API is up. Does not require auth.
      operationId: healthz
      tags: [Events]
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [healthy]
                  version:
                    type: string
                required: [status, version]
              example:
                status: healthy
                version: 0.2.0

  /v1/events:
    post:
      summary: Ingest an event
      description: |
        Accepts an event and enqueues it for fan-out to all matching
        subscriptions. Provide an `Idempotency-Key` header to safely retry;
        dedup window is 24 hours per workspace.
      operationId: createEvent
      tags: [Events]
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema:
            type: string
            maxLength: 255
          description: Optional dedup key; requests with the same key within 24h return the original response.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EventCreate"
      responses:
        "202":
          description: Event accepted for fan-out.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Event"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: Idempotency key already used with a different payload.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "429":
          $ref: "#/components/responses/RateLimited"

    get:
      summary: List events
      description: Returns a cursor-paginated page of events, newest first, scoped to the caller's workspace.
      operationId: listEvents
      tags: [Events]
      parameters:
        - in: query
          name: type
          schema:
            type: string
          description: Filter by event type (exact match).
        - in: query
          name: created_after
          schema:
            type: string
            format: date-time
        - in: query
          name: cursor
          schema:
            type: string
          description: Opaque cursor from a prior page's `next_cursor`.
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        "200":
          description: Page of events, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EventPage"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/events/{id}:
    get:
      summary: Retrieve an event
      description: Returns the event and a summary of its deliveries across all matching subscriptions.
      operationId: getEvent
      tags: [Events]
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: ULID of the event.
      responses:
        "200":
          description: The event with a delivery summary.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Event"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/subscriptions:
    post:
      summary: Create a subscription
      description: |
        Registers a new endpoint to receive matching events. The signing
        secret is generated server-side and returned ONCE in the response —
        store it now; you cannot retrieve it later (only rotate it).
      operationId: createSubscription
      tags: [Subscriptions]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubscriptionCreate"
      responses:
        "201":
          description: Subscription created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriptionWithSecret"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

    get:
      summary: List subscriptions
      description: Cursor-paginated, scoped to the caller's workspace.
      operationId: listSubscriptions
      tags: [Subscriptions]
      parameters:
        - in: query
          name: cursor
          schema:
            type: string
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - in: query
          name: state
          schema:
            type: string
            enum: [active, paused, disabled]
      responses:
        "200":
          description: Page of subscriptions.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriptionPage"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/subscriptions/{id}:
    get:
      summary: Retrieve a subscription
      description: Returns a single subscription without its signing secret.
      operationId: getSubscription
      tags: [Subscriptions]
      parameters:
        - $ref: "#/components/parameters/SubscriptionId"
      responses:
        "200":
          description: The subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      summary: Update a subscription
      description: Partial update. Mutable fields are name, url, event_filter.
      operationId: updateSubscription
      tags: [Subscriptions]
      parameters:
        - $ref: "#/components/parameters/SubscriptionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubscriptionUpdate"
      responses:
        "200":
          description: Updated subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      summary: Delete a subscription
      description: Permanently deletes the subscription. Deliveries are retained for audit.
      operationId: deleteSubscription
      tags: [Subscriptions]
      parameters:
        - $ref: "#/components/parameters/SubscriptionId"
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/subscriptions/{id}/pause:
    post:
      summary: Pause a subscription
      description: Stops new deliveries until resumed. Existing in-flight deliveries are not cancelled.
      operationId: pauseSubscription
      tags: [Subscriptions]
      parameters:
        - $ref: "#/components/parameters/SubscriptionId"
      responses:
        "200":
          description: Paused.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/subscriptions/{id}/resume:
    post:
      summary: Resume a paused subscription
      description: Resets consecutive_failures to 0 and returns the subscription to active state.
      operationId: resumeSubscription
      tags: [Subscriptions]
      parameters:
        - $ref: "#/components/parameters/SubscriptionId"
      responses:
        "200":
          description: Resumed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Subscription"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/subscriptions/{id}/rotate-secret:
    post:
      summary: Rotate the signing secret
      description: |
        Generates a new secret and returns it once. The previous secret
        remains valid for a 48-hour grace window so receivers can update
        their verifier without losing requests.
      operationId: rotateSubscriptionSecret
      tags: [Subscriptions]
      parameters:
        - $ref: "#/components/parameters/SubscriptionId"
      responses:
        "200":
          description: Rotated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubscriptionWithSecret"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/deliveries:
    get:
      summary: List deliveries
      description: Cursor-paginated, newest first, scoped to the caller's workspace.
      operationId: listDeliveries
      tags: [Deliveries]
      parameters:
        - in: query
          name: event_id
          schema:
            type: string
        - in: query
          name: subscription_id
          schema:
            type: string
        - in: query
          name: status
          schema:
            type: string
            enum: [pending, success, failed, dead]
        - in: query
          name: cursor
          schema:
            type: string
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        "200":
          description: Page of deliveries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeliveryPage"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/deliveries/{id}:
    get:
      summary: Retrieve a delivery
      description: Returns the delivery with its full attempt timeline.
      operationId: getDelivery
      tags: [Deliveries]
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The delivery with attempts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Delivery"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/deliveries/{id}/retry:
    post:
      summary: Manually retry a delivery
      description: |
        Sets the delivery to `pending` and enqueues a fresh delivery attempt
        immediately. Works regardless of current status. Does not reset
        `attempts_made` — the new attempt is recorded as the next one in
        the timeline.
      operationId: retryDelivery
      tags: [Deliveries]
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Updated delivery with attempts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Delivery"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/dead-letters:
    get:
      summary: List dead-lettered deliveries
      description: |
        Returns deliveries with `status=dead` — the human-attention queue.
        A delivery lands here when:
          - The subscriber returned 4xx (not retried).
          - Retries exhausted on 5xx / timeout / connection errors.
          - The subscription was paused / disabled before the attempt.
          - The SSRF guard blocked the URL.
      operationId: listDeadLetters
      tags: [Dead Letters]
      parameters:
        - in: query
          name: subscription_id
          schema:
            type: string
        - in: query
          name: since
          schema:
            type: string
            format: date-time
        - in: query
          name: cursor
          schema:
            type: string
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        "200":
          description: Page of dead-lettered deliveries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeliveryPage"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/webhooks/test:
    post:
      summary: Ping a webhook URL
      description: |
        Sends one synchronously signed sample event to the URL you provide
        and returns the response. No subscription is created; nothing is
        stored. Used by the docs-site try-it widget so prospects can probe
        their own endpoints before subscribing. Subject to the workspace
        rate limit.

        The SSRF guard applies: private / loopback / link-local IPs are
        rejected with 400.
      operationId: testWebhook
      tags: [Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  pattern: "^https://"
                  description: HTTPS endpoint to probe.
                secret:
                  type: string
                  description: Optional signing secret. If omitted, a random one is generated and returned.
                event_type:
                  type: string
                  pattern: "^[a-z0-9._-]{1,128}$"
                  default: test.ping
                payload:
                  type: object
                  description: Optional payload, defaults to `{"hello":"from-webhook-relay"}`.
      responses:
        "200":
          description: Probe completed (regardless of receiver's HTTP status).
          content:
            application/json:
              schema:
                type: object
                required: [ok, signature_sent, secret_used]
                properties:
                  ok: { type: boolean }
                  status:
                    type: integer
                    nullable: true
                    description: Receiver's HTTP status, null on connection error.
                  latency_ms: { type: integer }
                  response_body_snippet:
                    type: string
                    nullable: true
                    description: First 4 KB of the receiver's response body.
                  signature_sent:
                    type: string
                    description: The exact X-Webhook-Signature header sent — the prospect verifies their own code against this.
                  secret_used:
                    type: string
                  error_code:
                    type: string
                    nullable: true
                  error_detail:
                    type: string
                    nullable: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/dead-letters/{id}/replay:
    post:
      summary: Replay a dead-lettered delivery
      description: |
        Resurrects a `status=dead` delivery — sets it back to `pending` and
        enqueues a fresh attempt. Returns 409 if the delivery is not in the
        dead-letter state. To retry deliveries in other states use POST
        /v1/deliveries/{id}/retry instead.
      operationId: replayDeadLetter
      tags: [Dead Letters]
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Replayed delivery (now pending) with attempts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Delivery"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Delivery is not dead-lettered.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: API Key
      description: |
        Bearer token using your workspace API key.

        ```
        Authorization: Bearer whk_live_...
        ```

  parameters:
    SubscriptionId:
      in: path
      name: id
      required: true
      schema:
        type: string
      description: ULID of the subscription.

  responses:
    BadRequest:
      description: Validation error.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    NotFound:
      description: Resource not found in this workspace.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
    RateLimited:
      description: Workspace rate limit exceeded.
      headers:
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer, description: Unix timestamp at which the bucket refills. }
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"

  schemas:
    EventCreate:
      type: object
      required: [type, payload]
      properties:
        type:
          type: string
          pattern: "^[a-z0-9._-]{1,128}$"
          example: order.created
          description: Dot-separated event type.
        payload:
          type: object
          additionalProperties: true
          description: Arbitrary JSON payload, max 256 KB serialized.

    Event:
      type: object
      required: [id, type, payload, created_at, deliveries_summary]
      properties:
        id:
          type: string
          example: 01JAB3K5XYZQRSTUVWXYZABCDE
        type:
          type: string
          example: order.created
        payload:
          type: object
          additionalProperties: true
        idempotency_key:
          type: string
          nullable: true
        source_ip:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        deliveries_summary:
          type: object
          properties:
            total: { type: integer }
            succeeded: { type: integer }
            failed: { type: integer }
            pending: { type: integer }
          required: [total, succeeded, failed, pending]

    EventPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Event"
        next_cursor:
          type: string
          nullable: true

    SubscriptionCreate:
      type: object
      required: [url]
      properties:
        name:
          type: string
          maxLength: 255
        url:
          type: string
          format: uri
          pattern: "^https://"
          description: HTTPS endpoint to receive deliveries.
        event_filter:
          type: string
          default: "*"
          description: |
            "*" matches all events, otherwise an exact type or a glob
            like "order.*". Glob uses "*" as a wildcard segment.

    SubscriptionUpdate:
      type: object
      properties:
        name:
          type: string
        url:
          type: string
          format: uri
          pattern: "^https://"
        event_filter:
          type: string

    Subscription:
      type: object
      required: [id, url, event_filter, state, consecutive_failures, created_at]
      properties:
        id: { type: string }
        name: { type: string, nullable: true }
        url: { type: string, format: uri }
        event_filter: { type: string }
        state: { type: string, enum: [active, paused, disabled] }
        consecutive_failures: { type: integer }
        paused_at: { type: string, format: date-time, nullable: true }
        secret_rotated_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    SubscriptionWithSecret:
      allOf:
        - $ref: "#/components/schemas/Subscription"
        - type: object
          required: [signing_secret]
          properties:
            signing_secret:
              type: string
              description: Plaintext signing secret. Shown only on creation and rotation.
              example: whsec_abcdef0123456789...

    SubscriptionPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Subscription"
        next_cursor:
          type: string
          nullable: true

    Delivery:
      type: object
      required: [id, event_id, subscription_id, status, attempts_made, created_at]
      properties:
        id: { type: string }
        event_id: { type: string }
        subscription_id: { type: string }
        status: { type: string, enum: [pending, success, failed, dead] }
        attempts_made: { type: integer }
        next_attempt_at: { type: string, format: date-time, nullable: true }
        final_status_code: { type: integer, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        attempts:
          type: array
          description: Present on retrieve, omitted on list.
          items:
            $ref: "#/components/schemas/DeliveryAttempt"

    DeliveryAttempt:
      type: object
      required: [attempt_number, request_signature, attempted_at]
      properties:
        attempt_number: { type: integer }
        request_signature:
          type: string
          description: "Format: t={unix_ts},v1={hex_hmac_sha256}"
        response_status: { type: integer, nullable: true }
        response_headers:
          type: object
          additionalProperties: { type: string }
          nullable: true
        response_body_snippet:
          type: string
          nullable: true
          description: First 4 KB of the response body, UTF-8 best-effort.
        latency_ms: { type: integer, nullable: true }
        error_code: { type: string, nullable: true }
        attempted_at: { type: string, format: date-time }

    DeliveryPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Delivery"
        next_cursor:
          type: string
          nullable: true

    Problem:
      type: object
      description: RFC 7807 problem details.
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          example: https://webhook-relay.dcsuniverse.com/errors/validation
        title:
          type: string
          example: Invalid request
        status:
          type: integer
          example: 400
        detail:
          type: string
        instance:
          type: string
        errors:
          type: object
          additionalProperties:
            type: array
            items: { type: string }
