openapi: 3.1.0
info:
  title: Synfin API
  version: 1.0.0
  summary: Best-execution quotes, execution plans, and swap outcomes for the Canton Network.
  description: |
    The public HTTP contract for Synfin. This document is the SINGLE SOURCE for
    the wire surface; the `@synfin/client` SDK and the docs API reference are
    generated from it, so they cannot drift.

    Two integration surfaces share the same shapes:

    - **Keyless (first-party / Launch App):** `GET /api/quote` →
      `POST /api/app/plan` → `GET /api/outcome`. Rate-limited per IP.
    - **Keyed (partner, RFC-0009):** `GET /api/quote` (with your fee params) →
      `POST /api/execute/plan`, tracked with `GET /api/outcome`. Send your key
      as `x-api-key` (or `Authorization: Bearer`). Rate-limited per key.

    Execution is **non-custodial**: a plan is a set of steps your own wallet
    signs and sends. Synfin holds no keys and moves no funds. The memo floor
    (`minReceive`) is computed server-side and pinned in the plan.

    ## Versioning

    The major version is carried in the path (`/openapi/v1.yaml`) and on every
    response as `x-synfin-api-version: 1`. A breaking wire change mints
    `/openapi/v2.yaml` and bumps the header. `info.version` carries the exact
    semver. Every response also carries `x-synfin-build` (the deploy's short
    commit sha) for traceability.

    ## Fees

    Fees are DISCLOSED in every plan and quote (`clientFees`) but on-ledger
    collection is gated behind a server flag that is OFF today
    (`FEE_COLLECTION_ENABLED = false`); until it flips every swap is fee-less
    and the disclosed lines read zero. When it flips, the fee rides the swap as
    an atomic CIP-0112 batch leg and is wallet-dependent (a wallet whose Canton
    participant lacks the batching package degrades the swap to fee-less
    honestly, never a broken swap, never a silent charge).
  contact:
    name: Synfin
    url: https://synfin.xyz/docs
  license:
    name: Apache-2.0
    url: https://www.apache.org/licenses/LICENSE-2.0
servers:
  - url: https://synfin.xyz
    description: Production
tags:
  - name: quote
    description: Cross-venue best-execution quotes.
  - name: plan
    description: One-call execution plans (keyless B2C and keyed partner).
  - name: track
    description: Swap outcome and venue liveness.
  - name: status
    description: Service status.
paths:
  /api/quote:
    get:
      tags: [quote]
      operationId: getQuote
      summary: Live cross-venue quote
      description: |
        Returns a live quote for `from → to` across the Canton venues, ranked
        best net receive first. Keyless callers get the venue nets. A keyed
        caller additionally gets the disclosed `clientFees` per venue (the
        service fee and, if `feeBps`/`feeRecipient` are supplied, the
        integrator split) plus the `appliedSchema` audit field.

        Honest by construction: if every venue fails this still returns `200`
        with each venue marked `available: false` and `best: null`.

        Programmatic volume (meta-routers): the stated path is a FREE API key
        (attribution + a per-key window). Keyless callers are metered per IP,
        and fresh parameter combinations (edge-cache misses) draw from a
        separate keyless fresh-quote budget, so per-request `amount` variation
        rate-limits by design. Keyless callers SHOULD send `ref=<slug>`
        (lowercase letters, digits, hyphens, up to 32 chars): recorded for
        source visibility only, never changes the response.
      security:
        - {}
        - apiKeyAuth: []
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: string, example: CC }
          description: Give-side token symbol.
        - name: to
          in: query
          required: true
          schema: { type: string, example: USDCx }
          description: Want-side token symbol.
        - name: amount
          in: query
          required: true
          schema: { type: string, example: '100' }
          description: Give amount, a positive decimal.
        - name: slippageBps
          in: query
          required: false
          schema: { type: integer, minimum: 0, default: 50 }
          description: Max slippage in basis points (memo floor input).
        - name: ref
          in: query
          required: false
          schema: { type: string, pattern: '^[a-z0-9][a-z0-9-]{0,31}$' }
          description: >-
            Keyless soft attribution slug (the meta-router convention).
            Recorded for source visibility only; never changes the response
            and is stripped from the cache key.
        - name: feeBps
          in: query
          required: false
          schema: { type: integer, minimum: 0, maximum: 10 }
          description: |
            Integrator fee in bps (0 to 10, the cap). The integrator keeps 100
            percent of it (no revenue share): it settles as an independent leg
            to `feeRecipient`. KEYED ONLY; on the keyless path it is
            `400 missing_key`. Requires `feeRecipient`.
        - name: feeRecipient
          in: query
          required: false
          schema: { type: string }
          description: Canton party your integrator fee settles to. Keyed only.
      responses:
        '200':
          description: A quote. `clientFees`/`appliedSchema` present only for keyed callers.
          headers:
            x-synfin-api-version: { $ref: '#/components/headers/ApiVersion' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining:
              { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuoteResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /api/app/plan:
    post:
      tags: [plan]
      operationId: createAppPlan
      summary: First-party (keyless) execution plan
      description: |
        The Launch App plan route. Keyless (per-IP limited). Builds an
        `ExecutionPlan` for the chosen venue through the same core as the keyed
        route, with the server-side memo floor. Takes NO fee params (any
        `feeBps`/`feeRecipient` is `400 bad_request`). Discloses the B2C
        service line; while fees are paused it returns `collectsFees: false`
        with zeroed disclosure and no fee step.
      security:
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PlanRequest' }
      responses:
        '200':
          description: An execution plan.
          headers:
            x-synfin-api-version: { $ref: '#/components/headers/ApiVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AppPlan' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /api/execute/plan:
    post:
      tags: [plan]
      operationId: createPartnerPlan
      summary: Partner (keyed) execution plan
      description: |
        The partner execution plan (RFC-0009). KEYED ONLY, a keyless request
        is `400 missing_key`. Returns an `ExecutionPlan` with the memo floor
        computed server-side and the applied quote pinned. Idempotent on
        `idempotencyKey`; a replay returns the same plan without rate-limit
        headers.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PlanRequest' }
      responses:
        '200':
          description: An execution plan.
          headers:
            x-synfin-api-version: { $ref: '#/components/headers/ApiVersion' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining:
              { $ref: '#/components/headers/RateLimitRemaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExecutionPlan' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
        '502': { $ref: '#/components/responses/Internal' }
  /api/outcome:
    get:
      tags: [track]
      operationId: getOutcome
      summary: Swap outcome + real settlement id
      description: |
        The server-side outcome read the swap watch polls. Given the taker
        `party` and the time window, returns what happened plus the REAL ledger
        settlement transaction id (the datum a wallet cannot give). The party
        transits transiently and is never logged or persisted.
      security:
        - {}
      parameters:
        - name: party
          in: query
          required: true
          schema: { type: string }
          description: The taker's Canton party ("<hint>::<fingerprint>").
        - name: since
          in: query
          required: true
          schema: { type: string, format: date-time }
        - name: deadline
          in: query
          required: true
          schema: { type: string, format: date-time }
          description: The on-ledger executeBefore deadline.
        - name: escrow
          in: query
          required: false
          schema: { type: string }
          description: Optional wire tuple (escrow party) to disambiguate concurrent swaps.
        - name: amount
          in: query
          required: false
          schema: { type: string }
        - name: minReceive
          in: query
          required: false
          schema: { type: string }
        - name: want
          in: query
          required: false
          schema: { type: string }
      responses:
        '200':
          description: The classified outcome.
          headers:
            x-synfin-api-version: { $ref: '#/components/headers/ApiVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Outcome' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
  /api/liveness:
    get:
      tags: [track]
      operationId: getLiveness
      summary: Venue liveness (server-side probe)
      description: |
        The primary venue-liveness read used by the pre-arm guards, run from
        Cloudflare egress (the path the venue edge has never dropped).
      security:
        - {}
      parameters:
        - name: venue
          in: query
          required: false
          schema: { type: string, example: tradecraft }
        - name: from
          in: query
          required: false
          schema: { type: string, example: CC }
        - name: to
          in: query
          required: false
          schema: { type: string, example: USDCx }
      responses:
        '200':
          description: The liveness verdict.
          headers:
            x-synfin-api-version: { $ref: '#/components/headers/ApiVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Liveness' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/Internal' }
  /api/status:
    get:
      tags: [status]
      operationId: getStatus
      summary: Service status summary
      description: The summary the /status page and footer badge read. Edge-cached ~60s.
      security:
        - {}
      responses:
        '200':
          description: The status summary.
          headers:
            x-synfin-api-version: { $ref: '#/components/headers/ApiVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusSummary' }
components:
  securitySchemes:
    apiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        A partner key (`sk_live_...`). May instead be sent as
        `Authorization: Bearer <key>`. Get one at portal.synfin.xyz.
  headers:
    ApiVersion:
      description: The API major version. Assert this and alert on an unexpected bump.
      schema: { type: string, example: '1' }
    RateLimitLimit:
      description: Requests allowed in the current window (keyed responses).
      schema: { type: string }
    RateLimitRemaining:
      description: Requests remaining in the current window (keyed responses).
      schema: { type: string }
  responses:
    BadRequest:
      description: Malformed or missing parameters (`bad_request`, `unknown_token`, or `missing_key`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid API key (`missing_key`, `invalid_key`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: The pair is not executable (`unsupported_pair`, `venue_unavailable`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Per-IP or per-key window exceeded (`rate_limited`). Carries `Retry-After`.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unavailable:
      description: A dependency is temporarily unavailable (`key_verification_unavailable`, `fee_unconfigured`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Internal:
      description: An unexpected server error (`internal`, `guardrail`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    Error:
      type: object
      required: [error, code]
      description: |
        Every non-2xx response has this shape. `error` is human-readable,
        `code` is a stable machine token (see `x-synfin-error-codes`), and
        `action`, when present, tells the integrator what to do next.
      properties:
        error: { type: string, description: Human-readable message. }
        code:
          type: string
          description: Stable machine-readable code.
          enum:
            - bad_request
            - unknown_token
            - missing_key
            - invalid_key
            - rate_limited
            - key_verification_unavailable
            - unsupported_pair
            - venue_unavailable
            - fee_unconfigured
            - guardrail
            - internal
        action:
          type: string
          description: What the caller should do next (optional).
    ClientFees:
      type: object
      description: The disclosed fee block. Disclosed only; on-ledger collection is flag-gated off today.
      required: [service, userReceives]
      properties:
        integrator:
          nullable: true
          type: object
          properties:
            bps: { type: integer }
            amount: { type: string }
            recipient: { type: string }
        service:
          type: object
          required: [bps, amount]
          properties:
            bps: { type: integer }
            amount: { type: string }
        userReceives:
          { type: string, description: Net receive after the disclosed fees. }
    QuoteVenue:
      type: object
      required:
        [
          venueId,
          settlementMode,
          available,
          gross,
          net,
          fee,
          feeBps,
          rejectionCode,
        ]
      properties:
        venueId: { type: string }
        settlementMode: { type: string }
        available: { type: boolean }
        gross: { type: [string, 'null'] }
        net: { type: [string, 'null'] }
        fee:
          nullable: true
          type: object
          properties:
            amount: { type: string }
            asset: { type: string }
            appliedTo: { type: string, enum: [on_top, deducted_from_give] }
        feeBps: { type: [integer, 'null'] }
        rejectionCode: { type: [string, 'null'] }
        clientFees:
          allOf: [{ $ref: '#/components/schemas/ClientFees' }]
          description: Present only for keyed callers on available venues.
    QuoteResponse:
      type: object
      required: [pair, amount, asOf, venues, best, edgeBps]
      properties:
        pair:
          type: object
          required: [give, want]
          properties:
            give: { type: string }
            want: { type: string }
        amount: { type: string }
        asOf: { type: string, format: date-time }
        venues:
          type: array
          items: { $ref: '#/components/schemas/QuoteVenue' }
        best:
          nullable: true
          type: object
          properties:
            venueId: { type: string }
            gross: { type: string }
            net: { type: string }
        edgeBps: { type: [integer, 'null'] }
        appliedSchema:
          type: object
          description: Keyed only, the exact fee schema applied.
          properties:
            schemaId: { type: string }
            overrideId: { type: [string, 'null'] }
    PlanRequest:
      type: object
      description: |
        The plan request body. `from`/`to`/`amount`/`venueId`/`takerParty` are
        required to build a plan; `idempotencyKey` makes retries return the same
        plan. `feeBps`/`feeRecipient` are keyed-only (rejected on /api/app/plan).
      properties:
        from: { type: string }
        to: { type: string }
        amount: { type: string }
        venueId: { type: string }
        takerParty: { type: string }
        slippageBps: { type: [integer, string] }
        idempotencyKey: { type: string }
        feeBps: { type: [integer, string] }
        feeRecipient: { type: string }
        quoteRef:
          type: object
          properties:
            venueId: { type: string }
    QuoteRef:
      type: object
      required:
        [
          venueId,
          pair,
          amount,
          asOf,
          net,
          minReceive,
          clientFees,
          appliedSchema,
        ]
      properties:
        venueId: { type: string }
        pair:
          type: object
          properties:
            give: { type: string }
            want: { type: string }
        amount: { type: string }
        asOf: { type: string, format: date-time }
        net: { type: string }
        minReceive:
          { type: string, description: The server-computed memo floor. }
        clientFees: { $ref: '#/components/schemas/ClientFees' }
        appliedSchema:
          type: object
          properties:
            schemaId: { type: string }
            overrideId: { type: [string, 'null'] }
    PlanStep:
      type: object
      description: |
        A step the wallet performs. `kind: venue-deposit` is always present.
        `kind: b2c-fee-transfer` (keyless) or `kind: fee-escrow-lock` (keyed)
        appear only when a plan collects fees.
      required: [kind]
      properties:
        kind:
          {
            type: string,
            enum: [venue-deposit, b2c-fee-transfer, fee-escrow-lock],
          }
        target: { type: string }
        instrument: { type: string }
        amount: { type: string }
        memo: { type: string }
        expiresAt: { type: string, format: date-time }
    ExecutionPlan:
      type: object
      required:
        [
          planId,
          quoteRef,
          mode,
          steps,
          expiry,
          idempotencyKey,
          trustWindow,
          collectsFees,
        ]
      properties:
        planId: { type: string }
        quoteRef: { $ref: '#/components/schemas/QuoteRef' }
        mode: { type: string, enum: [managed-deposit] }
        steps:
          type: array
          items: { $ref: '#/components/schemas/PlanStep' }
        expiry: { type: string, format: date-time }
        idempotencyKey: { type: string }
        trustWindow: { type: string }
        collectsFees:
          {
            type: boolean,
            description: Whether the plan collects fees on-ledger (false = disclosed only).,
          }
    B2cFee:
      type: object
      description: The B2C fee consent record (present only when collectsFees is true).
      properties:
        direction: { type: string, enum: [input-cc, output-cc] }
        bps: { type: integer }
        ccAmount: { type: string }
    AppPlan:
      allOf:
        - { $ref: '#/components/schemas/ExecutionPlan' }
        - type: object
          properties:
            b2cFee: { $ref: '#/components/schemas/B2cFee' }
            collectsFees: { type: boolean }
    Outcome:
      type: object
      description: |
        The classified swap outcome. `status` is the terminal or in-flight
        state; `depositUpdateId` (when bindable) is the real ledger settlement
        transaction id.
      properties:
        status: { type: string }
        depositUpdateId: { type: [string, 'null'] }
    Liveness:
      type: object
      properties:
        venue: { type: string }
        state: { type: string, enum: [live, dead, unverified] }
    StatusSummary:
      type: object
      properties:
        mode: { type: string, enum: [monitor, live] }
        state: { type: string }
        components:
          type: array
          items:
            type: object
            properties:
              component: { type: string }
              state: { type: string }
x-synfin-error-codes:
  description: |
    The canonical error-code table. Every non-2xx response carries one of these
    in `code`, with a human `error` and an optional `action`.
  codes:
    bad_request:
      http: [400]
      meaning: Malformed or missing parameters or body.
      action: Fix the request per the `error` message.
    unknown_token:
      http: [400]
      meaning: A token symbol is not recognized.
      action: Use a supported symbol (the `error` lists them).
    missing_key:
      http: [400, 401]
      meaning: The endpoint or feature requires an API key.
      action: Get a free key at portal.synfin.xyz and send it as x-api-key.
    invalid_key:
      http: [401]
      meaning: The API key is unrecognized or revoked.
      action: Check the key, or create one at portal.synfin.xyz.
    rate_limited:
      http: [429]
      meaning: The per-IP or per-key window was exceeded.
      action: Back off until Retry-After; raise your plan limit in the portal.
    key_verification_unavailable:
      http: [503]
      meaning: The key store is temporarily unreadable; the key may be valid.
      action: Retry after Retry-After.
    unsupported_pair:
      http: [409]
      meaning: The pair is not executable (e.g. no CC leg).
      action: Re-quote a supported pair.
    venue_unavailable:
      http: [409]
      meaning: The chosen venue is not currently executable.
      action: Re-quote and choose an available venue.
    fee_unconfigured:
      http: [503]
      meaning: A collecting B2C plan has no configured fee destination (internal fail-closed).
      action: Internal; should never reach a keyed integrator.
    guardrail:
      http: [500]
      meaning: A party-isolation guardrail tripped (internal).
      action: Internal; contact support if seen.
    internal:
      http: [500, 502]
      meaning: An unexpected server error.
      action: Retry; if it persists, contact support.
