openapi: 3.1.0
info:
  title: BoundIdentity API
  version: 0.2.0
  description: |
    Institutional DID + KYC/KYB bind platform for Antelope chains.
    PII documents remain at the IDV provider (Shufti Pro). BoundIdentity stores attestations and hashes only.
  contact:
    name: BoundIdentity
# Only list hostnames that are live for integrators. Sandbox first so generated
# clients default there.
servers:
  - url: https://sandbox.identityinc.io
    description: >-
      Sandbox — free test verifications (real Shufti test account), simulated
      chain. Keys are bi_test_… and must not be sent to production. Mint via
      POST /v1/billing/checkout on this host (see Quickstart).
  - url: https://api.identityinc.io
    description: Production — the base URL for the API key issued at signup (bi_…)
tags:
  - name: Accounts
  - name: KYC
  - name: KYB
  - name: Bind
  - name: Compliance
  - name: Admin
  - name: AML
  - name: Billing
  - name: Phone
  - name: Contact
    description: >-
      Verified email and phone bound to an account. Contact channels are never
      identity evidence - KYC owns that.
  - name: Resolution
  - name: Ops
security:
  - bearerAuth: []
paths:
  /v1/health:
    get:
      tags: [Ops]
      security: []
      summary: Liveness
      responses:
        "200":
          description: OK
  /v1/whoami:
    get:
      tags: [Ops]
      security: []
      summary: The source IP this API attributes to you
      description: >-
        Returns the address tenant IP allowlists are compared against. Call it
        from the server that will hold your API key before configuring an
        allowlist: NAT and egress gateways mean the address we see is often not
        the one you expect, and an allowlist that does not match fails closed —
        every request returns 403. Unauthenticated, and returns nothing beyond
        your own address.
      responses:
        "200":
          description: The caller's resolved source address
          content:
            application/json:
              schema:
                type: object
                required: [clientIp]
                properties:
                  clientIp:
                    type: string
                    example: 203.0.113.42
  /v1/ready:
    get:
      tags: [Ops]
      security: []
      summary: Readiness
      responses:
        "200":
          description: Ready
        "503":
          description: Not ready
  /v1/metrics:
    get:
      tags: [Ops]
      security:
        - bearerAuth: []
      summary: Metrics (Prometheus text exposition)
      description: Requires `read` scope.
      responses:
        "200":
          description: text/plain exposition
        "401":
          description: Missing or invalid API key
  /v1/accounts/free:
    post:
      tags: [Accounts]
      summary: Sponsor free provisional account
      parameters:
        - $ref: "#/components/parameters/CorrelationId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ownerPublicKey]
              properties:
                ownerPublicKey: { type: string }
                activePublicKey: { type: string }
                passkeyCredentialId: { type: string }
                preferredName:
                  type: string
                  pattern: "^[a-z1-5.]{1,12}$"
                  description: >-
                    Requested account name. Permanent once created. Conflicts
                    with an existing name return 409 and hand the kycCreateToken
                    back, so the caller can retry with a different name without
                    repeating KYC.
                deviceFingerprint: { type: string }
                personDid: { type: string }
                kycCreateToken:
                  type: string
                  description: One-time token from GET /v1/kyc/status after approval.
                emailProofToken:
                  type: string
                  description: >-
                    From POST /v1/email/otp/confirm. Binds the verified address
                    to the new account. Required when the organization sets
                    requireEmailProofBeforeFreeCreate.
                phoneProofToken:
                  type: string
                  description: From POST /v1/phone/otp/confirm.
      responses:
        "201":
          description: Created
        "409":
          description: Requested name, email, or number already taken
  /v1/accounts/name-available:
    get:
      tags: [Accounts]
      summary: Check whether an account name can be claimed
      description: >-
        Advisory only. Nothing is reserved: the chain is the authority and
        /v1/accounts/free re-checks, so a name can still be lost between this
        call and create.
      parameters:
        - name: name
          in: query
          required: true
          schema: { type: string, maxLength: 12 }
      responses:
        "200":
          description: Availability
          content:
            application/json:
              schema:
                type: object
                properties:
                  name: { type: string }
                  available: { type: boolean }
                  reason: { type: string, enum: [TAKEN, INVALID] }
                  message: { type: string }
  /v1/email/otp/start:
    post:
      tags: [Contact]
      summary: Email a verification code
      description: >-
        Confirming an email never creates or promotes a Person. Email is a
        contact channel; KYC remains the only identity authority.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: Challenge issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  challengeId: { type: string, format: uuid }
                  expiresAt: { type: string, format: date-time }
                  emailMasked: { type: string }
                  devCode:
                    type: string
                    description: >-
                      Present only in non-hosted local development. Hosted
                      sandbox and production never include the code.
        "409":
          description: Address already bound to an account
  /v1/email/otp/confirm:
    post:
      tags: [Contact]
      summary: Confirm an emailed code; returns a short-lived emailProofToken
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challengeId, code]
              properties:
                challengeId: { type: string, format: uuid }
                code: { type: string }
      responses:
        "200":
          description: Verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  emailProofToken: { type: string }
                  expiresInSec: { type: integer }
                  emailMasked: { type: string }
        "429":
          description: Too many attempts on this challenge
  /v1/accounts/{did}/contact:
    get:
      tags: [Contact]
      summary: Masked view of the contacts bound to an account
      parameters:
        - name: did
          in: path
          required: true
          schema: { type: string }
          description: Account DID, or the bare chain name.
      responses:
        "200":
          description: Masked contacts; raw values are never returned
  /v1/accounts/contact/challenge:
    post:
      tags: [Contact]
      summary: Issue a nonce the account's owner key must sign to change a contact
      description: >-
        Contact changes are authorized by the account key, not by the session —
        whoever can change the address controls where account notices go. The
        nonce is single-use and consumed by the matching start call.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountDid, channel]
              properties:
                accountDid: { type: string }
                channel: { type: string, enum: [email, phone] }
      responses:
        "200":
          description: Challenge
          content:
            application/json:
              schema:
                type: object
                properties:
                  nonce: { type: string }
                  payloadToSign: { type: string }
                  expiresAt: { type: string, format: date-time }
  /v1/accounts/contact/email/start:
    post:
      tags: [Contact]
      summary: Send a code to a proposed new email
      description: Nothing is unbound until the code is confirmed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountDid, email, nonce, signature]
              properties:
                accountDid: { type: string }
                email: { type: string, format: email }
                nonce: { type: string }
                signature: { type: string }
      responses:
        "200":
          description: Challenge issued
        "400":
          description: >-
            INVALID_SIGNATURE - the signature does not verify against the
            account's active key.
        "409":
          description: Address already bound to another account
  /v1/accounts/contact/email/confirm:
    post:
      tags: [Contact]
      summary: Confirm the code and swap the bound email
      description: >-
        Detach and attach run in one transaction, so the account is never left
        without a verified email. The previous address is notified.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challengeId, code]
              properties:
                challengeId: { type: string, format: uuid }
                code: { type: string }
      responses:
        "200":
          description: Changed
  /v1/accounts/contact/phone/start:
    post:
      tags: [Contact]
      summary: Send a code to a proposed new phone number
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountDid, phone, nonce, signature]
              properties:
                accountDid: { type: string }
                phone: { type: string }
                nonce: { type: string }
                signature: { type: string }
      responses:
        "200":
          description: Challenge issued
  /v1/accounts/contact/phone/confirm:
    post:
      tags: [Contact]
      summary: Confirm the code and swap the bound phone number
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challengeId, code]
              properties:
                challengeId: { type: string, format: uuid }
                code: { type: string }
      responses:
        "200":
          description: Changed
  /v1/kyc/start:
    post:
      tags: [KYC]
      summary: Start KYC; returns verification URL / access token
      description: |
        Person-first by default: omit `accountDid` to create a Person before any
        chain account (required when the organization gates free-create on KYC).
        Identity checks are document + face via Shufti Pro — BoundIdentity does
        not enable Shufti phone OTP for KYC. On sandbox, complete
        `verificationUrl` with a Shufti test ID (free on the test account).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                accountDid:
                  type: string
                  description: Optional. Omit for person-first KYC before free-create.
                levelName: { type: string }
      responses:
        "200":
          description: Token issued
  /v1/bind/challenge:
    post:
      tags: [Bind]
      summary: Issue bind nonce for wallet signature
      responses:
        "200":
          description: Challenge
  /v1/bind/confirm:
    post:
      tags: [Bind]
      summary: Confirm bind with signature; issue AccountBinding VC
      responses:
        "200":
          description: Bound
  /v1/accounts/unlock:
    post:
      tags: [Accounts]
      summary: Remove faucet cosigner after bind
      responses:
        "200":
          description: Unlocked
  /v1/kyb/start:
    post:
      tags: [KYB]
      summary: Start Know-Your-Business for a legal entity
      responses:
        "200":
          description: Organization pending
  /v1/admin/accounts/suspend:
    post:
      tags: [Admin]
      summary: Suspend an account (compliance action)
      responses:
        "200":
          description: Suspended
  /v1/admin/dashboard:
    get:
      tags: [Admin]
      summary: Organization dashboard aggregates (lifecycle, AML, recent audit)
      responses:
        "200":
          description: Dashboard payload
  /v1/admin/accounts:
    get:
      tags: [Admin]
      summary: List chain accounts for the organization
      responses:
        "200":
          description: Account list
  /v1/admin/people:
    get:
      tags: [Admin]
      summary: List people / KYC subjects
      responses:
        "200":
          description: People list
  /v1/admin/organizations:
    get:
      tags: [Admin]
      summary: List KYB businesses
      responses:
        "200":
          description: Businesses
  /v1/admin/audit:
    get:
      tags: [Admin]
      summary: Hash-chained audit log entries
      responses:
        "200":
          description: Audit entries
  /v1/admin/members:
    get:
      tags: [Admin]
      summary: Organization console memberships (requires admin scope)
      responses:
        "200":
          description: Members
    post:
      tags: [Admin]
      summary: Add a console member, or restore and re-role a revoked one
      description: |
        The membership roster decides who may sign in to the admin console and
        what they may do there. Roles, weakest first: `viewer`, `operator`,
        `compliance_officer`, `admin`, `owner`.

        Re-adding an existing address updates its role and reactivates it, so a
        returning colleague does not collide with their old row.

        While an organization has no members at all, the console accepts any
        address with the correct password and treats it as an `owner` — adding
        the first member closes that path.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email: { type: string, format: email }
                role:
                  type: string
                  enum: [viewer, operator, compliance_officer, admin, owner]
      responses:
        "201":
          description: Member added or updated
  /v1/admin/members/{email}:
    patch:
      tags: [Admin]
      summary: Change a member's role or reactivate them
      parameters:
        - name: email
          in: path
          required: true
          schema: { type: string, format: email }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                role:
                  type: string
                  enum: [viewer, operator, compliance_officer, admin, owner]
                active: { type: boolean }
      responses:
        "200":
          description: Updated
        "404":
          description: No such member in this organization
    delete:
      tags: [Admin]
      summary: Revoke a member's console access
      description: |
        Deactivates rather than deletes. The address remains the recorded actor
        on every audit entry the member produced, and removing the row that
        explains who they were would leave those entries unresolvable.
      parameters:
        - name: email
          in: path
          required: true
          schema: { type: string, format: email }
      responses:
        "200":
          description: Revoked
        "404":
          description: No such member in this organization
  /v1/admin/aml:
    get:
      tags: [Admin]
      summary: PEP / sanctions alerts + AML audit events
      responses:
        "200":
          description: Screening feed
  /v1/admin/risk:
    get:
      tags: [Admin]
      summary: Anti-abuse posture (velocity, shared devices, risk events)
      responses:
        "200":
          description: Risk summary
  /v1/admin/tenant:
    get:
      tags: [Admin]
      summary: Current organization + policy (scoped by API key)
      responses:
        "200":
          description: Organization detail
  /v1/aml/screen-applicant:
    post:
      tags: [Compliance]
      summary: Screen applicant id hash (sanctions / denylist)
      responses:
        "200":
          description: Screen result
  /v1/risk/assess:
    post:
      tags: [Compliance]
      summary: Anti-abuse risk score before free create / bind
      responses:
        "200":
          description: Assessment
  /v1/billing/plans:
    get:
      tags: [Ops]
      security: []
      summary: Public Identity Inc. plan catalog
      responses:
        "200":
          description: Plans
  /v1/billing/ai/advise:
    post:
      tags: [Ops]
      security: []
      summary: AI checkout advisor (rate-limited)
      responses:
        "200":
          description: Recommendation
        "429":
          description: Rate limited
  /v1/billing/checkout:
    post:
      tags: [Ops]
      security: []
      summary: Builder signup, Growth Stripe Checkout, or Institutional lead
      responses:
        "200":
          description: Redirect or sales lead
        "201":
          description: Builder provisioned
        "429":
          description: Rate limited
  /v1/billing/stripe/webhook:
    post:
      tags: [Ops]
      security: []
      summary: Stripe lifecycle + checkout.session.completed
      responses:
        "200":
          description: Received
  /v1/billing/checkout/complete:
    post:
      tags: [Ops]
      security: []
      summary: Complete a pending Growth checkout (sandbox / test settlement)
      responses:
        "200":
          description: Provisioned or upgraded
  /v1/billing/checkout/reveal:
    post:
      tags: [Ops]
      security: []
      summary: Reveal API key once after Stripe Checkout (rate-limited)
      responses:
        "200":
          description: Key payload
  /v1/billing/usage:
    get:
      tags: [Ops]
      summary: Plan + usage meters for the authenticated organization
      responses:
        "200":
          description: Usage snapshot
  /v1/billing/upgrade:
    post:
      tags: [Ops]
      summary: Builder → Growth Checkout (keeps organization + keys)
      responses:
        "200":
          description: Checkout redirect
  /v1/billing/portal:
    post:
      tags: [Ops]
      summary: Stripe Customer Portal session
      responses:
        "200":
          description: Portal URL
  /v1/compliance/export:
    post:
      tags: [Compliance]
      summary: Export hash-chained audit log for a date range
      responses:
        "200":
          description: Export job
  /v1/compliance/exports:
    get:
      tags: [Compliance]
      summary: List recent compliance export jobs
      responses:
        "200":
          description: Jobs
  /v1/compliance/export/{jobId}:
    get:
      tags: [Compliance]
      summary: Fetch completed export artifact
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Job + artifact JSON
  /v1/compliance/audit/verify:
    post:
      tags: [Compliance]
      summary: Verify audit hash-chain integrity
      responses:
        "200":
          description: Verification result
  /v1/admin/api-keys:
    get:
      tags: [Admin]
      summary: List API keys (prefixes only)
      responses:
        "200":
          description: Keys
    post:
      tags: [Admin]
      summary: Create or rotate API key (raw returned once)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 2
                  maxLength: 64
                revokeKeyId:
                  type: string
                  format: uuid
                  description: Deactivate this key after minting the new one
                scopes:
                  type: array
                  minItems: 1
                  description: |
                    Least-privilege scope set. **Omitting this grants every
                    scope, including `admin`** — the historical behaviour of this
                    endpoint. Pass a narrower set for a key that only needs part
                    of the API, for example `["read"]` for a dashboard or
                    `["accounts_write","kyc_write"]` for an onboarding service.
                  items:
                    type: string
                    enum:
                      - read
                      - accounts_write
                      - kyc_write
                      - bind_write
                      - unlock_write
                      - admin
                      - compliance_export
                      - webhook_manage
      responses:
        "201":
          description: Created — the raw key is returned once and never again
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  prefix: { type: string }
                  apiKey:
                    type: string
                    description: Raw key. Shown once; store it immediately.
                  scopes: { type: array, items: { type: string } }
                  revokedKeyId: { type: string, nullable: true }
                  warning: { type: string }
                  scopeNotice:
                    type: string
                    description: Present only when the key was granted every scope.
  /v1/admin/api-keys/{id}:
    delete:
      tags: [Admin]
      summary: Revoke API key
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Revoked
  /v1/admin/signing/enable:
    post:
      tags: [Admin]
      summary: Enable HMAC request signing (secret once)
      responses:
        "200":
          description: HMAC secret
  /v1/admin/signing/disable:
    post:
      tags: [Admin]
      summary: Disable HMAC request signing
      responses:
        "200":
          description: Disabled
  /v1/admin/ip-allowlist:
    put:
      tags: [Admin]
      summary: Replace organization IP allowlist
      responses:
        "200":
          description: Allowlist
  /v1/admin/dpa/accept:
    post:
      tags: [Admin]
      summary: Accept Data Processing Agreement (sets dpaAcceptedAt)
      responses:
        "200":
          description: Acceptance recorded
  /v1/admin/webhooks:
    get:
      tags: [Admin]
      summary: List outbound webhooks
      responses:
        "200":
          description: Webhooks
    post:
      tags: [Admin]
      summary: Register outbound webhook (signing secret once)
      responses:
        "201":
          description: Registered
  /v1/admin/webhooks/{id}:
    delete:
      tags: [Admin]
      summary: Deactivate outbound webhook
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Deactivated
  /v1/admin/webhooks/deliveries:
    get:
      tags: [Admin]
      summary: Recent outbound delivery attempts
      description: |
        Diagnostics for a receiver that is rejecting events: status, attempt
        count, and the last error per delivery. Scoped to your own webhooks.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, failed, delivered, dead]
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "200":
          description: Delivery attempts
  /v1/admin/webhooks/deliveries/{id}/replay:
    post:
      tags: [Admin]
      summary: Resend a delivery that gave up
      description: |
        Re-queues a `failed` or `dead` delivery and resets its attempt counter,
        for use once the receiving system has been repaired. The signed body is
        stored with the delivery, so the replay carries the original payload and
        signature inputs rather than a regenerated approximation.

        Deliveries that already succeeded are refused — deduplicate on
        `X-BI-Delivery-Id` if you need to reprocess one.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Re-queued
        "409":
          description: Already delivered, or the payload is no longer available
  /v1/tenant:
    get:
      tags: [Admin]
      summary: Current organization posture (signing, allowlist, policy)
      responses:
        "200":
          description: Organization
  /v1/admin/country-policy:
    put:
      tags: [Admin]
      summary: Set the organization's jurisdiction policy
      description: |
        Enforced when binding a person to an account: a country on
        `blockedCountries`, or absent from a non-empty `allowedCountries`, is
        refused. The blocklist takes precedence. When either list is non-empty a
        subject whose country is unknown is also refused, so the control cannot
        be bypassed by an unresolvable jurisdiction. Empty lists mean no
        restriction. Omit a field to leave it unchanged.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                allowedCountries:
                  type: array
                  items: { type: string, minLength: 2, maxLength: 2 }
                  description: ISO-3166 alpha-2 codes
                blockedCountries:
                  type: array
                  items: { type: string, minLength: 2, maxLength: 2 }
      responses:
        "200":
          description: Policy applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  allowedCountries: { type: array, items: { type: string } }
                  blockedCountries: { type: array, items: { type: string } }
        "400": { description: A code was not a valid alpha-2 }
  /v1/accounts/{did}:
    get:
      tags: [Accounts]
      summary: Account status, lifecycle, and active bindings
      parameters:
        - name: did
          in: path
          required: true
          schema: { type: string }
          description: Account DID, URL-encoded (`did:antelope:<chain>:<name>`)
      responses:
        "200":
          description: Account with non-revoked bindings
        "404": { description: Account not found in this organization }
  /v1/kyc/status:
    get:
      tags: [KYC]
      summary: KYC status for a person
      parameters:
        - name: personDid
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Status, level, provider, and country
        "404": { description: Person not found in this organization }
  /v1/phone/otp/start:
    post:
      tags: [Phone]
      summary: Send an SMS one-time code
      description: |
        Rate limited per phone and per client IP. Requires `accounts_write`.
        On hosted sandbox and production the code is delivered by SMS only —
        it is never returned in the response body.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone:
                  type: string
                  description: E.164, or a local Ghana number
      responses:
        "200":
          description: Challenge issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  challengeId: { type: string, format: uuid }
                  expiresAt: { type: string, format: date-time }
                  phoneMasked: { type: string }
        "429": { description: Too many codes for this phone or IP }
  /v1/phone/otp/confirm:
    post:
      tags: [Phone]
      summary: Confirm an SMS code, returning a short-lived phone proof
      description: |
        Capped at 5 attempts per challenge. The returned `phoneProofToken` is
        accepted by `POST /v1/accounts/free` for 15 minutes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challengeId, code]
              properties:
                challengeId: { type: string, format: uuid }
                code: { type: string }
      responses:
        "200":
          description: Proof issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  phoneProofToken: { type: string }
                  expiresInSec: { type: integer }
                  personDid: { type: string, nullable: true }
                  softIdentity: { type: boolean }
        "400": { description: Invalid or expired code }
        "429": { description: Attempt cap reached for this challenge }
  /v1/aml/screen-account:
    post:
      tags: [AML]
      summary: KYT-style screen of a chain account
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [chainAccount]
              properties:
                chainAccount: { type: string }
      responses:
        "200":
          description: Score and labels
  /v1/aml/clear:
    post:
      tags: [AML]
      summary: Clear PEP / sanctions flags on a person
      description: |
        Compliance-officer action. Resets `pepFlag`, `sanctionsFlag`, and
        `riskScore`, and writes an `aml.cleared` audit entry with the reason.
        Requires `compliance_export`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subjectDid, reason]
              properties:
                subjectDid: { type: string }
                reason: { type: string, minLength: 3, maxLength: 500 }
      responses:
        "200": { description: Flags cleared }
        "404": { description: Person not found in this organization }
  /v1/billing/chain/confirm:
    post:
      tags: [Billing]
      summary: Settle an on-chain A payment for a Growth checkout
      security: []
      description: |
        Verifies a transfer to the treasury carrying this checkout's memo, then
        provisions the organization. A transaction id can only ever pay for one
        checkout. Returns 425 while the transfer is still reversible — retry
        shortly rather than treating it as a rejection.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sessionId, transactionId]
              properties:
                sessionId: { type: string }
                transactionId: { type: string }
                account:
                  type: string
                  description: Optional payer; when set the transfer must originate from it
      responses:
        "201": { description: Organization set up (API key returned once) }
        "400": { description: Payment not verified }
        "409": { description: Already provisioned, or transaction already used }
        "425": { description: Transfer seen but not yet irreversible — retry }
  /v1/billing/checkout/{sessionId}:
    get:
      tags: [Billing]
      summary: Poll checkout status
      security: []
      parameters:
        - name: sessionId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Status with a masked email; never includes the API key
        "404": { description: Unknown session }
  /.well-known/did.json:
    get:
      tags: [Resolution]
      summary: Issuer DID document (W3C did:web)
      security: []
      description: |
        Publishes the Ed25519 `assertionMethod` key used to sign issued
        credentials. A relying party needs only this document to verify a
        credential offline.
      responses:
        "200": { description: DID document }
  /people/{personId}/did.json:
    get:
      tags: [Resolution]
      summary: Person DID document (W3C did:web)
      security: []
      parameters:
        - name: personId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: DID document exposing KYC status only }
        "404": { description: Unknown person }
  /orgs/{orgId}/did.json:
    get:
      tags: [Resolution]
      summary: Organization DID document (W3C did:web)
      security: []
      parameters:
        - name: orgId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: DID document exposing KYB status only }
        "404": { description: Unknown organization }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        Organization API key. Optional HMAC via X-BI-Timestamp + X-BI-Signature.


        Authenticated requests are counted per organization, not per IP, so
        running more application servers does not divide your allowance. The
        quota is your plan's `limits.requestsPerMinute` (see GET
        /v1/billing/plans): Builder 120/min, Growth 600/min, Institutional
        3,000/min. Every authenticated response carries the remaining budget in
        `x-ratelimit-plan-remaining`, so a client can back off before it is
        refused. Exceeding it returns 429; `x-ratelimit-plan-reset` is the
        seconds until the window rolls over. The separate `x-ratelimit-*`
        headers describe a per-IP volumetric backstop set well above every plan.
  parameters:
    CorrelationId:
      name: X-Correlation-Id
      in: header
      schema: { type: string }
