openapi: 3.1.0
info:
  title: Lifted ShipKit API
  version: 1.0.0
  summary: The open shipping toolkit ? secured by Lifted Payments 3-D Secure.
  description: |
    Lifted ShipKit buys multi-carrier shipping labels (USPS, UPS, FedEx and more via
    EasyPost) and takes card payment authenticated by **Lifted Payments 3-D
    Secure**, so fraud-chargeback liability shifts to the issuer.

    ## Authentication
    Every `/api/*` route **except** `GET /api/health` and the HMAC-verified
    `POST /api/webhook/easypost` requires an API key in the `ShipKit-Api-Key`
    header:

    ```
    ShipKit-Api-Key: sk_live_your_secret_key_here
    ```

    Keys carry a **scope**: **secret** keys (`sk_live_?`/`sk_test_?`) reach every
    route and are server/admin-only, while **publishable** keys (`pk_live_?`/`pk_test_?`)
    are browser-safe and the backend confines them to the customer flow (address
    verify, rate, payment session/status, purchase-label) ? any secret-only action
    returns `403`. The mode token is `_live_` (production) or `_test_`. Mint keys with
    the `shipkitKeygen` Gradle task / `com.lifted.shipkit.KeygenKt` CLI (`--publishable`
    for a `pk_?` key), via the admin-gated `/api/keys` endpoints (`"scope"`:
    `"secret"` default or `"publishable"`), or ? for managed deployments ? via the
    control-plane-only `/api/config/keys` routes (Bearer
    `SHIPKIT_MANAGED_CONFIG_TOKEN`). Only a SHA-256 hash of each key is stored;
    the full key is shown once at creation and can never be retrieved again.

    ## Money
    All amounts ? carrier rates and charges ? are **strings** (e.g. `"8.42"`).
    Parse them as decimals; never float- or string-compare.

    ## Errors
    Failures return a non-2xx status with `{ "error": "<message>" }`. A feature
    whose credentials are not configured returns `503`.
  contact:
    name: Lifted Holdings
    url: https://liftedholdings.com/payments
    email: support@liftedholdings.com
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT
externalDocs:
  description: Take live payments through a 3-D Secure merchant account.
  url: https://liftedholdings.com/payments
servers:
  - url: http://localhost:8080
    description: Local self-host default
  - url: https://api.liftedholdings.com/shipkit/v1
    description: ShipKit Managed edge

security:
  - ShipKitApiKey: []

tags:
  - name: Health
  - name: Addresses
  - name: Shipments
  - name: Payment
  - name: Bulk & international
  - name: EndShipper
  - name: Configuration
  - name: Labels
  - name: Webhooks
  - name: API keys

paths:
  /api/health:
    get:
      tags: [Health]
      summary: Liveness / readiness probe (no API key required).
      operationId: getHealth
      security: []
      responses:
        "200":
          description: Service is up; reports which features are configured.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, examples: ["ok"] }
                  shipping: { type: boolean }
                  payments: { type: boolean }
              example: { status: "ok", shipping: true, payments: true }

  /api/address/verify:
    post:
      tags: [Addresses]
      summary: Verify and normalize a delivery address.
      operationId: verifyAddress
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Address" }
            example:
              name: "Ada Lovelace"
              street1: "1600 Pennsylvania Ave NW"
              city: "Washington"
              state: "DC"
              zip: "20500"
              country: "US"
      responses:
        "200":
          description: Normalized address plus any correction messages.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyResponse" }
              example:
                verified: true
                address:
                  name: "Ada Lovelace"
                  company: ""
                  street1: "1600 PENNSYLVANIA AVE NW"
                  street2: ""
                  city: "WASHINGTON"
                  state: "DC"
                  zip: "20500-0003"
                  country: "US"
                  phone: ""
                residential: false
                errors: []
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/shipment/create:
    post:
      tags: [Shipments]
      summary: Create a shipment and return carrier rates.
      operationId: createShipment
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShipmentRequest" }
            example:
              from:
                name: "Analytical Engines"
                street1: "1 Foundry St"
                city: "Atlanta"
                state: "GA"
                zip: "30301"
                country: "US"
                phone: "4045551234"
              to:
                name: "Ada Lovelace"
                street1: "1600 Pennsylvania Ave NW"
                city: "Washington"
                state: "DC"
                zip: "20500"
                country: "US"
              parcel:
                weight_oz: 32
                length_in: 10
                width_in: 8
                height_in: 4
      responses:
        "200":
          description: |
            The shipment id and its rates. An **empty `rates` with a populated
            `messages`** is a carrier error ? surface `messages[].message`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShipmentResponse" }
              example:
                id: "shp_1a2b3c"
                rates:
                  - { id: "rate_aaa", carrier: "USPS", service: "Priority", rate: "8.42", currency: "USD", delivery_days: 2 }
                  - { id: "rate_bbb", carrier: "UPS", service: "Ground", rate: "11.07", currency: "USD", delivery_days: 3 }
                messages: []
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/shipment/smartrates:
    post:
      tags: [Shipments]
      summary: Delivery-date-accurate SmartRates for a shipment.
      operationId: smartRates
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [shipment_id]
              properties:
                shipment_id: { type: string, examples: ["shp_1a2b3c"] }
      responses:
        "200":
          description: SmartRates with delivery-date percentiles.
          content:
            application/json:
              schema:
                type: object
                properties:
                  shipment_id: { type: string }
                  rates: { type: array, items: { type: object, additionalProperties: true } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/shipment/buy:
    post:
      tags: [Shipments]
      summary: Buy a label directly for a chosen rate (payment handled out of band).
      operationId: buyLabelDirect
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [shipment_id]
              properties:
                shipment_id: { type: string, examples: ["shp_1a2b3c"] }
                rate_id: { type: string, examples: ["rate_aaa"] }
                end_shipper_id: { type: string, examples: ["es_123"] }
      responses:
        "200":
          description: The purchased label.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BoughtLabel" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/payment/session:
    post:
      tags: [Payment]
      summary: Open a Lifted Payments 3-D Secure payment session for a rate.
      description: |
        The charge **amount is computed on the server** from the rate plus the
        configured markup ? any client-sent amount is ignored. The canonical
        hosted-form key is `form_url`.
      operationId: createPaymentSession
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentSessionRequest" }
            example: { shipment_id: "shp_1a2b3c", rate_id: "rate_aaa" }
      responses:
        "200":
          description: The session and the hosted 3-D Secure form URL.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentSessionResponse" }
              example:
                session_id: "sess_9f8e7d"
                form_url: "https://dashboard.maverickpayments.com/hosted-fields/sess_9f8e7d"
                amount: "9.48"
                subtotal: "9.48"
                surcharge: { enabled: false, amount: "0.00", percentage: "3.75", fixed_cents: 15 }
                currency: "USD"
                expires_at: "2026-07-12T18:05:00Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/GatewayError" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/payment/status/{sessionId}:
    get:
      tags: [Payment]
      summary: Poll the server-verified payment / 3-D Secure status.
      description: |
        Status is confirmed against Lifted Payments ? never from return-URL
        params. A label may be bought **only** when `status == "approved"` **and**
        `three_ds.liability_shift == true`.
      operationId: getPaymentStatus
      parameters:
        - { $ref: "#/components/parameters/SessionIdCamel" }
      responses:
        "200":
          description: The verified status and 3-D Secure result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentStatusResponse" }
              example:
                status: "approved"
                three_ds: { eci: "05", liability_shift: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502": { $ref: "#/components/responses/GatewayError" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/payment/purchase-label/{sessionId}:
    post:
      tags: [Payment]
      summary: Buy the label for an approved session (idempotent).
      description: |
        Re-verifies the charge server-side, then buys the label. **Idempotent** ?
        a second call returns the same label. Refuses unless approved with a
        3-D Secure liability shift, and refuses a session past its charge window.
      operationId: purchaseLabel
      parameters:
        - { $ref: "#/components/parameters/SessionIdCamel" }
      responses:
        "200":
          description: The purchased label.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LabelResponse" }
              example:
                label_url: "https://easypost-files.s3.amazonaws.com/files/postage_label/label.png"
                qr_code_url: "https://easypost-files.s3.amazonaws.com/files/postage_label/qr.png"
                tracking_code: "9400100000000000000000"
                carrier: "USPS"
                service: "Priority"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: Payment not approved with a 3-D Secure liability shift.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: A label purchase is already in progress for this session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "410":
          description: The payment session has expired (past its charge window).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502": { $ref: "#/components/responses/GatewayError" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/payment/save-card:
    post:
      tags: [Payment]
      summary: Save a card on file (frictionless / card-on-file ? account-gated).
      description: |
        Vault a card for one-tap repeat checkout (Maverick customer vault). The body
        carries a Maverick **Hosted Fields card token**, never a PAN. Returns a
        `vault_id` (`"{customerId}:{cardId}"`) to charge later via
        `POST /api/payment/charge-saved-card`.

        **Account-gated and secret-key only.** Available ONLY when the deployment's
        tier armed frictionless mode (a Lifted merchant/managed or enterprise
        custom-dev account). Self-host / bring-your-own-payments is **refused**
        (`403`) ? forced 3-D Secure is its only mode. Never reachable by a
        publishable (`pk_?`) key.
      operationId: saveCardOnFile
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SaveCardRequest" }
            example:
              card_token: "hf_card_tok_abc"
              billing: { first_name: "Ada", last_name: "Lovelace", address1: "1 Analytical Way", city: "London", state: "CA", zip: "90210", country: "US" }
      responses:
        "200":
          description: The saved card's vault reference.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SavedCardResponse" }
              example: { success: true, vault_id: "cust_1:card_1", card_token: "vtok_1" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Frictionless / card-on-file is not enabled for this account (forced 3-D Secure).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502": { $ref: "#/components/responses/GatewayError" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/payment/charge-saved-card:
    post:
      tags: [Payment]
      summary: One-tap purchase on a saved card (frictionless ? account-gated).
      description: |
        Price the rate **on the server** (a client-sent amount is ignored), charge
        the saved card **frictionlessly** (`3ds:false`), and buy the label ? the
        one-tap repeat-checkout path. **Idempotent** on the required
        `idempotency_key`: the card is charged and the label bought at most once, and
        the card is charged before the label ? a buy that fails after a successful
        charge is retried WITHOUT re-charging.

        **Account-gated and secret-key only** (as with `save-card`). Self-host / BYO
        is refused with `403`.
      operationId: chargeSavedCard
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChargeSavedCardRequest" }
            example:
              vault_id: "cust_1:card_1"
              shipment_id: "shp_1a2b3c"
              rate_id: "rate_aaa"
              idempotency_key: "order-8842"
      responses:
        "200":
          description: The purchased label plus the charge reference and amount.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SavedCardChargeResponse" }
              example:
                label_url: "https://easypost-files.s3.amazonaws.com/files/postage_label/label.png"
                qr_code_url: null
                tracking_code: "9400100000000000000000"
                carrier: "USPS"
                service: "Priority"
                transaction_id: "txn_s"
                amount: "9.48"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: The saved-card charge was not approved.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Frictionless mode is not enabled for this account (forced 3-D Secure).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: A saved-card purchase for this idempotency key is already in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502": { $ref: "#/components/responses/GatewayError" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/batch/create:
    post:
      tags: [Bulk & international]
      summary: Create and buy a batch of shipments.
      operationId: createBatch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [shipment_ids]
              properties:
                shipment_ids: { type: array, items: { type: string }, examples: [["shp_a", "shp_b"]] }
      responses:
        "200":
          description: The created batch.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
              example: { id: "batch_1", state: "created", num_shipments: 2 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/scanform/create:
    post:
      tags: [Bulk & international]
      summary: Generate a SCAN form (manifest) for a batch.
      operationId: createScanForm
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [batch_id]
              properties:
                batch_id: { type: string, examples: ["batch_1"] }
      responses:
        "200":
          description: The SCAN form.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
              example: { id: "sf_1", form_url: "https://easypost-files.s3.amazonaws.com/scanform.pdf", status: "created" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/customs/create:
    post:
      tags: [Bulk & international]
      summary: Create customs info + items for an international shipment.
      operationId: createCustoms
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
            example:
              customs_signer: "Ada Lovelace"
              contents_type: "merchandise"
              eel_pfc: "NOEEI 30.37(a)"
              items:
                - { description: "Cotton t-shirt", quantity: 2, value: 20.0, weight: 8.0, hs_tariff_number: "610910", origin_country: "US" }
      responses:
        "200":
          description: The created customs info.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/endshipper/get:
    get:
      tags: [EndShipper]
      summary: Read the configured EndShipper identity.
      operationId: getEndShipper
      responses:
        "200":
          description: The EndShipper id (if configured).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  end_shipper_id: { type: [string, "null"] }
                  configured: { type: boolean }
              example: { success: true, end_shipper_id: "es_123", configured: true }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/endshipper/create:
    post:
      tags: [EndShipper]
      summary: Register / update the EndShipper identity.
      operationId: createEndShipper
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Address" }
            example:
              name: "Analytical Engines"
              company: "Analytical Engines LLC"
              phone: "4045551234"
              street1: "1 Foundry St"
              city: "Atlanta"
              state: "GA"
              zip: "30301"
              country: "US"
      responses:
        "200":
          description: The registered EndShipper.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  end_shipper_id: { type: string }
                  name: { type: [string, "null"] }
                  company: { type: [string, "null"] }
              example: { success: true, end_shipper_id: "es_123" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/config/tier:
    get:
      tags: [Configuration]
      summary: Read the adoption tier and pricing model (self-host / merchant / managed).
      description: |
        The honest three-tier story: the declared `tier`, the tier-2 buyer
        `surcharge` (3.75% + $0.15 + $25/mo), the tier-3 `shipping_markup`, and
        the payments environment / card-entry model. Non-secret ? a publishable
        (`pk_?`) key may read it.
      operationId: getTierConfig
      responses:
        "200":
          description: The tier and pricing configuration.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TierConfig" }
              example:
                tier: "merchant"
                label: "Lifted 3-D Secure merchant account"
                surcharge: { enabled: true, amount: "0.00", percentage: "3.75", fixed_cents: 15, monthly_fee_usd: 25 }
                shipping_markup: { percentage_markup: 12.0, fixed_fee_cents: 50 }
                payments: { configured: true, environment: "live", card_entry: "hosted_fields" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/config/markup:
    get:
      tags: [Configuration]
      summary: Read the merchant markup applied on top of carrier rates.
      operationId: getMarkup
      responses:
        "200":
          description: The current markup.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Markup" }
              example: { percentage_markup: 12.0, fixed_fee_cents: 50, fixed_fee_dollars: 0.5 }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Configuration]
      summary: Update the merchant markup.
      description: |
        Accepts either body shape; both land in the same stored markup:

        - **Native** ? `{percentage_markup, fixed_fee_cents}`, the shape the
          `GET` reports.
        - **Managed** ? `{markup_pct, fixed_fee, card_fee_pct?}` as percent +
          **dollars**, the shape a hosting control plane typically holds.
          Bounds: `markup_pct` 0?100, `fixed_fee` $0?$1000. The optional
          `card_fee_pct` (0?100, accepted with either shape) is validated and
          echoed for forward compatibility but **not persisted** ? the buyer
          card surcharge is a fixed, environment-driven toggle
          (`SHIPKIT_SURCHARGE_ENABLED`). Mixing the two shapes in one body is
          rejected with `400`.

        Authenticate with a secret `sk_?` key, **or** ? when the deployment sets
        `SHIPKIT_MANAGED_CONFIG_TOKEN` ? with that value as a bearer token
        (`Authorization: Bearer <token>`). The bearer alternative is scoped to
        this endpoint only and is disabled (fail closed) when the variable is
        unset.
      operationId: updateMarkup
      security:
        - ShipKitApiKey: []
        - ManagedConfigToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  title: Native
                  required: [percentage_markup, fixed_fee_cents]
                  properties:
                    percentage_markup: { type: number, examples: [12.0] }
                    fixed_fee_cents: { type: integer, examples: [50] }
                    card_fee_pct:
                      type: number
                      minimum: 0
                      maximum: 100
                      description: Optional; validated and echoed; not persisted.
                - type: object
                  title: Managed
                  required: [markup_pct, fixed_fee]
                  properties:
                    markup_pct:
                      type: number
                      minimum: 0
                      maximum: 100
                      description: Percentage markup on the carrier rate.
                      examples: [10.0]
                    fixed_fee:
                      type: number
                      minimum: 0
                      maximum: 1000
                      description: Flat add-on in **dollars** (stored as cents).
                      examples: [0.75]
                    card_fee_pct:
                      type: number
                      minimum: 0
                      maximum: 100
                      description: Validated and echoed; not persisted.
                      examples: [3.75]
            examples:
              native:
                value: { percentage_markup: 12.0, fixed_fee_cents: 50 }
              managed:
                value: { markup_pct: 10.0, fixed_fee: 0.75, card_fee_pct: 3.75 }
      responses:
        "200":
          description: The updated markup (always reported in the native shape).
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
              example: { success: true, percentage_markup: 12.0, fixed_fee_cents: 50 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/label/{labelId}:
    get:
      tags: [Labels]
      summary: Fetch a stored label by id.
      operationId: getLabel
      parameters:
        - name: labelId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The stored label.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/label/session/{sessionId}:
    get:
      tags: [Labels]
      summary: Fetch a stored label by payment session id.
      operationId: getLabelBySession
      parameters:
        - { $ref: "#/components/parameters/SessionIdCamel" }
      responses:
        "200":
          description: The stored label.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/label/session/{sessionId}/shred:
    delete:
      tags: [Labels]
      summary: Remove a label from storage (does not void it with the carrier).
      operationId: shredLabel
      parameters:
        - { $ref: "#/components/parameters/SessionIdCamel" }
      responses:
        "200":
          description: The label was removed from storage.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/webhook/easypost:
    post:
      tags: [Webhooks]
      summary: Receive EasyPost tracking / event webhooks (HMAC-verified).
      description: |
        Authenticated by EasyPost's HMAC-SHA256 signature ? **not** an API key ?
        so it is exempt from the `ShipKit-Api-Key` requirement. ShipKit verifies
        `X-Hmac-Signature` (`hmac-sha256-hex=` + hex HMAC over the raw body,
        keyed by the NFKD-normalized secret, constant-time) before processing.
      operationId: easyPostWebhook
      security: []
      parameters:
        - name: X-Hmac-Signature
          in: header
          required: true
          schema: { type: string }
          description: "`hmac-sha256-hex=` + hex HMAC-SHA256 of the raw body."
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
            example:
              id: "evt_123"
              description: "tracker.updated"
              result: { tracking_code: "9400100000000000000000", status: "in_transit", carrier: "USPS" }
      responses:
        "200": { description: Event verified and accepted. }
        "401":
          description: Missing or invalid signature.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503": { $ref: "#/components/responses/FeatureUnconfigured" }

  /api/keys:
    post:
      tags: [API keys]
      summary: Mint a new API key (admin-gated). The full key is shown once.
      description: |
        Requires a valid API key (the `/api/*` gate) **and** a verified admin
        session. The returned `api_key` is the only time the full secret is
        exposed ? store it immediately.
      operationId: createApiKey
      parameters:
        - { $ref: "#/components/parameters/AdminSession" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label]
              properties:
                label: { type: string, examples: ["prod-checkout"] }
                mode: { type: string, enum: [live, test], default: live }
                scope:
                  type: string
                  enum: [secret, publishable]
                  default: secret
                  description: |
                    `secret` (`sk_?`) is a full server key. `publishable` (`pk_?`)
                    is browser-embeddable and limited to the customer rating +
                    3-D Secure payment flow ? it cannot buy a free label, rewrite
                    pricing, or purge data, so it is safe in page source.
      responses:
        "201":
          description: The minted key (with the one-time `api_key`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyCreated" }
              example:
                id: "b2f1c0de-1111-2222-3333-444455556666"
                label: "prod-checkout"
                prefix: "sk_live_Ab12Cd"
                scope: "sk"
                created_at: "2026-07-12T17:00:00Z"
                last_used_at: null
                revoked: false
                mode: "live"
                api_key: "sk_live_your_secret_key_here"
                message: "Store this key now ? it is shown only once and cannot be retrieved again."
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    get:
      tags: [API keys]
      summary: List API keys (metadata only; never the key or its hash). Admin-gated.
      operationId: listApiKeys
      parameters:
        - { $ref: "#/components/parameters/AdminSession" }
      responses:
        "200":
          description: All keys, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
                  keys: { type: array, items: { $ref: "#/components/schemas/ApiKey" } }
              example:
                count: 1
                keys:
                  - { id: "b2f1c0de-1111-2222-3333-444455556666", label: "prod-checkout", prefix: "sk_live_Ab12Cd", created_at: "2026-07-12T17:00:00Z", last_used_at: "2026-07-12T17:05:00Z", revoked: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/config/keys:
    post:
      tags: [API keys]
      summary: Mint an API key from the hosting control plane (managed bearer only).
      description: |
        Managed-deployment key provisioning. Authenticated **exclusively** by the
        `SHIPKIT_MANAGED_CONFIG_TOKEN` bearer token (constant-time compare) ?
        the same scheme as the managed markup write. When the variable is unset
        the endpoint is disabled (fail closed); even a valid secret API key is
        refused, keeping in-band minting behind the admin-gated `/api/keys`
        surface. The returned `api_key` is the only time the full secret is
        exposed ? store it immediately.
      operationId: managedCreateApiKey
      security:
        - ManagedConfigToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label]
              properties:
                label: { type: string, maxLength: 120, examples: ["tenant-42-server"] }
                scope:
                  type: string
                  enum: [pk, sk]
                  default: sk
                  description: |
                    `sk` mints a full server key; `pk` mints a browser-embeddable
                    widget key confined to the customer rating + 3-D Secure
                    payment flow. Unknown values are rejected with `400`.
                mode:
                  type: string
                  enum: [live, test]
                  default: live
                  description: Unknown values are rejected with `400`.
      responses:
        "201":
          description: The minted key (with the one-time `api_key`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyCreated" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    get:
      tags: [API keys]
      summary: List key records for control-plane reconciliation (managed bearer only).
      description: |
        Metadata only ? never a plaintext key or a stored hash. Same
        authentication and fail-closed behavior as `POST /api/config/keys`.
      operationId: managedListApiKeys
      security:
        - ManagedConfigToken: []
      responses:
        "200":
          description: All keys, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
                  keys: { type: array, items: { $ref: "#/components/schemas/ApiKey" } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/config/keys/{id}:
    delete:
      tags: [API keys]
      summary: Revoke an API key from the hosting control plane (managed bearer only).
      description: |
        Same idempotent semantics as `DELETE /api/keys/{id}`: an unknown id is
        `404`; a known id always answers `200` with `revoked: true`, and
        `already_revoked: true` when the key had been revoked before this call.
        Same authentication and fail-closed behavior as `POST /api/config/keys`.
      operationId: managedRevokeApiKey
      security:
        - ManagedConfigToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The key is revoked.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
              example: { success: true, id: "b2f1c0de-1111-2222-3333-444455556666", revoked: true, already_revoked: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/keys/{id}:
    delete:
      tags: [API keys]
      summary: Revoke an API key by id (idempotent). Admin-gated.
      operationId: revokeApiKey
      parameters:
        - { $ref: "#/components/parameters/AdminSession" }
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The key is revoked.
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
              example: { success: true, id: "b2f1c0de-1111-2222-3333-444455556666", revoked: true, already_revoked: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    ShipKitApiKey:
      type: apiKey
      in: header
      name: ShipKit-Api-Key
      description: |
        A ShipKit API key (`sk_live_?` or `sk_test_?`). Required on every `/api/*`
        route except `GET /api/health` and the HMAC-verified EasyPost webhook.
    ManagedConfigToken:
      type: http
      scheme: bearer
      description: |
        Optional managed-deployment scheme, valid **only** on
        `POST /api/config/markup` and the `/api/config/keys` provisioning
        routes: the `SHIPKIT_MANAGED_CONFIG_TOKEN` environment value, sent as
        `Authorization: Bearer <token>` and compared in constant time. When the
        environment variable is unset this scheme is disabled (fail closed):
        the markup write then requires a secret API key, and the
        `/api/config/keys` routes are disabled outright.

  parameters:
    SessionIdCamel:
      name: sessionId
      in: path
      required: true
      schema: { type: string }
      description: Payment session id from `POST /api/payment/session`.
    AdminSession:
      name: X-Session-ID
      in: header
      required: false
      schema: { type: string }
      description: |
        A verified admin SMS session id (an admin-whitelisted phone). Required for
        the `/api/keys` management endpoints.

  responses:
    BadRequest:
      description: Invalid request or carrier/validation error.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing, invalid, or revoked API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { success: false, error: "Missing API key. Send it in the 'ShipKit-Api-Key' header." }
    Forbidden:
      description: Authenticated but not authorized for this admin action.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Unknown or expired resource.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    GatewayError:
      description: Upstream gateway error; retry.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    FeatureUnconfigured:
      description: The feature's credentials are not configured.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { success: false, error: "Shipping is not configured (set EASYPOST_API_KEY)" }

  schemas:
    Error:
      type: object
      properties:
        success: { type: boolean, examples: [false] }
        error: { type: string, examples: ["Payment session not found"] }
      required: [error]
    Address:
      type: object
      properties:
        name: { type: string }
        company: { type: string }
        street1: { type: string }
        street2: { type: string }
        city: { type: string }
        state: { type: string, description: Two-letter US/CA state. }
        zip: { type: string }
        country: { type: string, default: "US", description: ISO alpha-2. }
        phone: { type: string }
      required: [street1, city, state, zip]
    VerifyResponse:
      type: object
      properties:
        verified: { type: boolean }
        address: { $ref: "#/components/schemas/Address" }
        residential: { type: boolean }
        errors:
          type: array
          items:
            type: object
            properties:
              code: { type: string }
              message: { type: string }
    Parcel:
      type: object
      properties:
        weight_oz: { type: number, description: "Weight in OUNCES (required, > 0)." }
        length_in: { type: number }
        width_in: { type: number }
        height_in: { type: number }
      required: [weight_oz]
    ShipmentRequest:
      type: object
      properties:
        from: { $ref: "#/components/schemas/Address" }
        to: { $ref: "#/components/schemas/Address" }
        parcel: { $ref: "#/components/schemas/Parcel" }
      required: [to, parcel]
    Rate:
      type: object
      properties:
        id: { type: string, examples: ["rate_aaa"] }
        carrier: { type: string, examples: ["USPS"] }
        service: { type: string, examples: ["Priority"] }
        rate: { type: string, description: "Money as a STRING.", examples: ["8.42"] }
        currency: { type: string, examples: ["USD"] }
        delivery_days: { type: [integer, "null"] }
    Message:
      type: object
      properties:
        carrier: { type: string }
        type: { type: string }
        message: { type: string }
    ShipmentResponse:
      type: object
      properties:
        id: { type: string }
        rates: { type: array, items: { $ref: "#/components/schemas/Rate" } }
        messages: { type: array, items: { $ref: "#/components/schemas/Message" } }
    BoughtLabel:
      type: object
      properties:
        id: { type: string }
        tracking_code: { type: string }
        label_url: { type: string }
        qr_code_url: { type: [string, "null"] }
        carrier: { type: [string, "null"] }
        service: { type: [string, "null"] }
        status: { type: string }
    PaymentSessionRequest:
      type: object
      properties:
        shipment_id: { type: string }
        rate_id: { type: string }
        end_shipper_id: { type: string }
        description: { type: string }
      required: [shipment_id, rate_id]
    PaymentSessionResponse:
      type: object
      properties:
        session_id: { type: string }
        form_url: { type: string, description: "Canonical hosted 3-D Secure form URL." }
        amount: { type: string, description: "Total charged (subtotal + surcharge); money as a STRING." }
        subtotal: { type: string, description: "Shipping price (carrier rate + merchant markup)." }
        surcharge: { $ref: "#/components/schemas/Surcharge" }
        currency: { type: string }
        expires_at: { type: string, format: date-time }
    Surcharge:
      type: object
      description: |
        The tier-2 buyer surcharge ? the Lifted 3-D Secure merchant-account fee
        (3.75% + $0.15) optionally passed to the buyer.
      properties:
        enabled: { type: boolean, description: "Whether the surcharge is added to the charge." }
        amount: { type: string, description: "The surcharge fee for this charge; money as a STRING." }
        percentage: { type: string, examples: ["3.75"] }
        fixed_cents: { type: integer, examples: [15] }
    TierConfig:
      type: object
      description: The deployment's adoption tier and pricing knobs.
      properties:
        tier: { type: string, enum: [selfhost, merchant, managed] }
        label: { type: string }
        surcharge:
          allOf:
            - { $ref: "#/components/schemas/Surcharge" }
            - type: object
              properties:
                monthly_fee_usd: { type: integer, examples: [25] }
        shipping_markup:
          type: object
          properties:
            percentage_markup: { type: number }
            fixed_fee_cents: { type: integer }
        payments:
          type: object
          properties:
            configured: { type: boolean }
            environment: { type: [string, "null"], enum: [live, sandbox, null] }
            card_entry: { type: [string, "null"], enum: [hosted_fields, hosted_form, null] }
        frictionless:
          type: object
          description: |
            The account-gated frictionless / card-on-file capability (SPEC_R3 �5).
            `false` by default and always false for self-host / bring-your-own-
            payments (forced 3-D Secure is their only mode). Server-derived ? never a
            client toggle.
          properties:
            allowed: { type: boolean, description: "May this account charge 3ds-off?" }
            save_cards: { type: boolean, description: "May this account save cards on file? (same capability)" }
    SaveCardRequest:
      type: object
      required: [card_token]
      properties:
        card_token: { type: string, description: "A Maverick Hosted Fields card token (never a PAN)." }
        billing: { $ref: "#/components/schemas/VaultBilling" }
    VaultBilling:
      type: object
      description: Cardholder billing for the vault record; address1 + zip enable the billing record the card-add references.
      properties:
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string }
        phone: { type: string }
        company: { type: string }
        address1: { type: string }
        city: { type: string }
        state: { type: string }
        zip: { type: string }
        country: { type: string, examples: ["US"] }
    SavedCardResponse:
      type: object
      properties:
        success: { type: boolean }
        vault_id: { type: string, description: "Opaque saved-card reference (\"{customerId}:{cardId}\")." }
        card_token: { type: string, description: "Vault card token when the gateway echoed one (may be empty)." }
    ChargeSavedCardRequest:
      type: object
      required: [vault_id, shipment_id, rate_id, idempotency_key]
      properties:
        vault_id: { type: string, description: "The saved-card reference from POST /api/payment/save-card." }
        shipment_id: { type: string }
        rate_id: { type: string }
        idempotency_key: { type: string, description: "Makes the charge + label buy at-most-once." }
        end_shipper_id: { type: string }
        description: { type: string }
        billing: { $ref: "#/components/schemas/VaultBilling" }
    SavedCardChargeResponse:
      allOf:
        - { $ref: "#/components/schemas/LabelResponse" }
        - type: object
          properties:
            transaction_id: { type: string }
            amount: { type: string, description: "The amount charged; money as a STRING." }
    ThreeDs:
      type: [object, "null"]
      # The CAVV cryptogram is intentionally NOT exposed to API clients ? it is
      # cardholder-authentication data and stays server-side (audit only).
      properties:
        eci: { type: [string, "null"], description: "Scheme-specific (Visa 05 / MC 02 = fully authenticated)." }
        liability_shift: { type: boolean }
    PaymentStatusResponse:
      type: object
      properties:
        status: { type: string, enum: [pending, authenticated, approved, declined, failed] }
        three_ds: { $ref: "#/components/schemas/ThreeDs" }
    LabelResponse:
      type: object
      properties:
        label_url: { type: [string, "null"] }
        qr_code_url: { type: [string, "null"] }
        tracking_code: { type: [string, "null"] }
        carrier: { type: [string, "null"] }
        service: { type: [string, "null"] }
    Markup:
      type: object
      properties:
        percentage_markup: { type: number }
        fixed_fee_cents: { type: integer }
        fixed_fee_dollars: { type: number }
    ApiKey:
      type: object
      description: An API key's non-secret metadata (never the key or its hash).
      properties:
        id: { type: string }
        label: { type: string }
        prefix: { type: string, description: "Short display prefix, e.g. sk_live_Ab12Cd." }
        scope: { type: string, enum: [sk, pk], description: "sk = secret server key; pk = publishable widget key." }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }
        revoked: { type: boolean }
    ApiKeyCreated:
      allOf:
        - { $ref: "#/components/schemas/ApiKey" }
        - type: object
          properties:
            mode: { type: string, enum: [live, test] }
            api_key: { type: string, description: "The full secret ? shown ONCE." }
            message: { type: string }
