openapi: 3.1.0
info:
  title: TriggersAPI
  version: "0.1.0"
  description: >
    A prototype event ingestion and delivery API in the style of Zapier's
    TriggersAPI concept. Producers POST events; TriggersAPI stores them,
    fans them out to registered webhook endpoints with retries and
    backoff, and exposes a pull-style inbox for consumers who would
    rather poll than receive a webhook.
servers:
  - url: http://localhost:3000
    description: Local dev server

tags:
  - name: events
    description: Ingesting events
  - name: inbox
    description: Pull-style retrieval of undelivered events
  - name: endpoints
    description: Registering webhook consumers
  - name: deliveries
    description: Delivery status and manual retry
  - name: stats
    description: Observability
  - name: demo
    description: Demo webhook receiver, used to exercise retries locally

paths:
  /api/events:
    get:
      tags: [events]
      summary: List events with delivery summaries
      description: >
        Read API for the Explorer UI: all events (acked or not), newest
        first, each with a count of its deliveries by status.
      parameters:
        - name: source
          in: query
          schema: { type: string }
        - name: type
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Events, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/Event"
                        - type: object
                          properties:
                            deliveryStatuses:
                              type: object
                              description: Delivery count per status for this event.
                              additionalProperties: { type: integer }
    post:
      tags: [events]
      summary: Ingest an event
      description: >
        Accepts a new event for a source. Fans it out to every active
        endpoint as a pending delivery. Send an `Idempotency-Key` header to
        make retries of the same logical event safe — the same key from the
        same source will not create a second event or a second set of
        deliveries.
      security:
        - bearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
          description: >
            Caller-supplied key, unique per source. Re-sending the same
            (source, key) pair returns the original event instead of
            creating a duplicate.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, payload]
              properties:
                type:
                  type: string
                  description: Event type, e.g. "order.created".
                  example: order.created
                payload:
                  type: object
                  description: Arbitrary JSON payload for the event.
                  additionalProperties: true
      responses:
        "202":
          description: Event accepted and fanned out to active endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, description: ULID of the stored event }
                  status: { type: string, enum: [accepted] }
                  deliveries:
                    type: integer
                    description: Number of deliveries created (one per active endpoint).
        "200":
          description: Duplicate of a previous event with the same Idempotency-Key.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string, enum: [duplicate] }
        "400":
          description: Invalid JSON body or failed schema validation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Missing or invalid bearer API key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "413":
          description: Payload exceeds the 64 KB size limit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/inbox:
    get:
      tags: [inbox]
      summary: List inbox events
      description: >
        A single shared inbox for the prototype: every consumer sees the
        same list, filterable by ack status, source, and type. By default
        only not-yet-acked events are returned.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, acked, all]
            default: pending
          description: >
            pending = not yet acked (default), acked = already consumed,
            all = both.
        - name: source
          in: query
          schema: { type: string }
        - name: type
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Matching events, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/Event" }
        "400":
          description: status is not one of pending, acked, all.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/inbox/{eventId}/ack:
    post:
      tags: [inbox]
      summary: Acknowledge an event
      description: Marks an event as consumed so it drops out of the inbox listing.
      parameters:
        - name: eventId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Event acked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  acked: { type: boolean }
        "404":
          description: Event not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Event was already acked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/events/{id}:
    get:
      tags: [events]
      summary: Get event detail with delivery timeline
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Event, its deliveries, and each delivery's attempt history.
          content:
            application/json:
              schema:
                type: object
                properties:
                  event: { $ref: "#/components/schemas/Event" }
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/DeliveryDetail" }
        "404":
          description: Event not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/endpoints:
    get:
      tags: [endpoints]
      summary: List registered endpoints with delivery counts
      description: >
        Webhook URLs are treated as secrets (they often embed tokens), so
        `url` here is masked to origin + first path segment, e.g.
        `https://api.example.com/hooks/…`. `urlOrigin` gives the unmasked
        scheme+host+port, and `urlMasked` is the masked string itself
        (always masked, even for the demo receiver). The one exception is
        `url` for the built-in demo receiver endpoint, which is shown in
        full since it always points back at this app.
      responses:
        "200":
          description: All endpoints (active and deactivated), each with counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoints:
                    type: array
                    items: { $ref: "#/components/schemas/EndpointWithStats" }
    post:
      tags: [endpoints]
      summary: Register a webhook endpoint
      description: >
        Registers a URL to receive future deliveries. The URL is checked
        against an SSRF blocklist (loopback, private, and link-local
        ranges) both here and again at send time, since DNS answers can
        change between registration and delivery.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, url]
              properties:
                name: { type: string }
                url: { type: string, format: uri }
      responses:
        "201":
          description: Endpoint registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Endpoint" }
        "400":
          description: Invalid body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: URL blocked (SSRF guard rejected it). `error` holds the reason.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/endpoints/{id}:
    delete:
      tags: [endpoints]
      summary: Deactivate an endpoint (soft delete)
      description: >
        Marks the endpoint inactive so it stops receiving new deliveries,
        and cancels its still-queued deliveries (status `pending` or
        `delivering` becomes `canceled`) so nothing sits stuck forever.
        Finished history (delivered and dead rows, and all attempts) is
        kept.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Endpoint deactivated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  active: { type: boolean, enum: [false] }
                  canceledDeliveries:
                    type: integer
                    description: How many queued deliveries were canceled.
        "404":
          description: Endpoint not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    patch:
      tags: [endpoints]
      summary: Reactivate or deactivate an endpoint
      description: >
        `{ "active": true }` reactivates a soft-deleted endpoint so it
        receives new deliveries again, and is what unblocks replaying a
        dead delivery whose endpoint was deactivated (the retry route
        returns 409 until then). Deliveries canceled while it was off stay
        canceled — `canceled` is terminal.
        `{ "active": false }` is DELETE by another name: same deactivation
        and same queued-delivery cancellation.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [active]
              properties:
                active: { type: boolean }
      responses:
        "200":
          description: >
            The updated endpoint (with a masked url). Deactivating also
            returns canceledDeliveries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EndpointWithStats"
                  - type: object
                    properties:
                      canceledDeliveries:
                        type: integer
                        description: Only present when deactivating.
        "400":
          description: Invalid JSON body or missing/!boolean `active`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Endpoint not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/demo/self-check:
    get:
      tags: [demo]
      summary: Identify this app to itself
      description: >
        Returns a nonce stored in this app's own database. Used only by the
        origin-recording probe in src/lib/self-origin.ts: because a request's
        Host header is client-controlled, a candidate origin is trusted for
        the SSRF demo exception only after it answers this route with the
        matching nonce, proving it routes back here rather than to some other
        service listening on loopback.
      responses:
        "200":
          description: This app's self-identification nonce.
          content:
            application/json:
              schema:
                type: object
                properties:
                  nonce: { type: string }

  /api/deliveries/{id}/retry:
    post:
      tags: [deliveries]
      summary: Retry a dead delivery
      description: >
        Re-queues a delivery that has exhausted its 6 attempts (status
        "dead"), resetting it to "pending" so the worker picks it up again
        on its next poll. This gives the delivery exactly ONE more attempt:
        attemptCount is set to 5 (MAX_ATTEMPTS - 1), not reset to zero, so
        the next claim brings it to attempt 6 and a further failure dead-
        letters it again. The response includes `attemptsRemaining: 1`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Delivery re-queued with exactly one attempt remaining.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Delivery"
                  - type: object
                    properties:
                      attemptsRemaining:
                        type: integer
                        enum: [1]
                        description: Always 1 — retry grants exactly one more attempt.
        "404":
          description: Delivery not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: >
            Either the delivery is not in a "dead" state, or its endpoint
            has been deactivated (reactivate the endpoint before retrying).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/stats:
    get:
      tags: [stats]
      summary: Aggregate counters for the dashboard
      responses:
        "200":
          description: Event, delivery, and attempt counters.
          content:
            application/json:
              schema:
                type: object
                properties:
                  totalEvents: { type: integer }
                  acked: { type: integer }
                  deliveriesByStatus:
                    type: object
                    properties:
                      pending: { type: integer }
                      delivering: { type: integer }
                      delivered: { type: integer }
                      dead: { type: integer }
                      canceled: { type: integer }
                  totalAttempts: { type: integer }
                  successRate:
                    type: number
                    description: delivered / (delivered + dead), 0 when nothing has finished yet.
                  attemptsHistogram:
                    type: object
                    description: Count of deliveries that took exactly N attempts (N = 1..6, 6 = "6 or more").
                    additionalProperties: { type: integer }
                  queueDepth:
                    type: integer
                    description: Deliveries currently pending or delivering.
                  workerPaused: { type: boolean }
                  claimBatchSize:
                    type: integer
                    description: >
                      The worker's per-tick claim cap (currently 20), so
                      the UI can say "processing N per tick" without
                      hardcoding it.
                  workerState:
                    type: string
                    enum: [healthy, paused, recovering]
                    description: >
                      "paused" mirrors workerPaused. "recovering" means at
                      least one delivery is currently past ~50s of its 60s
                      claim lease (about to be rescued). Otherwise
                      "healthy".
                  latency:
                    type: object
                    description: Ingest-to-delivered latency over the last 200 delivered deliveries.
                    properties:
                      p50Ms: { type: [integer, "null"] }
                      p95Ms: { type: [integer, "null"] }
                      sampleSize: { type: integer }
                  timeline:
                    type: array
                    description: Last 120s in 5s buckets, oldest first.
                    items:
                      type: object
                      properties:
                        t: { type: integer, description: bucket start, epoch ms }
                        ingested: { type: integer }
                        delivered: { type: integer }
                        failed: { type: integer }

  /api/demo/receiver:
    post:
      tags: [demo]
      summary: Demo webhook receiver
      description: >
        A stand-in "customer" webhook consumer, and the target of the
        built-in "Demo receiver" endpoint that is auto-registered on first
        use. Its behavior is set via /api/demo/receiver/toggle and has
        three modes: "ok" (default) accepts any JSON body and returns 200;
        "fail" returns 500 so retries and dead-lettering can be
        demonstrated; "hang" stalls for 15 seconds, past the worker's 10
        second request timeout, so the worker's fetch aborts and the
        delivery is recorded as a timeout failure before this route ever
        gets to respond 504. Reads X-Event-Id / X-Delivery-Id headers set
        by the worker for consumer-side dedup: it keeps its own log of
        X-Delivery-Id values it has already handled (see GET
        /api/demo/receiver/log) and flags repeats instead of reprocessing
        them, which is what makes the duplicate-delivery demo (see
        POST /api/demo/worker action "crash-after-send") visibly provable.
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: >
            Accepted (mode is "ok"). The normal shape is
            {ok:true, receivedEventId}. When this X-Delivery-Id has already
            been recorded — the redelivery half of a genuine at-least-once
            duplicate, most reliably produced with
            POST /api/demo/worker {"action":"crash-after-send"} — the
            receiver instead returns {ok:true, duplicate:true, note}
            without treating the payload as new.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  receivedEventId: { type: [string, "null"] }
                  duplicate:
                    type: boolean
                    description: Present and true only on a repeat X-Delivery-Id.
                  note:
                    type: string
                    description: >
                      Present only when duplicate is true; a short
                      human-readable explanation of why this request was
                      recognized as a repeat.
        "500":
          description: Simulated failure (mode is "fail").
        "504":
          description: >
            Simulated hang (mode is "hang"). Sent after a 15s stall; the
            worker will already have timed out and recorded the delivery
            as failed by the time this response is sent.

  /api/demo/receiver/log:
    get:
      tags: [demo]
      summary: What the demo consumer saw
      description: >
        The demo receiver's own request log, newest first, including
        duplicates. `duplicate: true` means the receiver had already
        recorded that X-Delivery-Id and skipped reprocessing it — that's
        the consumer's own idempotency check firing, not a server-side
        dedup. It's the clearest evidence that at-least-once delivery can
        hand a consumer the same delivery twice, and that a well-behaved
        consumer shrugs it off instead of double-processing it.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Log entries, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                    items: { $ref: "#/components/schemas/ReceiverLogEntry" }

  /api/demo/run:
    post:
      tags: [demo]
      summary: Start the guided reliability demo
      description: >
        The one-click way to see the whole system work. Clears demo data
        (same effect as POST /api/demo/reset), then drives a scripted
        walkthrough in the background: registers a healthy consumer and a
        flaky one, ingests a realistic order.created event, makes the flaky
        consumer fail, recovers it, and leaves the final delivery timeline
        open for inspection. Poll GET /api/demo/run to follow along and
        drive a narrator panel.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                scenario:
                  type: string
                  enum: [reliability]
                  default: reliability
      responses:
        "202":
          description: Run started.
          content:
            application/json:
              schema:
                type: object
                properties:
                  runId: { type: string }
                  totalSteps: { type: integer }
        "409":
          description: A run is already in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      tags: [demo]
      summary: Poll the guided demo run
      description: >
        Read-only status for the narrator panel driving the guided demo
        started by POST /api/demo/run.
      responses:
        "200":
          description: >
            Current (or most recently finished) run state, or the
            `{active: false}` inactive shape (all other fields null/0) if
            no run has ever been started.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DemoRun" }

  /api/demo/reset:
    post:
      tags: [demo]
      summary: Clear demo data
      description: >
        Deletes events, deliveries, delivery_attempts, the demo receiver's
        log, and any run state, and deactivates/removes every endpoint
        EXCEPT the built-in demo receiver (id `demo-receiver`), which is
        re-registered/repaired rather than deleted, so there's always
        somewhere for the next demo's deliveries to go. Used to give both
        the guided demo (POST /api/demo/run) and the manual demo controls a
        clean slate.
      responses:
        "200":
          description: Counts of what was cleared.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cleared:
                    type: object
                    properties:
                      events: { type: integer }
                      deliveries: { type: integer }
                      endpoints: { type: integer }
                      receiverLog: { type: integer }

  /api/demo/receiver/toggle:
    get:
      tags: [demo]
      summary: Read the demo receiver's current mode
      responses:
        "200":
          description: Current state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReceiverToggle" }
    put:
      tags: [demo]
      summary: Set the demo receiver's mode
      description: >
        Accepts {mode} ("ok" = respond 200, "fail" = respond 500, "hang" =
        stall past the worker's 10s timeout) or the legacy {fail: boolean}.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [mode]
                  properties:
                    mode: { type: string, enum: [ok, fail, hang] }
                - type: object
                  required: [fail]
                  properties:
                    fail: { type: boolean }
      responses:
        "200":
          description: Updated state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReceiverToggle" }
        "400":
          description: Invalid JSON body, or body matches neither the mode nor legacy fail shape.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/demo/worker:
    get:
      tags: [demo]
      summary: Read worker demo-control state
      responses:
        "200":
          description: Current state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkerControl" }
    post:
      tags: [demo]
      summary: Control the delivery worker for demos
      description: >
        "pause"/"resume" stop and restart delivery ticks.

        "crash-next" arms a one-shot simulated worker crash that aborts
        BEFORE the POST goes out: the next claimed delivery is abandoned
        mid-attempt (stuck in `delivering` with an open attempt row, no
        request ever sent) and rescued by the 60-second lease. This proves
        lease recovery — a crash that happens before the consumer ever saw
        anything.

        "crash-after-send" is the other half of the story: also one-shot,
        but the worker crashes AFTER a successful POST and BEFORE recording
        that success. That's the true at-least-once window — the consumer
        already has the webhook, the lease still expires, and the delivery
        goes out a second time with the SAME X-Delivery-Id, producing a
        genuine duplicate delivery (see GET /api/demo/receiver/log and the
        `duplicate` flag on POST /api/demo/receiver).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action:
                  type: string
                  enum: [pause, resume, crash-next, crash-after-send]
      responses:
        "200":
          description: State after applying the action.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkerControl" }
        "400":
          description: Unknown action.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/demo/burst:
    post:
      tags: [demo]
      summary: Queue a burst of demo events
      description: >
        Inserts up to 500 events (type "burst.test") and their deliveries in
        one transaction, for load/drain demos. The worker drains them at its
        bounded rate (20 per 2s tick).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                count:
                  type: integer
                  minimum: 1
                  maximum: 500
                  default: 100
      responses:
        "202":
          description: Burst queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  deliveries: { type: integer }
        "400":
          description: Invalid JSON body or failed schema validation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/deliveries:
    get:
      tags: [deliveries]
      summary: List deliveries
      description: >
        Newest first, joined with event {id, type, source} and endpoint
        {id, name}. The Explorer UI uses status=delivering plus claimedAt to
        render lease-rescue countdowns.
      parameters:
        - name: status
          in: query
          schema:
            { $ref: "#/components/schemas/DeliveryStatus" }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Matching deliveries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/DeliveryListItem" }
        "400":
          description: Invalid status filter.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/openapi:
    get:
      tags: [stats]
      summary: This OpenAPI document
      responses:
        "200":
          description: The OpenAPI 3.1 spec, as YAML.
          content:
            text/yaml:
              schema: { type: string }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        `Authorization: Bearer <api key>`. The demo key seeded on boot is
        `tapi_demo_5f2c9b1e`, source "demo".
  schemas:
    ReceiverToggle:
      type: object
      properties:
        mode: { type: string, enum: [ok, fail, hang] }
        fail:
          type: boolean
          description: Legacy view of mode — true when mode is not "ok".
    WorkerControl:
      type: object
      properties:
        paused: { type: boolean }
        crashNextArmed: { type: boolean }
        crashAfterSendArmed: { type: boolean }
    ReceiverLogEntry:
      type: object
      properties:
        id: { type: string }
        deliveryId: { type: string }
        eventId: { type: string }
        attemptNumber: { type: integer }
        receivedAt: { type: integer, description: epoch ms }
        duplicate:
          type: boolean
          description: >
            True when this X-Delivery-Id had already been recorded by the
            receiver — its own idempotency check, separate from any
            server-side retry/dedup logic.
        mode:
          type: string
          description: Receiver mode in effect when this request arrived (ok, fail, hang).
        responseStatus: { type: integer }
    DemoRun:
      type: object
      description: >
        {active: false} (every other field null/0) when no run has been
        started yet. Otherwise reflects the current, or most recently
        finished, guided demo run.
      properties:
        active: { type: boolean }
        scenario: { type: [string, "null"] }
        stepIndex:
          type: integer
          description: 0-based index of the current step.
        totalSteps: { type: integer }
        title:
          type: [string, "null"]
          description: Short step title, e.g. "Flaky consumer fails".
        narration:
          type: [string, "null"]
          description: One plain-language sentence for the narrator panel.
        status:
          type: [string, "null"]
          enum: [running, waiting, done, failed, null]
        context:
          type: object
          description: Ids the UI follows as the run progresses.
          properties:
            eventId: { type: [string, "null"] }
            healthyEndpointId: { type: [string, "null"] }
            flakyEndpointId: { type: [string, "null"] }
            deliveryIds:
              type: array
              items: { type: string }
        startedAt: { type: [integer, "null"], description: epoch ms }
        updatedAt: { type: [integer, "null"], description: epoch ms }
    Error:
      type: object
      properties:
        error: { type: string }
        issues:
          type: array
          description: Present on 400s from zod validation failures.
          items: { type: object }
    Event:
      type: object
      properties:
        id: { type: string, description: ULID }
        source: { type: string }
        type: { type: string }
        payload: { type: object, additionalProperties: true }
        idempotencyKey: { type: [string, "null"] }
        receivedAt: { type: integer, description: epoch ms }
        ackedAt: { type: [integer, "null"], description: epoch ms, null if unacked }
    Endpoint:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        url: { type: string }
        active: { type: boolean }
        createdAt: { type: integer, description: epoch ms }
    EndpointWithStats:
      type: object
      description: >
        Shape returned by GET /api/endpoints. Unlike Endpoint (the shape
        returned when you register one), `url` here is masked.
      properties:
        id: { type: string }
        name: { type: string }
        url:
          type: string
          description: >
            Masked to origin + first path segment (e.g.
            "https://api.example.com/hooks/…"), except for the built-in
            demo receiver endpoint, which is shown in full.
        urlOrigin:
          type: string
          description: Scheme + host (+ port) of the endpoint URL, unmasked.
        urlMasked:
          type: string
          description: >
            Always the masked form of the endpoint URL (origin + first path
            segment, as described for `url` above) — including for the
            built-in demo receiver, unlike `url`, which shows that one
            endpoint's URL in full.
        active: { type: boolean }
        createdAt: { type: integer, description: epoch ms }
        delivered: { type: integer }
        dead: { type: integer }
        pending: { type: integer, description: pending + delivering }
        successRate:
          type: number
          description: delivered / (delivered + dead) for this endpoint.
    DeliveryStatus:
      type: string
      enum: [pending, delivering, delivered, dead, canceled]
    Delivery:
      type: object
      properties:
        id: { type: string }
        eventId: { type: string }
        endpointId: { type: string }
        status: { $ref: "#/components/schemas/DeliveryStatus" }
        attemptCount: { type: integer }
        nextAttemptAt: { type: integer, description: epoch ms, when the worker will try next }
        claimedAt: { type: [integer, "null"] }
        claimToken: { type: [string, "null"] }
        deliveredAt: { type: [integer, "null"] }
        createdAt: { type: integer }
    DeliveryAttempt:
      type: object
      properties:
        id: { type: string }
        deliveryId: { type: string }
        attemptNumber: { type: integer }
        startedAt: { type: integer, description: epoch ms }
        finishedAt: { type: [integer, "null"] }
        httpStatus: { type: [integer, "null"] }
        error: { type: [string, "null"] }
        requestHeaders:
          type: [object, "null"]
          description: >
            Headers sent with this attempt's POST (e.g. X-Event-Id,
            X-Delivery-Id), shown in the event detail waterfall.
          additionalProperties: { type: string }
        requestBody:
          type: [string, "null"]
          description: The request body sent with this attempt, as sent.
        responseSnippet:
          type: [string, "null"]
          description: >
            A truncated snippet of the response body, for quick inspection
            without storing full responses.
        retryReason:
          type: [string, "null"]
          description: >
            Why this attempt is being retried, e.g. "500 response",
            "timeout", or "abandoned (lease expired)".
        nextAttemptDelayMs:
          type: [integer, "null"]
          description: Backoff delay in ms before the next attempt, if one is scheduled.
    DeliveryDetail:
      allOf:
        - $ref: "#/components/schemas/Delivery"
        - type: object
          properties:
            endpoint:
              oneOf:
                - type: object
                  properties:
                    id: { type: string }
                    name: { type: string }
                    url: { type: string }
                - type: "null"
            attempts:
              type: array
              items: { $ref: "#/components/schemas/DeliveryAttempt" }
    DeliveryListItem:
      description: >
        Shape returned by GET /api/deliveries: a Delivery joined with
        minimal event and endpoint context (used to render the dashboard's
        delivery table without a second round trip per row).
      allOf:
        - $ref: "#/components/schemas/Delivery"
        - type: object
          properties:
            event:
              oneOf:
                - type: object
                  properties:
                    id: { type: string }
                    type: { type: string }
                    source: { type: string }
                - type: "null"
            endpoint:
              oneOf:
                - type: object
                  properties:
                    id: { type: string }
                    name: { type: string }
                - type: "null"
