openapi: 3.1.0
info:
  title: SchedStack API
  version: "v0"
  summary: Durable scheduled HTTP delivery — schedule once or on a cron, fire within 100–300 ms, retried until it lands.
  description: |
    **Spec-first: this document is the source of truth.** SDKs, reference docs, the interactive
    explorer, and server-side request validation all generate from it.

    ## Conventions (apply to every endpoint)
    - **Base URL:** `https://api.schedstack.com` · all paths under `/v1`.
    - **Auth:** `Authorization: Bearer <api_key>`. Keys are scoped to a `(project, mode)`. A `sk_test_…`
      key only ever touches test data; `sk_live_…` only live. (Human/dashboard auth is a *separate*
      cookie-session control plane — not part of this public API. F22.)
    - **Versioning:** major in the path (`/v1`); backward-compatible field additions ship continuously.
      Breaking changes are gated behind a **date-pinned** `Sched-Version: 2026-06-14` header (account
      default applies if omitted), à la Stripe.
    - **Idempotency:** every mutating request SHOULD send `Idempotency-Key: <uuid>`. We store the first
      response for 24 h keyed by `(project, mode, key)` (so a test-mode and live-mode request that share a
      key never cross-replay). "Same request" is determined by a **fingerprint of method + path + body**:
      a retry with the same key + same fingerprint replays the
      stored response; same key + **different** body → `409 idempotency_key_reuse`. A concurrent second
      request with an in-flight key → `409` (insert-before-process lock). F24. *(This is API-request
      idempotency — distinct from a delivery's `idempotency_key` echoed to the receiver.)*
    - **Pagination:** cursor-based. Lists return `{ object: "list", data: [...], has_more, next_cursor }`.
      Pass `?cursor=` + `?limit=` (default 20, max 100). The cursor is opaque and composite
      `(scheduled_for|created_at, id)` so reschedules can't make a page skip/duplicate. F24.
    - **Errors:** typed envelope (see `Error`). Every response carries `Sched-Request-Id`; include it in
      support requests. Correlate to your own logs via the same id echoed on outbound deliveries.
    - **Limits:** request body ≤ **1 MB**; delivery `body` ≤ **256 KB**; total custom headers ≤ **16 KB**
      (`413 payload_too_large`). Minimum schedulable delay ~**1 s** (shorter is best-effort, excluded
      from the accuracy SLO). F25.
  contact:
    name: SchedStack
  x-logo:
    altText: SchedStack

servers:
  - url: https://api.schedstack.com/v1
    description: Managed (production)

security:
  - apiKey: []

tags:
  - name: Schedules
    description: Create and manage one-shot & recurring schedules (the unit you create).
  - name: Deliveries
    description: Individual occurrences fired from a schedule (read + replay).
  - name: Attempts
    description: Per–HTTP-try records (the technical trail behind a delivery).

paths:
  /schedules:
    post:
      tags: [Schedules]
      operationId: createSchedule
      summary: Create a schedule (one-shot or recurring)
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/Version'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScheduleCreate' }
      responses:
        '201':
          description: Created
          headers: { Sched-Request-Id: { $ref: '#/components/headers/RequestId' } }
          content: { application/json: { schema: { $ref: '#/components/schemas/Schedule' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
    get:
      tags: [Schedules]
      operationId: listSchedules
      summary: List schedules
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - { in: query, name: state, schema: { $ref: '#/components/schemas/ScheduleState' } }
        - { in: query, name: kind, schema: { $ref: '#/components/schemas/ScheduleKind' } }
        - { in: query, name: 'metadata[key]', schema: { type: string }, description: Filter by a metadata label. }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { $ref: '#/components/schemas/ScheduleList' } } }
        '400': { $ref: '#/components/responses/BadRequest' }

  /schedules/{id}:
    parameters: [ { $ref: '#/components/parameters/ScheduleId' } ]
    get:
      tags: [Schedules]
      operationId: getSchedule
      summary: Retrieve a schedule
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Schedule' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Schedules]
      operationId: updateSchedule
      summary: Update a schedule (metadata / target / policy)
      description: |
        Patch mutable non-timing fields (endpoint, method, ttl, retry policy, metadata). **Timing is a
        separate, side-effectful operation** — use `POST /schedules/{id}/reschedule`. Timing fields in a
        PATCH body are ignored.
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        content: { application/json: { schema: { $ref: '#/components/schemas/ScheduleUpdate' } } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Schedule' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /schedules/{id}/reschedule:
    parameters: [ { $ref: '#/components/parameters/ScheduleId' } ]
    post:
      tags: [Schedules]
      operationId: rescheduleSchedule
      summary: Reschedule a schedule (timing only)
      description: |
        Move the fire time. Provide exactly one of `delay`, `fire_at`, `local_fire_at` (+`timezone`), or
        `cron` (+`timezone`, which also updates the recurrence). Rescheduling **earlier** re-arms correctly
        (the engine un-claims + re-claims at the new time, F05). Best-effort against an already-firing
        delivery.
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        content: { application/json: { schema: { $ref: '#/components/schemas/ScheduleReschedule' } } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Schedule' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /schedules/{id}/pause:
    parameters: [ { $ref: '#/components/parameters/ScheduleId' } ]
    post:
      tags: [Schedules]
      operationId: pauseSchedule
      summary: Pause a schedule (reversible)
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Schedule' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
  /schedules/{id}/resume:
    parameters: [ { $ref: '#/components/parameters/ScheduleId' } ]
    post:
      tags: [Schedules]
      operationId: resumeSchedule
      summary: Resume a paused schedule
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Schedule' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
  /schedules/{id}/cancel:
    parameters: [ { $ref: '#/components/parameters/ScheduleId' } ]
    post:
      tags: [Schedules]
      operationId: cancelSchedule
      summary: Cancel a schedule
      description: Terminal. Stops future occurrences and cancels the pending next delivery. History is retained. Best-effort against an already-firing delivery (returns its actual state).
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Schedule' } } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /endpoints:
    post:
      tags: [Endpoints]
      operationId: createEndpoint
      summary: Create an endpoint profile
      description: 'A reusable destination + delivery policy. Schedules reference it by `endpoint_id` (or keep an inline endpoint). Scoped to (project, mode).'
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        content: { application/json: { schema: { $ref: '#/components/schemas/EndpointWrite' } } }
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/Endpoint' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
    get:
      tags: [Endpoints]
      operationId: listEndpoints
      summary: List endpoint profiles
      parameters:
        - { $ref: '#/components/parameters/Cursor' }
        - { $ref: '#/components/parameters/Limit' }
        - { in: query, name: include_archived, required: false, schema: { type: boolean, default: false } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/EndpointList' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }

  /endpoints/{id}:
    parameters: [ { $ref: '#/components/parameters/EndpointId' } ]
    get:
      tags: [Endpoints]
      operationId: getEndpoint
      summary: Retrieve an endpoint profile
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Endpoint' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Endpoints]
      operationId: updateEndpoint
      summary: Update an endpoint profile (bumps version)
      description: 'Sparse patch. Each successful edit bumps `version`, which future deliveries snapshot (live-link across deliveries; frozen per delivery).'
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        content: { application/json: { schema: { $ref: '#/components/schemas/EndpointWrite' } } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Endpoint' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /endpoints/{id}/archive:
    parameters: [ { $ref: '#/components/parameters/EndpointId' } ]
    post:
      tags: [Endpoints]
      operationId: archiveEndpoint
      summary: Archive an endpoint profile (soft)
      description: 'Archived profiles stay queryable for audit but cannot back new schedules. Archiving one with active dependent schedules returns `409 profile_in_use` unless an explicit follow-up is given (dependents check lands with the schedule reference).'
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Endpoint' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /schedules/{id}/deliveries:
    parameters: [ { $ref: '#/components/parameters/ScheduleId' }, { $ref: '#/components/parameters/Cursor' }, { $ref: '#/components/parameters/Limit' } ]
    get:
      tags: [Deliveries]
      operationId: listScheduleDeliveries
      summary: List a schedule's deliveries (occurrences)
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryList' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }

  /deliveries:
    get:
      tags: [Deliveries]
      operationId: listDeliveries
      summary: List deliveries
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - { in: query, name: status, schema: { $ref: '#/components/schemas/DeliveryStatus' } }
        - { in: query, name: schedule_id, schema: { type: string } }
        - { in: query, name: created_after, schema: { type: string, format: date-time } }
        - { in: query, name: created_before, schema: { type: string, format: date-time } }
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryList' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }

  /deliveries/{id}:
    parameters: [ { $ref: '#/components/parameters/DeliveryId' } ]
    get:
      tags: [Deliveries]
      operationId: getDelivery
      summary: Retrieve a delivery
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Delivery' } } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /deliveries/{id}/replay:
    parameters: [ { $ref: '#/components/parameters/DeliveryId' } ]
    post:
      tags: [Deliveries]
      operationId: replayDelivery
      summary: Replay a delivery
      description: Re-deliver a dead-lettered/expired/succeeded delivery. Creates a NEW delivery with `replay_of` set to the original.
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/Delivery' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /deliveries/{id}/attempts:
    parameters: [ { $ref: '#/components/parameters/DeliveryId' }, { $ref: '#/components/parameters/Cursor' }, { $ref: '#/components/parameters/Limit' } ]
    get:
      tags: [Attempts]
      operationId: listDeliveryAttempts
      summary: List a delivery's HTTP attempts
      responses:
        '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/AttemptList' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

# What WE send to the customer's endpoint — described spec-first so receivers can verify (F21).
webhooks:
  delivery:
    post:
      summary: An outbound delivery SchedStack sends to your `endpoint`
      description: |
        SchedStack POSTs your `body` with your custom headers, plus the headers below.
        **Verify the signature** with the schedule's signing secret before trusting the request.
      parameters:
        - { in: header, name: Sched-Delivery-Id, required: true, schema: { type: string }, description: 'dlv_… — the occurrence.' }
        - { in: header, name: Sched-Attempt, required: true, schema: { type: integer }, description: Attempt number (≥1; >1 means a retry of the same delivery — dedupe on Idempotency-Key). }
        - { in: header, name: Idempotency-Key, required: true, schema: { type: string }, description: Stable per occurrence (same across retries) — dedupe on this. }
        - { in: header, name: Sched-Timestamp, required: true, schema: { type: integer }, description: Unix seconds when signed; reject if too old (replay window). }
        - { in: header, name: Sched-Signature, required: true, schema: { type: string }, description: 'HMAC-SHA256, `t=<ts>,v1=<sig>[,v1=<sig>]`. Multiple v1 values during secret rotation — accept if ANY verifies. Signed payload: `{timestamp}.{delivery_id}.{attempt}.{METHOD}.{path}.{body}`.' }
        - { in: header, name: traceparent, schema: { type: string }, description: W3C trace context for correlation. }
      responses:
        '2xx': { description: Success — SchedStack marks the delivery succeeded. }
        default: { description: Any non-2xx / timeout — retried per the schedule's backoff policy until success, max_attempts, or TTL. }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: '`Authorization: Bearer sk_live_… | sk_test_…`'

  parameters:
    ScheduleId: { in: path, name: id, required: true, schema: { type: string, example: sch_01J9ZK3F8Q } }
    DeliveryId: { in: path, name: id, required: true, schema: { type: string, example: dlv_01J9ZK3F8Q } }
    EndpointId: { in: path, name: id, required: true, schema: { type: string, example: ep_01J9ZK3F8Q } }
    Cursor: { in: query, name: cursor, required: false, schema: { type: string }, description: Opaque pagination cursor from a prior `next_cursor`. }
    Limit: { in: query, name: limit, required: false, schema: { type: integer, default: 20, minimum: 1, maximum: 100 } }
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      schema: { type: string, maxLength: 255 }
      description: Safe-retry key; stored 24 h. Strongly recommended on all mutating calls.
    Version:
      in: header
      name: Sched-Version
      required: false
      schema: { type: string, example: '2026-06-14' }
      description: Date-pinned API version. Defaults to the account's pinned version.

  headers:
    RequestId:
      description: Echoes the request's correlation id.
      schema: { type: string, example: req_01J9ZK3F8Q }

  responses:
    BadRequest: { description: Malformed request., content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Unauthorized: { description: Missing/invalid API key., content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    NotFound: { description: No such resource in this (project, mode)., content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Conflict: { description: Idempotency reuse, or operation invalid for current state (e.g. in_flight)., content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    PayloadTooLarge: { description: Body/headers exceed limits., content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    UnprocessableEntity: { description: 'Well-formed but rejected (e.g. endpoint resolves to a blocked address — SSRF; invalid cron; sub-floor delay).', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }

  schemas:
    ScheduleKind: { type: string, enum: [one_shot, recurring] }
    ScheduleState: { type: string, enum: [active, paused, canceled, completed] }
    DeliveryStatus:
      type: string
      enum: [scheduled, claimed, retry_scheduled, succeeded, dead_letter, expired, paused, canceled]
      description: No `in_flight` — `claimed` spans the send (F03).

    RetryPolicy:
      type: object
      properties:
        max_attempts: { type: integer, default: 8, minimum: 1, maximum: 50 }
        strategy: { type: string, enum: [exponential], default: exponential }
        base: { type: string, default: "5s", description: 'Backoff base as a duration string (Go syntax, e.g. "5s"); 0–24h.' }
        factor: { type: number, default: 2, minimum: 1, maximum: 100 }
        max: { type: string, default: "1h", description: 'Backoff cap as a duration string (e.g. "1h"); 0–168h.' }
        jitter: { type: boolean, default: true }

    ScheduleCreate:
      type: object
      required: [endpoint]
      description: 'Provide exactly one timing: `delay`, `fire_at`, or `local_fire_at`+`timezone` (one-shot), or `cron`+`timezone` (recurring).'
      properties:
        endpoint: { type: string, format: uri, description: 'HTTPS destination. Validated against SSRF rules at fire time (F11). Provide this OR endpoint_id.', example: 'https://api.acme.com/hooks/billing' }
        endpoint_id: { type: string, description: 'Reference an endpoint profile (ep_…) instead of an inline endpoint. Each delivery resolves the profile at materialization (live-link), frozen for that delivery. Mutually exclusive with `endpoint`. When set, transport (incl. `method`) comes from the profile — sending `method` too is rejected (`400 method_on_profile`).', example: ep_01J9ZK3F8Q }
        method: { type: string, enum: [POST, PUT, PATCH, GET, DELETE], default: POST, description: 'Inline endpoints only. Omit when using `endpoint_id` (the profile owns method).' }
        headers: { type: object, additionalProperties: { type: string }, description: 'Custom headers. CR/LF + reserved (`Host`, hop-by-hop, `Sched-*`, `Idempotency-Key`) forbidden. Encrypted at rest.' }
        body: { type: string, maxLength: 262144, description: Request body (≤256 KB). Encrypted at rest. }
        delay: { type: string, description: 'Relative one-shot, e.g. "24h", "30m", "90s". Min ~1s.', example: 24h }
        fire_at: { type: string, format: date-time, description: Absolute one-shot instant (strict RFC3339, with offset/Z). }
        local_fire_at: { type: string, description: 'Offset-less wall-clock one-shot ("2026-07-01T09:00:00"), interpreted in `timezone` with DST gap/fold handling matching the engine.', example: '2026-07-01T09:00:00' }
        cron: { type: string, description: Recurring cron expression., example: '0 9 * * 1-5' }
        timezone: { type: string, description: 'IANA tz for `cron` or `local_fire_at` (DST-correct).', example: America/New_York }
        ttl: { type: string, description: 'Deliver-by deadline; past it the delivery `expire`s. Also time-caps retries.', example: 1h }
        idempotency_key: { type: string, description: Echoed to the receiver (per-occurrence; auto-generated for recurring if omitted). }
        retry_policy: { $ref: '#/components/schemas/RetryPolicy' }
        rate_limit: { type: object, nullable: true, properties: { per_second: { type: number } }, description: Max delivery rate to this endpoint. }
        jitter: { type: string, description: 'Spread fires by ±this window (opt-in; default off).', example: 30s }
        metadata: { type: object, additionalProperties: { type: string }, description: Up to 50 user labels (filterable). }

    ScheduleUpdate:
      type: object
      description: 'Metadata / target / policy only. All fields optional. Timing is a separate op (`POST /schedules/{id}/reschedule`); timing fields here are ignored.'
      properties:
        endpoint: { type: string, format: uri }
        method: { type: string, enum: [POST, PUT, PATCH, GET, DELETE] }
        ttl: { type: string }
        retry_policy: { $ref: '#/components/schemas/RetryPolicy' }
        metadata: { type: object, additionalProperties: { type: string } }

    ScheduleReschedule:
      type: object
      description: 'Move the fire time. Provide exactly one of `delay`, `fire_at`, `local_fire_at`(+`timezone`), or `cron`(+`timezone`).'
      properties:
        delay: { type: string, description: 'Relative, e.g. "2h". Min ~1s.' }
        fire_at: { type: string, format: date-time, description: Absolute instant (strict RFC3339). }
        local_fire_at: { type: string, description: Offset-less wall-clock + `timezone` (DST-correct). }
        cron: { type: string, description: New recurrence (also updates the schedule's cron). }
        timezone: { type: string, description: IANA tz for `cron` or `local_fire_at`. }

    Schedule:
      type: object
      properties:
        id: { type: string, example: sch_01J9ZK3F8Q }
        object: { type: string, const: schedule }
        mode: { type: string, enum: [test, live] }
        kind: { $ref: '#/components/schemas/ScheduleKind' }
        state: { $ref: '#/components/schemas/ScheduleState' }
        endpoint: { type: string, format: uri }
        method: { type: string }
        header_keys: { type: array, items: { type: string }, description: Header NAMES only — values are encrypted and never returned. }
        fire_at: { type: string, format: date-time, nullable: true }
        cron: { type: string, nullable: true }
        timezone: { type: string, nullable: true }
        next_fire_at: { type: string, format: date-time, nullable: true }
        next_runs: { type: array, items: { type: string, format: date-time }, description: Preview of the next 5 fire instants (DST/jitter-accurate). }
        ttl: { type: string, nullable: true }
        retry_policy: { $ref: '#/components/schemas/RetryPolicy' }
        metadata: { type: object, additionalProperties: { type: string } }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Delivery:
      type: object
      properties:
        id: { type: string, example: dlv_01J9ZK3F8Q }
        object: { type: string, const: delivery }
        schedule_id: { type: string, nullable: true }
        mode: { type: string, enum: [test, live] }
        status: { $ref: '#/components/schemas/DeliveryStatus' }
        scheduled_for: { type: string, format: date-time }
        deadline: { type: string, format: date-time, nullable: true }
        next_fire_at: { type: string, format: date-time, nullable: true }
        attempt_count: { type: integer }
        last_status_code: { type: integer, nullable: true }
        idempotency_key: { type: string }
        replay_of: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        finalized_at: { type: string, format: date-time, nullable: true }

    Attempt:
      type: object
      properties:
        id: { type: string, example: att_01J9ZK3F8Q }
        object: { type: string, const: attempt }
        delivery_id: { type: string }
        attempt_no: { type: integer }
        outcome: { type: string, enum: [success, retryable, terminal] }
        status_code: { type: integer, nullable: true }
        fired_at: { type: string, format: date-time }
        finished_at: { type: string, format: date-time, nullable: true }
        egress_ms: { type: integer, nullable: true }
        error: { type: string, nullable: true }

    ListMeta:
      type: object
      properties:
        object: { type: string, const: list }
        has_more: { type: boolean }
        next_cursor: { type: string, nullable: true }
    EndpointWrite:
      type: object
      required: [url]
      description: 'Create/patch body for an endpoint profile. PATCH is sparse (omitted fields unchanged).'
      properties:
        url: { type: string, format: uri, description: 'HTTPS destination. SSRF-validated at connect time (F11) — a profile URL is not pre-trusted.' }
        method: { type: string, enum: [POST, PUT, PATCH, GET, DELETE], default: POST }
        headers: { type: object, additionalProperties: { type: string }, description: 'Static headers (encrypted at rest). Our reserved headers still overwrite.' }
        timeout: { type: string, description: 'Per-endpoint HTTP timeout, duration string (≤1h).', example: 10s }
        retry_policy: { $ref: '#/components/schemas/RetryPolicy' }
        retry_budget: { type: object, properties: { rate: { type: number, minimum: 0 }, burst: { type: number, minimum: 0 } }, description: 'Per-endpoint retry token bucket (cost protection); overrides the global default.' }
        rate_limit: { type: object, properties: { per_second: { type: number, minimum: 0 } } }
        breaker_policy: { type: object, description: 'Per-endpoint circuit-breaker tuning; overrides the global default. When present, ALL fields are required — a partial policy is rejected (422), since an omitted open/probe window would re-open the breaker immediately. Durations are strings.', required: [threshold, base_open, max_open, probe_timeout], properties: { threshold: { type: integer, minimum: 1, maximum: 1000 }, base_open: { type: string, example: 30s }, max_open: { type: string, example: 5m }, probe_timeout: { type: string, example: 30s } } }
        metadata: { type: object, additionalProperties: { type: string } }

    Endpoint:
      type: object
      properties:
        id: { type: string, example: ep_01J9ZK3F8Q }
        object: { type: string, const: endpoint }
        mode: { type: string, enum: [test, live] }
        url: { type: string, format: uri }
        method: { type: string }
        header_keys: { type: array, items: { type: string }, description: Header names (values never returned). }
        timeout: { type: string, nullable: true }
        retry_policy: { $ref: '#/components/schemas/RetryPolicy' }
        retry_budget: { type: object, nullable: true }
        rate_limit: { type: object, nullable: true }
        breaker_policy: { type: object, nullable: true }
        metadata: { type: object, additionalProperties: { type: string } }
        version: { type: integer, description: Bumped on every edit; snapshotted by future deliveries. }
        archived: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    EndpointList:
      allOf: [ { $ref: '#/components/schemas/ListMeta' }, { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Endpoint' } } } } ]

    ScheduleList:
      allOf: [ { $ref: '#/components/schemas/ListMeta' }, { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Schedule' } } } } ]
    DeliveryList:
      allOf: [ { $ref: '#/components/schemas/ListMeta' }, { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Delivery' } } } } ]
    AttemptList:
      allOf: [ { $ref: '#/components/schemas/ListMeta' }, { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Attempt' } } } } ]

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message]
          properties:
            type:
              type: string
              enum: [invalid_request_error, authentication_error, rate_limit_error, idempotency_error, not_found_error, api_error]
            code: { type: string, example: url_blocked, description: 'Stable machine code (e.g. url_blocked, invalid_cron, sub_floor_delay, payload_too_large, idempotency_key_reuse, in_flight).' }
            message: { type: string }
            param: { type: string, nullable: true, description: The offending field, if applicable. }
            request_id: { type: string }
