openapi: 3.0.3
info:
  title: PeptideClients Integration API
  version: 1.0.0
  description: |
    Inbound **write** API for PeptideClients. External storefronts,
    payment processors, and middleware push orders, payments, and
    clients into a single PeptideClients (org, store) using a
    store-pinned `pck_int_*` bearer key.

    Authenticate every request with `Authorization: Bearer pck_int_*`.
    Mint keys at
    `https://app.peptideclients.com/settings/connectors`. Each key
    is pinned to a single `(org_id, store_id)` tuple and carries a
    set of scopes (`orders:write`, `orders:cancel`, `payments:write`,
    `payments:refund`, `clients:write`). Keys can additionally require
    HMAC body signatures (always-on for WooCommerce-format keys).

    Three independent dedupe / safety layers protect every write:

    1. **`Idempotency-Key` header** &mdash; Stripe pattern. Same key +
       same body = cached response (24h TTL). Same key + different
       body = `409 idempotency_conflict`.
    2. **`external_id` entity dedupe** &mdash; `(key_id, external_id)`
       is unique per resource. Re-posting an existing `external_id`
       returns the original internal id with `duplicate: true` and
       does not mutate state.
    3. **`PC-Signature` HMAC** &mdash; `t=<unix>,v1=<hex hmac>` over
       `t + "." + raw_body` using the key's `signing_secret`.
       Required when `require_signature=true`. Timestamp skew &gt; 5
       minutes is rejected.

    The matching landing page, recipes, and copy/paste cookbooks
    (WooCommerce, Zapier, Square, Node, Python) live at
    `https://docs.peptideclients.com/api/integration-recipes`.

  contact:
    name: PeptideClients support
    email: support@peptideclients.com
  license:
    name: Proprietary

externalDocs:
  description: Integration recipes (WooCommerce, Zapier, Node, Python, HMAC, error codes)
  url: https://docs.peptideclients.com/api/integration-recipes

servers:
  - url: https://api.peptideclients.com
    description: Custom domain (Netlify proxy in front of the Supabase edge function)
  - url: https://bxbdqmtldkhgwpnwecox.supabase.co/functions/v1/integration-api
    description: Supabase Edge Function fallback (works without DNS)

tags:
  - name: orders
    description: Create, look up, and cancel orders pushed in from external storefronts.
  - name: payments
    description: Record additional payments and refunds against existing orders.
  - name: clients
    description: Upsert CRM clients (typically called implicitly via `POST /v1/orders`).
  - name: webhooks
    description: Native receivers for third-party platforms (WooCommerce today).
  - name: meta
    description: Health and OpenAPI discovery (no auth required).

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key (pck_int_*)

  parameters:
    pathId:
      name: id
      in: path
      required: true
      description: Internal PeptideClients UUID.
      schema: { type: string, format: uuid }
    pathExternalId:
      name: external_id
      in: path
      required: true
      description: |
        The `external_id` value you assigned at create time. URL-encode
        slashes if your platform uses them (e.g. `shopify%2F12345`).
      schema: { type: string, maxLength: 255 }
    idempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Stripe-pattern replay key, 1&ndash;255 chars. Same value with
        the same body returns the cached response within 24h. Same
        value with a different body returns `409 idempotency_conflict`.
      schema: { type: string, maxLength: 255 }
    pcSignature:
      name: PC-Signature
      in: header
      required: false
      description: |
        HMAC-SHA256 body signature, in the form
        `t=<unix>,v1=<hex hmac-sha256(t + "." + raw_body, signing_secret)>`.
        Required when the key's `require_signature=true`. Timestamp
        skew greater than 5 minutes is rejected with
        `401 signature_expired`.
      schema:
        type: string
        example: t=1779249317,v1=7d4f...c0a1
    wcSignature:
      name: X-WC-Webhook-Signature
      in: header
      required: true
      description: |
        Base64-encoded `hmac_sha256(raw_body, signing_secret)` &mdash;
        the native WooCommerce signature scheme.
      schema: { type: string }
    wcTopic:
      name: X-WC-Webhook-Topic
      in: header
      required: true
      description: WooCommerce event topic (`order.created` or `order.updated`).
      schema:
        type: string
        enum: [order.created, order.updated]
    wcResource:
      name: X-WC-Webhook-Resource
      in: header
      required: false
      description: Resource type sent by WooCommerce (`order`).
      schema: { type: string }
    wcEvent:
      name: X-WC-Webhook-Event
      in: header
      required: false
      description: Event name sent by WooCommerce (`created`, `updated`).
      schema: { type: string }

  schemas:
    Address:
      type: object
      description: Billing or shipping address. All fields optional.
      properties:
        name:          { type: string, nullable: true, example: "Jane Buyer" }
        company:       { type: string, nullable: true, example: "Acme Peptides LLC" }
        address_line1: { type: string, nullable: true, example: "742 Evergreen Terrace" }
        address_line2: { type: string, nullable: true, example: "Suite 4" }
        city:          { type: string, nullable: true, example: "Springfield" }
        state:         { type: string, nullable: true, example: "OR" }
        postal_code:   { type: string, nullable: true, example: "97403" }
        country:       { type: string, nullable: true, example: "US",
                         description: "ISO 3166-1 alpha-2." }
        phone:         { type: string, nullable: true, example: "+15551234567" }

    ClientPayload:
      type: object
      description: |
        Inbound client (buyer) shape. Used both as the body of
        `POST /v1/clients` and as the `client` block inside
        `POST /v1/orders`.

        Resolution priority when a client is included on an order:
        1. `(key.org_id, key.store_id, external_id)` mapping hit.
        2. Case-insensitive email match in the same `(org, store)`.
        3. Otherwise create a new client with `source_*` badge.
      properties:
        external_id:
          type: string
          nullable: true
          maxLength: 255
          description: Stable id from your system. Strongly recommended.
          example: "shopify-cust-987"
        email:
          type: string
          format: email
          nullable: true
          example: "jane.buyer@example.com"
        display_name: { type: string, nullable: true, example: "Jane Buyer" }
        first_name:   { type: string, nullable: true, example: "Jane" }
        last_name:    { type: string, nullable: true, example: "Buyer" }
        company_name: { type: string, nullable: true, example: "Acme Peptides LLC" }
        phone:        { type: string, nullable: true, example: "+15551234567" }
        client_type:
          type: string
          nullable: true
          enum: [retail, wholesale, research, clinic, distributor]
          example: "retail"
        tags:
          type: array
          items: { type: string }
          example: ["wholesale", "shopify-import"]
        metadata:
          type: object
          additionalProperties: true
          description: Free-form JSONB. Stored verbatim on the client row.
          example: { source: "shopify", "tier": "gold" }
        billing_address:  { $ref: '#/components/schemas/Address' }
        shipping_address: { $ref: '#/components/schemas/Address' }

    LineItem:
      type: object
      required: [description, quantity, unit_price_cents]
      properties:
        description:
          type: string
          example: "BPC-157 5mg (vial)"
        quantity:
          type: number
          format: float
          minimum: 0
          example: 2
        unit_price_cents:
          type: integer
          format: int64
          minimum: 0
          description: Per-unit price in the order's currency, in minor units.
          example: 4999
        discount_cents:
          type: integer
          format: int64
          minimum: 0
          default: 0
          description: Line-level discount, in minor units.
          example: 0
        tax_rate_pct:
          type: number
          format: float
          minimum: 0
          maximum: 100
          default: 0
          description: Tax rate applied to this line, percentage points (0&ndash;100).
          example: 0
        metadata:
          type: object
          additionalProperties: true
          description: Free-form per-line JSONB (sku, variant id, etc.).
          example: { sku: "BPC-157-5mg", lot: "L25-0421" }

    PaymentInput:
      type: object
      required: [amount_cents]
      properties:
        external_id:
          type: string
          nullable: true
          maxLength: 255
          description: |
            Stable payment id from your system. Used for entity-level
            dedupe just like `orders.external_id`.
          example: "shopify-txn-456"
        amount_cents:
          type: integer
          format: int64
          minimum: 0
          description: Payment amount in the order's currency, in minor units.
          example: 11997
        method:
          type: string
          nullable: true
          description: Free-form. Common values&colon; `card`, `ach`, `wire`, `cash`, `check`, `crypto`, `other`.
          example: "card"
        provider:
          type: string
          nullable: true
          description: Human-readable processor label (`Stripe`, `Stripe via Shopify`, `Authorize.net`).
          example: "Stripe via Shopify"
        provider_payment_id:
          type: string
          nullable: true
          description: Processor-side reference (Stripe payment_intent id, etc.).
          example: "pi_abc123"
        reference:
          type: string
          nullable: true
          description: Free-form memo (`order #12345`, check number, etc.).
          example: "order #12345"
        paid_at:
          type: string
          format: date-time
          nullable: true
          description: When the payment cleared in your system. Defaults to `now()`.
          example: "2026-05-21T12:34:56Z"

    OrderRequest:
      type: object
      required: [external_id, lines]
      properties:
        external_id:
          type: string
          maxLength: 255
          description: |
            Stable order id from your system. Required. Used for
            entity-level dedupe via `(key_id, external_id)`.
          example: "shopify-12345"
        currency:
          type: string
          minLength: 3
          maxLength: 3
          default: "USD"
          description: ISO 4217 currency code. Must match the store's default.
          example: "USD"
        client: { $ref: '#/components/schemas/ClientPayload' }
        billing_address:
          allOf:
            - $ref: '#/components/schemas/Address'
          description: Snapshotted onto the order row. Falls back to `client.billing_address`.
        shipping_address:
          allOf:
            - $ref: '#/components/schemas/Address'
          description: Snapshotted onto the order row. Falls back to `client.shipping_address`.
        lines:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/LineItem' }
        discount_cents:
          type: integer
          format: int64
          minimum: 0
          default: 0
          description: Order-level discount, in minor units.
          example: 0
        tax_cents:
          type: integer
          format: int64
          minimum: 0
          default: 0
          description: Pre-computed order-level tax, in minor units.
          example: 0
        shipping_cents:
          type: integer
          format: int64
          minimum: 0
          default: 0
          example: 999
        proposal_message:
          type: string
          nullable: true
          maxLength: 4000
          description: Customer-facing note rendered on the order/proposal PDF.
          example: "Thanks for your order! Tracking will arrive separately."
        internal_notes:
          type: string
          nullable: true
          maxLength: 4000
          description: Operator-only note. Never sent to the buyer.
          example: "Imported from Shopify"
        metadata:
          type: object
          additionalProperties: true
          description: Free-form JSONB. Stored verbatim on the order row.
          example: { source: "shopify", "shopify_order_number": "1042" }
        payment: { $ref: '#/components/schemas/PaymentInput' }

    OrderEnvelope:
      type: object
      description: |
        Canonical response from every order write endpoint. `status`
        is the *resulting* order status after the write&colon; `paid`,
        `partially_paid`, `invoiced`, `cancelled`, or `refunded`.
      properties:
        id:                { type: string, format: uuid }
        number:            { type: string, example: "INV-2026-0042" }
        status:            { type: string, example: "paid",
                             enum: [draft, invoiced, partially_paid, paid, cancelled, refunded] }
        total_cents:       { type: integer, format: int64, example: 11997 }
        amount_paid_cents: { type: integer, format: int64, example: 11997 }
        client_id:         { type: string, format: uuid, nullable: true }
        external_id:       { type: string, nullable: true, example: "shopify-12345" }
        payment_id:        { type: string, format: uuid, nullable: true,
                             description: Populated when an inline `payment` was recorded. }
        duplicate:
          type: boolean
          default: false
          description: |
            `true` iff the request was deduped on the `external_id`
            mapping and no state was mutated. The returned `id` is
            the original row.

    ClientResponse:
      type: object
      properties:
        id:      { type: string, format: uuid }
        created: { type: boolean,
                   description: "`true` if a new row was inserted, `false` if an existing client was matched." }

    CancelRequest:
      type: object
      properties:
        reason:
          type: string
          nullable: true
          maxLength: 1000
          description: Free-form reason. Stored on the audit trail and returned to outbound webhooks.
          example: "Customer requested cancellation"

    CancelResponse:
      type: object
      properties:
        id:               { type: string, format: uuid }
        already_cancelled: { type: boolean, default: false }

    RefundRequest:
      type: object
      properties:
        reason:
          type: string
          nullable: true
          maxLength: 1000
          example: "Refunded via Stripe dashboard"

    Health:
      type: object
      properties:
        ok: { type: boolean, example: true }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Stable, snake_case machine-readable code.
          example: "external_id_required"
        detail:
          type: string
          nullable: true
          description: Human-readable explanation. Safe to surface to your operator.
          example: "`external_id` is required on POST /v1/orders."

  responses:
    BadRequest:
      description: Malformed JSON or schema violation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            invalidJson:
              summary: Request body is not valid JSON
              value: { error: "invalid_json", detail: "Body is not valid JSON." }

    Unauthorized:
      description: Missing, invalid, revoked, expired, or unsigned key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missing:
              summary: No Authorization header
              value: { error: "missing_authorization", detail: "Authorization header is required." }
            invalidKey:
              summary: Bearer token is not a known `pck_int_*` key
              value: { error: "invalid_api_key", detail: "API key not found or mismatched scope." }
            revoked:
              summary: Key was revoked in the Connectors UI
              value: { error: "integration_key_revoked", detail: "This key was revoked on 2026-04-01." }
            expired:
              summary: Key passed its `expires_at`
              value: { error: "integration_key_expired", detail: "This key expired on 2026-05-01." }
            signatureInvalid:
              summary: "`PC-Signature` HMAC did not verify"
              value: { error: "signature_invalid", detail: "HMAC mismatch." }
            signatureExpired:
              summary: "`PC-Signature` `t` skew > 5 minutes"
              value: { error: "signature_expired", detail: "Timestamp skew exceeds 5 minutes." }

    Forbidden:
      description: Key is valid but lacks the required scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            insufficientScope:
              value: { error: "insufficient_scope", detail: "This key lacks the `orders:write` scope." }

    NotFound:
      description: Row not found in this key's `(org, store)` scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            notFound:
              value: { error: "not_found", detail: "No order matches that id under this key's store." }

    Conflict:
      description: Idempotency-Key replay with a different body.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            idempotencyConflict:
              value:
                error: "idempotency_conflict"
                detail: "This Idempotency-Key was previously used with a different request body."

    Unprocessable:
      description: Semantic validation failed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            externalIdRequired:
              value: { error: "external_id_required", detail: "`external_id` is required." }
            linesRequired:
              value: { error: "lines_required", detail: "At least one line item is required." }
            partialRefundNotSupported:
              value:
                error: "partial_refund_not_supported"
                detail: "v1 refunds full payment amounts only. Issue a new payment with a negative amount via the UI."

    RateLimited:
      description: Per-key rate limit exceeded.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds the caller should wait before retrying.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            rateLimited:
              value: { error: "rate_limited", detail: "Too many requests. Retry after the indicated number of seconds." }

security:
  - bearerAuth: []

paths:
  /integrations/v1/health:
    get:
      tags: [meta]
      security: []
      summary: Liveness probe (unauthenticated)
      description: |
        Returns `{ "ok": true }`. Use this to confirm DNS, Netlify
        proxy, and the underlying Supabase edge function are reachable
        before debugging auth/signature issues.
      responses:
        '200':
          description: Service is reachable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }
              examples:
                ok:
                  value: { ok: true }

  /integrations/v1/openapi.json:
    get:
      tags: [meta]
      security: []
      summary: Return this OpenAPI document as JSON
      description: Machine-readable spec served by the edge function itself.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  /integrations/v1/orders:
    post:
      tags: [orders]
      summary: Create an order (optionally with an inline payment)
      description: |
        Creates an order in `status='invoiced'`. If a `payment` block
        is included, the payment is recorded atomically and the order
        flips to `paid` (full) or `partially_paid` (less than total).
        Zero-dollar orders with no payment block flip straight to
        `paid`.

        **Required scope&colon;** `orders:write`. Optional but strongly
        recommended&colon; `Idempotency-Key` header.

        See the [Status reference](https://docs.peptideclients.com/api/integration-recipes#status-reference)
        table in the recipes book for the full inbound &rarr; status
        truth table.
      externalDocs:
        description: Quickstart, WooCommerce, Zapier, Node, Python recipes
        url: https://docs.peptideclients.com/api/integration-recipes
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
        - $ref: '#/components/parameters/pcSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderRequest' }
            examples:
              paidShopifyOrder:
                summary: Shopify checkout that was paid in full
                value:
                  external_id: "shopify-12345"
                  currency: "USD"
                  client:
                    external_id: "shopify-cust-987"
                    email: "jane.buyer@example.com"
                    display_name: "Jane Buyer"
                    first_name: "Jane"
                    last_name: "Buyer"
                    phone: "+15551234567"
                    client_type: "retail"
                    tags: ["shopify-import"]
                    billing_address:
                      address_line1: "742 Evergreen Terrace"
                      city: "Springfield"
                      state: "OR"
                      postal_code: "97403"
                      country: "US"
                    shipping_address:
                      address_line1: "742 Evergreen Terrace"
                      city: "Springfield"
                      state: "OR"
                      postal_code: "97403"
                      country: "US"
                  lines:
                    - description: "BPC-157 5mg (vial)"
                      quantity: 2
                      unit_price_cents: 4999
                      metadata: { sku: "BPC-157-5mg" }
                    - description: "TB-500 5mg (vial)"
                      quantity: 1
                      unit_price_cents: 5999
                      metadata: { sku: "TB-500-5mg" }
                  shipping_cents: 999
                  proposal_message: "Thanks for your order!"
                  internal_notes: "Imported from Shopify"
                  metadata: { source: "shopify", shopify_order_number: "1042" }
                  payment:
                    external_id: "shopify-txn-456"
                    amount_cents: 11997
                    method: "card"
                    provider: "Stripe via Shopify"
                    provider_payment_id: "pi_abc123"
                    reference: "order #1042"
                    paid_at: "2026-05-21T12:34:56Z"
              invoiceOnly:
                summary: Net-30 wholesale order, no payment yet
                value:
                  external_id: "wholesale-portal-2026-091"
                  currency: "USD"
                  client:
                    external_id: "wholesale-cust-44"
                    email: "ops@clinic-example.com"
                    company_name: "Clinic Example, P.A."
                    client_type: "wholesale"
                  lines:
                    - description: "Semaglutide 5mg (vial)"
                      quantity: 25
                      unit_price_cents: 12999
                      metadata: { sku: "SEMA-5mg" }
                  shipping_cents: 0
                  internal_notes: "Net-30. PO# 4571."
              zeroDollar:
                summary: Sample shipment (zero-dollar &rarr; paid)
                value:
                  external_id: "sample-2026-014"
                  currency: "USD"
                  client:
                    email: "rd@biotech-example.com"
                    client_type: "research"
                  lines:
                    - description: "GLP-1 reference standard (1mg)"
                      quantity: 1
                      unit_price_cents: 0
                      metadata: { sku: "GLP1-REF-1mg" }
      responses:
        '200':
          description: Order created (or duplicate replay).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderEnvelope' }
              examples:
                created:
                  summary: Fresh order, paid in full
                  value:
                    id: "8c1f3e8c-9b1c-4e36-95ff-1c3a8d7d2f01"
                    number: "INV-2026-0042"
                    status: "paid"
                    total_cents: 11997
                    amount_paid_cents: 11997
                    client_id: "1b1e9d40-0f3a-4f3c-8e3a-2d4e1f6b9a44"
                    external_id: "shopify-12345"
                    payment_id: "f0a93b9a-1234-4cba-9d8f-bcde0123abcd"
                    duplicate: false
                duplicate:
                  summary: Repeat of an existing `external_id`
                  value:
                    id: "8c1f3e8c-9b1c-4e36-95ff-1c3a8d7d2f01"
                    duplicate: true
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /integrations/v1/orders/by-external/{external_id}:
    get:
      tags: [orders]
      summary: Look up an order by your `external_id`
      description: |
        Returns the canonical envelope if `(key_id, external_id)` has
        a mapping. No additional scope required &mdash; any key may
        look up orders it created.
      parameters:
        - $ref: '#/components/parameters/pathExternalId'
      responses:
        '200':
          description: Found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderEnvelope' }
              examples:
                found:
                  value:
                    id: "8c1f3e8c-9b1c-4e36-95ff-1c3a8d7d2f01"
                    number: "INV-2026-0042"
                    status: "paid"
                    total_cents: 11997
                    amount_paid_cents: 11997
                    client_id: "1b1e9d40-0f3a-4f3c-8e3a-2d4e1f6b9a44"
                    external_id: "shopify-12345"
                    payment_id: "f0a93b9a-1234-4cba-9d8f-bcde0123abcd"
                    duplicate: false
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /integrations/v1/orders/{id}/payments:
    post:
      tags: [payments]
      summary: Record an additional payment against an existing order
      description: |
        Atomically inserts a payment row and recomputes
        `orders.amount_paid_cents` + `orders.status` (`invoiced` &rarr;
        `partially_paid` &rarr; `paid`).

        **Required scope&colon;** `payments:write`.
      parameters:
        - $ref: '#/components/parameters/pathId'
        - $ref: '#/components/parameters/idempotencyKey'
        - $ref: '#/components/parameters/pcSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentInput' }
            examples:
              partialPayment:
                summary: Second of two installments
                value:
                  external_id: "stripe-pi_def456"
                  amount_cents: 5998
                  method: "card"
                  provider: "Stripe"
                  provider_payment_id: "pi_def456"
                  reference: "balance for order INV-2026-0042"
                  paid_at: "2026-05-22T09:14:00Z"
      responses:
        '200':
          description: Payment recorded; envelope reflects the new status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderEnvelope' }
              examples:
                paidInFull:
                  value:
                    id: "8c1f3e8c-9b1c-4e36-95ff-1c3a8d7d2f01"
                    number: "INV-2026-0042"
                    status: "paid"
                    total_cents: 11997
                    amount_paid_cents: 11997
                    client_id: "1b1e9d40-0f3a-4f3c-8e3a-2d4e1f6b9a44"
                    external_id: "shopify-12345"
                    payment_id: "5cdc2e1a-77c4-4f0b-8b1d-2b7e3f5a99c1"
                    duplicate: false
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /integrations/v1/orders/{id}/cancel:
    post:
      tags: [orders]
      summary: Cancel an order
      description: |
        Flips the order to `cancelled`. Idempotent &mdash; cancelling
        an already-cancelled order returns `already_cancelled: true`
        and does not mutate state.

        **Required scope&colon;** `orders:cancel`.
      parameters:
        - $ref: '#/components/parameters/pathId'
        - $ref: '#/components/parameters/idempotencyKey'
        - $ref: '#/components/parameters/pcSignature'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CancelRequest' }
            examples:
              withReason:
                value: { reason: "Customer requested cancellation" }
      responses:
        '200':
          description: Cancelled (or already-cancelled).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CancelResponse' }
              examples:
                cancelled:
                  value: { id: "8c1f3e8c-9b1c-4e36-95ff-1c3a8d7d2f01", already_cancelled: false }
                alreadyCancelled:
                  value: { id: "8c1f3e8c-9b1c-4e36-95ff-1c3a8d7d2f01", already_cancelled: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /integrations/v1/payments/{id}/refund:
    post:
      tags: [payments]
      summary: Mark a payment as refunded (full refund only in v1)
      description: |
        Flags the payment row as refunded, decrements
        `orders.amount_paid_cents` by the full payment amount, and
        recomputes `orders.status` (`paid` &rarr; `partially_paid`
        &rarr; `refunded`/`invoiced`).

        Partial refunds are not yet supported by this endpoint &mdash;
        attempts return `422 partial_refund_not_supported`. Issue a
        negative-amount payment from the PeptideClients UI for partial
        amounts.

        **Required scope&colon;** `payments:refund`.
      parameters:
        - $ref: '#/components/parameters/pathId'
        - $ref: '#/components/parameters/idempotencyKey'
        - $ref: '#/components/parameters/pcSignature'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RefundRequest' }
            examples:
              refundedViaStripe:
                value: { reason: "Refunded via Stripe dashboard" }
      responses:
        '200':
          description: Refund recorded; envelope reflects the new status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderEnvelope' }
              examples:
                fullRefund:
                  value:
                    id: "8c1f3e8c-9b1c-4e36-95ff-1c3a8d7d2f01"
                    number: "INV-2026-0042"
                    status: "refunded"
                    total_cents: 11997
                    amount_paid_cents: 0
                    client_id: "1b1e9d40-0f3a-4f3c-8e3a-2d4e1f6b9a44"
                    external_id: "shopify-12345"
                    payment_id: "f0a93b9a-1234-4cba-9d8f-bcde0123abcd"
                    duplicate: false
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /integrations/v1/clients:
    post:
      tags: [clients]
      summary: Upsert a CRM client
      description: |
        Matches by `external_id` first, then case-insensitive `email`
        within the same `(org, store)`, then creates a new row.
        Returned `created` reflects which branch ran.

        Note&colon; for the common case &mdash; "create a client at the
        same time as their first order" &mdash; just embed the client
        block in `POST /v1/orders`. This endpoint exists so you can
        push CRM updates that aren't tied to a new order.

        **Required scope&colon;** `clients:write`.
      parameters:
        - $ref: '#/components/parameters/idempotencyKey'
        - $ref: '#/components/parameters/pcSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClientPayload' }
            examples:
              shopifyCustomerSync:
                summary: Sync a Shopify customer record
                value:
                  external_id: "shopify-cust-987"
                  email: "jane.buyer@example.com"
                  display_name: "Jane Buyer"
                  first_name: "Jane"
                  last_name: "Buyer"
                  phone: "+15551234567"
                  client_type: "retail"
                  tags: ["shopify-import", "vip"]
                  metadata: { lifetime_orders: 14, lifetime_revenue_cents: 1289900 }
                  billing_address:
                    address_line1: "742 Evergreen Terrace"
                    city: "Springfield"
                    state: "OR"
                    postal_code: "97403"
                    country: "US"
      responses:
        '200':
          description: Upsert succeeded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClientResponse' }
              examples:
                created:
                  value: { id: "1b1e9d40-0f3a-4f3c-8e3a-2d4e1f6b9a44", created: true }
                matched:
                  value: { id: "1b1e9d40-0f3a-4f3c-8e3a-2d4e1f6b9a44", created: false }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /integrations/v1/webhook/woocommerce:
    post:
      tags: [webhooks]
      summary: Native WooCommerce webhook receiver (paste-and-go)
      description: |
        Accepts WooCommerce's native Order payload shape and
        signature scheme. Configure the webhook in WordPress admin:

        - **WooCommerce &rarr; Settings &rarr; Advanced &rarr; Webhooks**
        - **Topic**&colon; `Order created` (repeat for `Order updated`)
        - **Delivery URL**&colon;
          `https://api.peptideclients.com/integrations/v1/webhook/woocommerce`
        - **Secret**&colon; your key's `signing_secret_plain` from the
          Connectors settings (shown once at create time).
        - **API version**&colon; `WP REST API Integration v3`

        The key MUST have `payload_format='woocommerce'` and
        `require_signature=true`. The handler dispatches on
        `X-WC-Webhook-Topic`, translates WC's order shape into our
        Standard Document Format (SDF), and runs the same idempotency
        +`external_id` dedupe as `POST /v1/orders`.

        Step-by-step setup, secret rotation, and a "send test ping"
        walkthrough live in the
        [WooCommerce paste-and-go](https://docs.peptideclients.com/api/integration-recipes#woocommerce-paste-and-go)
        recipe.
      externalDocs:
        description: WooCommerce paste-and-go (step-by-step recipe)
        url: https://docs.peptideclients.com/api/integration-recipes#woocommerce-paste-and-go
      parameters:
        - $ref: '#/components/parameters/wcSignature'
        - $ref: '#/components/parameters/wcTopic'
        - $ref: '#/components/parameters/wcResource'
        - $ref: '#/components/parameters/wcEvent'
      requestBody:
        required: true
        description: WooCommerce's native `Order` schema (we accept it verbatim).
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              description: WooCommerce Order object; see WC REST API v3 documentation.
            examples:
              wooOrderCreated:
                summary: Truncated WooCommerce `order.created` payload
                value:
                  id: 12345
                  number: "12345"
                  status: "processing"
                  currency: "USD"
                  total: "119.97"
                  customer_id: 987
                  billing:
                    first_name: "Jane"
                    last_name: "Buyer"
                    email: "jane.buyer@example.com"
                    phone: "+15551234567"
                    address_1: "742 Evergreen Terrace"
                    city: "Springfield"
                    state: "OR"
                    postcode: "97403"
                    country: "US"
                  shipping:
                    first_name: "Jane"
                    last_name: "Buyer"
                    address_1: "742 Evergreen Terrace"
                    city: "Springfield"
                    state: "OR"
                    postcode: "97403"
                    country: "US"
                  line_items:
                    - name: "BPC-157 5mg (vial)"
                      product_id: 401
                      sku: "BPC-157-5mg"
                      quantity: 2
                      price: "49.99"
                      total: "99.98"
                    - name: "TB-500 5mg (vial)"
                      product_id: 402
                      sku: "TB-500-5mg"
                      quantity: 1
                      price: "59.99"
                      total: "59.99"
                  shipping_lines:
                    - method_title: "USPS Priority"
                      total: "9.99"
                  date_paid_gmt: "2026-05-21T12:34:56"
                  transaction_id: "pi_abc123"
                  payment_method: "stripe"
                  payment_method_title: "Credit card (Stripe)"
      responses:
        '200':
          description: Received and translated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderEnvelope' }
              examples:
                received:
                  value:
                    id: "8c1f3e8c-9b1c-4e36-95ff-1c3a8d7d2f01"
                    number: "INV-2026-0042"
                    status: "paid"
                    total_cents: 11997
                    amount_paid_cents: 11997
                    client_id: "1b1e9d40-0f3a-4f3c-8e3a-2d4e1f6b9a44"
                    external_id: "woocommerce-12345"
                    payment_id: "f0a93b9a-1234-4cba-9d8f-bcde0123abcd"
                    duplicate: false
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
