openapi: 3.1.0
info:
  title: Quinable Partner API
  version: "1.1.0"
  description: >
    The Quinable Partner API lets an agency's external systems (e.g. a scheduling tool) create
    clients and routes/visits in Quinable and receive lifecycle status by signed webhooks.


    **Authentication.** Every request carries a per-agency secret key in the `Authorization`
    header (`Bearer qk_live_…`). The key's prefix is its mode: `qk_live_` acts on real data;
    `qk_test_` is a sandbox — test routes never reach caregivers and move no money.


    **Conventions.** JSON bodies; money is integer **cents**; ids are opaque prefixed strings
    (`route_123`); times of day are `HH:MM` in the route's local timezone. POST endpoints accept
    an `Idempotency-Key` header (replayed for 24h, then pruned). List endpoints use cursor
    pagination (`limit`, `starting_after`). Errors use real HTTP status codes plus a typed body.


    **Operations.** Default rate limit 120 requests/min per key; a 429 carries the typed error
    body and a `Retry-After` header. Every response includes an `X-Request-Id` — quote it in
    support requests. To rotate an API key with zero downtime, issue a second key, switch, then
    revoke the old one; to rotate a webhook signing secret, register a second endpoint (new
    secret), verify deliveries, then delete the old endpoint.
  contact:
    name: Quinable Integrations
    email: integrations@quinable.com
servers:
  - url: https://app.quinable.com/api/partner/v1
    description: Production (use a qk_live_ or qk_test_ key)

security:
  - ApiKeyAuth: []

tags:
  - name: Clients
    description: The people care is delivered to.
  - name: Routes
    description: A day of scheduled care (a route with one or more visits).
  - name: Bids
    description: Caregiver offers on a route (read-only).
  - name: Timesheets
    description: Worked-time records (read-only).
  - name: Events & Webhooks
    description: Lifecycle events and the endpoints they are delivered to.
  - name: Reference
    description: Supporting lookups.

paths:
  /clients:
    get:
      tags: [Clients]
      summary: List clients
      security: [{ ApiKeyAuth: [clients:read] }]
      parameters:
        - { name: external_ref, in: query, schema: { type: string }, description: Return the client with this partner-supplied id. }
        - { name: archived, in: query, schema: { type: boolean, default: false } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: A list of clients.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties: { data: { type: array, items: { $ref: '#/components/schemas/Client' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Clients]
      summary: Create a client
      security: [{ ApiKeyAuth: [clients:write] }]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClientCreate' }
      responses:
        '201':
          description: The created client.
          content: { application/json: { schema: { $ref: '#/components/schemas/Client' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/BusinessRule' }
  /clients/{id}:
    get:
      tags: [Clients]
      summary: Retrieve a client
      security: [{ ApiKeyAuth: [clients:read] }]
      parameters: [ { $ref: '#/components/parameters/ClientId' } ]
      responses:
        '200': { description: The client., content: { application/json: { schema: { $ref: '#/components/schemas/Client' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Clients]
      summary: Update a client
      description: >
        All fields optional. Sending `address` re-runs geocoding so timezone and caregiver-matching
        coordinates stay correct after a move. Sending `external_ref` reassigns the partner's own id
        (releases the old ref, claims the new one).
      security: [{ ApiKeyAuth: [clients:write] }]
      parameters: [ { $ref: '#/components/parameters/ClientId' } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/ClientCreate' } } }
      responses:
        '200': { description: The updated client., content: { application/json: { schema: { $ref: '#/components/schemas/Client' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/BusinessRule' }

  /clients/{id}/archive:
    post:
      tags: [Clients]
      summary: Archive a client (end of care)
      description: Archives the client (never deletes) and releases their external_ref for reuse by a replacement.
      security: [{ ApiKeyAuth: [clients:write] }]
      parameters: [ { $ref: '#/components/parameters/ClientId' } ]
      requestBody:
        required: false
        content: { application/json: { schema: { type: object, properties: { reason: { type: string } } } } }
      responses:
        '200': { description: The archived client., content: { application/json: { schema: { $ref: '#/components/schemas/Client' } } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /routes:
    get:
      tags: [Routes]
      summary: List routes
      security: [{ ApiKeyAuth: [routes:read] }]
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [open, filled, canceled] } }
        - { name: date, in: query, schema: { type: string, format: date } }
        - { name: date_from, in: query, schema: { type: string, format: date } }
        - { name: date_to, in: query, schema: { type: string, format: date } }
        - { name: external_ref, in: query, schema: { type: string } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: A list of routes.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties: { data: { type: array, items: { $ref: '#/components/schemas/Route' } } }
    post:
      tags: [Routes]
      summary: Create a route
      description: >
        Creates a route with its visits in one call and posts it to the caregiver marketplace.
        Recurrence is the caller's responsibility — post one route per date, each with its own
        `Idempotency-Key`.
      security: [{ ApiKeyAuth: [routes:write] }]
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/RouteCreate' } } }
      responses:
        '201': { description: The created route., content: { application/json: { schema: { $ref: '#/components/schemas/Route' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/BusinessRule' }
  /routes/{id}:
    get:
      tags: [Routes]
      summary: Retrieve a route
      description: Returns the route with its visits and the full bid list (`bids.data`) including provider decision info.
      security: [{ ApiKeyAuth: [routes:read] }]
      parameters: [ { $ref: '#/components/parameters/RouteId' } ]
      responses:
        '200': { description: The route., content: { application/json: { schema: { $ref: '#/components/schemas/Route' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
  /routes/{id}/cancel:
    post:
      tags: [Routes]
      summary: Cancel a route
      description: >
        Idempotent. A route with an accepted caregiver or recorded work returns
        `422 route_not_cancelable` — unwind it in the Quinable portal.
      security: [{ ApiKeyAuth: [routes:write] }]
      parameters:
        - { $ref: '#/components/parameters/RouteId' }
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: false
        content: { application/json: { schema: { type: object, properties: { reason: { type: string } } } } }
      responses:
        '200': { description: The canceled route., content: { application/json: { schema: { $ref: '#/components/schemas/Route' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/BusinessRule' }
  /routes/{id}/simulate:
    post:
      tags: [Routes]
      summary: Simulate a route's lifecycle (test mode only)
      description: >
        **Test keys only.** Drives a sandbox route through bid → accept → clock in/out →
        timesheet approved/paid, creating synthetic bids and timesheets and firing the
        `bid.*` / `visit.*` / `timesheet.*` webhooks — so you can exercise your receiver without
        a real caregiver or any money movement. A live key returns `400 not_in_test_mode`.
      security: [{ ApiKeyAuth: [routes:write] }]
      parameters: [ { $ref: '#/components/parameters/RouteId' } ]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                until:
                  type: string
                  enum: [placed, accepted, clocked_in, clocked_out, approved, paid]
                  default: paid
      responses:
        '200': { description: The simulated route., content: { application/json: { schema: { $ref: '#/components/schemas/Route' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /bids/{id}:
    get:
      tags: [Bids]
      summary: Retrieve a bid
      security: [{ ApiKeyAuth: [routes:read] }]
      parameters: [ { name: id, in: path, required: true, schema: { type: string, example: bid_9917 } } ]
      responses:
        '200': { description: The bid., content: { application/json: { schema: { $ref: '#/components/schemas/Bid' } } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /timesheets:
    get:
      tags: [Timesheets]
      summary: List timesheets
      security: [{ ApiKeyAuth: [timesheets:read] }]
      parameters:
        - { name: route, in: query, schema: { type: string, example: route_10312 } }
        - { name: date, in: query, schema: { type: string, format: date }, description: Filter by clock-in calendar date (UTC). }
        - { name: status, in: query, schema: { type: string, enum: [pending, approved, rejected] } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: A list of timesheets.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties: { data: { type: array, items: { $ref: '#/components/schemas/Timesheet' } } }
  /timesheets/{id}:
    get:
      tags: [Timesheets]
      summary: Retrieve a timesheet
      security: [{ ApiKeyAuth: [timesheets:read] }]
      parameters: [ { name: id, in: path, required: true, schema: { type: string, example: ts_4410 } } ]
      responses:
        '200': { description: The timesheet., content: { application/json: { schema: { $ref: '#/components/schemas/Timesheet' } } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /events:
    get:
      tags: [Events & Webhooks]
      summary: List events
      description: Poll fallback and post-outage catch-up for webhooks. Requires any read scope.
      parameters:
        - { name: type, in: query, schema: { type: string, example: bid.accepted } }
        - { name: created_after, in: query, schema: { type: string, format: date-time } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: A list of events.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties: { data: { type: array, items: { $ref: '#/components/schemas/Event' } } }
        '403': { $ref: '#/components/responses/Forbidden' }
  /events/{id}:
    get:
      tags: [Events & Webhooks]
      summary: Retrieve an event
      parameters: [ { name: id, in: path, required: true, schema: { type: string, example: evt_01J5XV9GJ2M4 } } ]
      responses:
        '200': { description: The event., content: { application/json: { schema: { $ref: '#/components/schemas/Event' } } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /webhook_endpoints:
    get:
      tags: [Events & Webhooks]
      summary: List webhook endpoints
      security: [{ ApiKeyAuth: [webhooks:manage] }]
      responses:
        '200':
          description: A list of endpoints (secrets are never re-shown).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties: { data: { type: array, items: { $ref: '#/components/schemas/WebhookEndpoint' } } }
    post:
      tags: [Events & Webhooks]
      summary: Register a webhook endpoint
      description: The signing secret (`qwhsec_…`) is returned once, in this response only.
      security: [{ ApiKeyAuth: [webhooks:manage] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, enabled_events]
              properties:
                url: { type: string, format: uri, description: An https:// URL. Internal/loopback targets are rejected. }
                enabled_events:
                  type: array
                  items: { type: string }
                  description: Event types to receive, or ["*"] for all.
                  example: ["*"]
      responses:
        '201':
          description: The created endpoint, including the one-time secret.
          content: { application/json: { schema: { $ref: '#/components/schemas/WebhookEndpointWithSecret' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
  /webhook_endpoints/{id}:
    delete:
      tags: [Events & Webhooks]
      summary: Delete a webhook endpoint
      security: [{ ApiKeyAuth: [webhooks:manage] }]
      parameters: [ { name: id, in: path, required: true, schema: { type: string, example: we_12 } } ]
      responses:
        '200': { description: Deleted. }
        '404': { $ref: '#/components/responses/NotFound' }
  /webhook_endpoints/{id}/enable:
    post:
      tags: [Events & Webhooks]
      summary: Re-enable a webhook endpoint
      description: >
        Recovers an auto-disabled endpoint (resets its failure counter) without touching its
        signing secret. An endpoint that keeps failing is auto-disabled after sustained failures,
        announced by a `webhook_endpoint.disabled` event; catch up on missed events via
        `GET /events?created_after=…`, fix the receiver, then call this.
      security: [{ ApiKeyAuth: [webhooks:manage] }]
      parameters: [ { name: id, in: path, required: true, schema: { type: string, example: we_12 } } ]
      responses:
        '200': { description: The re-enabled endpoint., content: { application/json: { schema: { $ref: '#/components/schemas/WebhookEndpoint' } } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /professions:
    get:
      tags: [Reference]
      summary: List professions
      description: The `profession_id` values valid for POST /routes.
      responses:
        '200':
          description: A list of professions.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - properties: { data: { type: array, items: { $ref: '#/components/schemas/Profession' } } }

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      description: >
        A per-agency secret key: `Authorization: Bearer qk_live_…` (or `qk_test_…` for sandbox).
        Scopes shown per endpoint: clients:read, clients:write, routes:read, routes:write,
        timesheets:read, webhooks:manage.

  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    StartingAfter:
      name: starting_after
      in: query
      schema: { type: string }
      description: A cursor — the id of the last object from the previous page.
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema: { type: string, maxLength: 191 }
      description: Retrying with the same key replays the original response (24h) and creates nothing.
    ClientId:
      name: id
      in: path
      required: true
      schema: { type: string, example: client_841 }
    RouteId:
      name: id
      in: path
      required: true
      schema: { type: string, example: route_10312 }

  schemas:
    ListEnvelope:
      type: object
      properties:
        object: { type: string, enum: [list] }
        data: { type: array, items: {} }
        has_more: { type: boolean }
    Address:
      type: object
      properties:
        line1: { type: string }
        line2: { type: [string, "null"] }
        city: { type: string }
        state: { type: string, description: Two-letter US state code. }
        zip: { type: [string, "null"] }
    Client:
      type: object
      properties:
        id: { type: string, example: client_841 }
        object: { type: string, enum: [client] }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: [string, "null"] }
        mobile: { type: [string, "null"] }
        address: { $ref: '#/components/schemas/Address' }
        timezone: { type: string, example: America/Detroit }
        external_ref: { type: [string, "null"] }
        archived: { type: boolean }
        livemode: { type: boolean }
        created_at: { type: string, format: date-time }
    ClientCreate:
      type: object
      required: [first_name, last_name, address]
      properties:
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string }
        mobile: { type: string }
        external_ref: { type: string, description: Your own id for this client. Unique per agency. }
        address:
          type: object
          required: [line1, city, state]
          properties:
            line1: { type: string }
            line2: { type: string }
            city: { type: string }
            state: { type: string, description: Two-letter US state code. }
            zip: { type: string }
    Visit:
      type: object
      properties:
        id: { type: string, example: visit_55810 }
        object: { type: string, enum: [visit] }
        client: { type: string, example: client_841 }
        start_time: { type: string, example: "08:00" }
        end_time: { type: string, example: "10:00" }
        pay_type: { type: string, enum: [per_hour, per_visit] }
        hourly_rate: { type: [integer, "null"], description: Cents per hour (when per_hour). }
        visit_amount: { type: [integer, "null"], description: Cents for the visit (when per_visit). }
        mileage_reimbursement: { type: integer }
        parking_reimbursement: { type: integer }
        note: { type: [string, "null"] }
        overnight: { type: boolean }
    VisitCreate:
      type: object
      required: [client, start_time, end_time, pay_type]
      properties:
        client: { type: string, example: client_841 }
        start_time: { type: string, example: "08:00" }
        end_time: { type: string, example: "10:00" }
        pay_type: { type: string, enum: [per_hour, per_visit] }
        hourly_rate: { type: integer, description: Cents per hour; required when pay_type is per_hour. }
        visit_amount: { type: integer, description: Cents; required when pay_type is per_visit. }
        mileage_reimbursement: { type: integer }
        parking_reimbursement: { type: integer }
        note: { type: string }
        overnight: { type: boolean }
    Provider:
      type: object
      description: The bidding caregiver. Contact and the two quality signals let the agency choose.
      properties:
        id: { type: string, example: prov_60091 }
        first_name: { type: string }
        last_name: { type: string }
        mobile: { type: [string, "null"] }
        email: { type: [string, "null"] }
        rating: { type: [number, "null"], description: "Average agency rating, 0-5. Null if never rated." }
        reliability_score: { type: [integer, "null"], description: "Attendance-based quality score, 0-100. Null if none yet." }
    Bid:
      type: object
      properties:
        id: { type: string, example: bid_9917 }
        object: { type: string, enum: [bid] }
        route: { type: string, example: route_10312 }
        status: { type: string, enum: [pending, accepted, rejected, withdrawn, called_off, ncns, agency_canceled] }
        amount: { type: integer, description: Bid amount in cents. }
        currency: { type: string, example: usd }
        provider: { $ref: '#/components/schemas/Provider' }
        placed_at: { type: string, format: date-time }
        status_changed_at: { type: [string, "null"], format: date-time }
    Route:
      type: object
      properties:
        id: { type: string, example: route_10312 }
        object: { type: string, enum: [route] }
        status: { type: string, enum: [open, filled, canceled] }
        name: { type: string }
        description: { type: [string, "null"] }
        date: { type: string, format: date }
        timezone: { type: string, example: America/Detroit }
        start_time: { type: string, example: "08:00" }
        end_time: { type: string, example: "12:30" }
        confirmation_type: { type: string, enum: [agency_confirm, instant_confirm] }
        profession_id: { type: integer }
        total_pay: { type: integer, description: Total provider pay in cents (server-computed). }
        currency: { type: string, example: usd }
        external_ref: { type: [string, "null"] }
        visits: { type: array, items: { $ref: '#/components/schemas/Visit' } }
        bids:
          type: object
          properties:
            pending: { type: integer, description: Count of pending bids. }
            accepted: { oneOf: [ { $ref: '#/components/schemas/Bid' }, { type: "null" } ] }
            data:
              type: array
              description: Full bid list with provider info (populated on GET /routes/{id}).
              items: { $ref: '#/components/schemas/Bid' }
        canceled_at: { type: [string, "null"], format: date-time }
        livemode: { type: boolean }
        created_at: { type: string, format: date-time }
    RouteCreate:
      type: object
      required: [name, date, confirmation_type, profession_id, visits]
      properties:
        name: { type: string }
        date: { type: string, format: date }
        confirmation_type: { type: string, enum: [agency_confirm, instant_confirm] }
        profession_id: { type: integer, description: A value from GET /professions. }
        description: { type: string }
        external_ref: { type: string, description: Your own id for this route. Unique per agency. }
        visits: { type: array, minItems: 1, items: { $ref: '#/components/schemas/VisitCreate' } }
    Timesheet:
      type: object
      properties:
        id: { type: string, example: ts_4410 }
        object: { type: string, enum: [timesheet] }
        route: { type: string }
        visit: { type: string }
        bid: { type: string }
        status: { type: string, enum: [pending, approved, rejected] }
        pay_status: { type: [string, "null"], enum: [null, paying, paid] }
        clock_in_at: { type: [string, "null"], format: date-time }
        clock_out_at: { type: [string, "null"], format: date-time }
        duration_seconds: { type: [integer, "null"] }
        amount: { type: [integer, "null"], description: "Actual caregiver pay for this timesheet, in cents. Final once pay_status is paid." }
        currency: { type: string, example: usd }
    Event:
      type: object
      properties:
        id: { type: string, example: evt_01J5XV9GJ2M4 }
        object: { type: string, enum: [event] }
        type:
          type: string
          enum:
            - route.created
            - route.updated
            - route.filled
            - route.reopened
            - route.canceled
            - bid.placed
            - bid.accepted
            - bid.withdrawn
            - bid.rejected
            - bid.called_off
            - bid.ncns
            - bid.agency_canceled
            - visit.clocked_in
            - visit.clocked_out
            - timesheet.approved
            - timesheet.rejected
            - timesheet.paid
            - timesheet.updated
            - webhook_endpoint.disabled
        created_at: { type: string, format: date-time }
        livemode: { type: boolean }
        data:
          type: object
          properties:
            object: { type: object, description: The affected object as of the event. }
    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, example: we_12 }
        object: { type: string, enum: [webhook_endpoint] }
        url: { type: string, format: uri }
        enabled_events: { type: array, items: { type: string } }
        status: { type: string, enum: [enabled, disabled] }
        consecutive_failures: { type: integer, description: Consecutive delivery failures; resets to 0 on success. }
        disabled_at: { type: [string, "null"], format: date-time, description: Set when the endpoint was auto-disabled. }
        created_at: { type: string, format: date-time }
    WebhookEndpointWithSecret:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpoint'
        - type: object
          properties:
            secret: { type: string, example: qwhsec_xxx, description: The signing secret. Shown once. }
    Profession:
      type: object
      properties:
        id: { type: integer }
        object: { type: string, enum: [profession] }
        name: { type: string, example: LPN }
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            type: { type: string, enum: [invalid_request_error, authentication_error, permission_error, idempotency_error, rate_limit_error, api_error] }
            code: { type: string, example: parameter_missing }
            message: { type: string }
            param: { type: [string, "null"] }

  responses:
    BadRequest:
      description: Malformed request.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Unauthorized:
      description: Missing, unknown, expired, or revoked key.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Forbidden:
      description: The key lacks the required scope.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    NotFound:
      description: No such object in your agency.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    IdempotencyConflict:
      description: Idempotency-Key reused with a different payload, or a concurrent request is in flight.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    BusinessRule:
      description: >
        A business rule was violated. `code` names the rule, e.g. rate_below_market_floor,
        client_archived, visit_gap_too_short, ach_required, insufficient_spending_power,
        external_ref_exists, route_not_cancelable.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

x-webhooks:
  description: >
    Quinable POSTs each event to your registered endpoints. Verify the signature header
    `Quinable-Signature: t=<unix>,v1=<HMAC-SHA256(t + "." + raw_body, endpoint_secret)>` with a
    constant-time compare and reject timestamps older than 5 minutes (Stripe-compatible scheme).
    Respond 2xx within 10s; failures retry with backoff up to ~24h; deliveries are at-least-once
    and may arrive out of order, so dedupe on the event id.
