openapi: 3.1.0

info:
  title: Verity Partner API
  version: "1.4.0"
  summary: Sell Verity raffle tickets from your own storefront, settled in USDC on Solana.
  description: |
    Integrate Verity raffles using your server-side partner API key. Buyers pay USDC on Solana in `verity_collected` mode. Use the environment-specific credentials issued by Verity.

    ## Changes in 1.4.0 (2026-10-28)

    - Added wallet-signed sellback offers, preparation, submission, status and cancellation.

    ## Changes in 1.3.0 (2026-10-28)

    Documentation sync; no API version or request contract changes.
    - Documented checkout responses, payment/refund states and all seven webhook events.
    - Published production URLs alongside staging.
    - Clarified pre-minted prize delivery and wallet-authenticated redemption.
    - Updated Malaysia tax-estimate guidance: the configurable RM500 CIF exemption affects import duty and sales tax, not excise.

    ## Recommended checkout flow

    1. Your server creates `POST /raffles/{raffleId}/checkouts` with an `Idempotency-Key`.
    2. Save the checkout ID. Pass only public payment details to the buyer's browser.
    3. Pay the quoted amount using one USDC `transferChecked` and exactly the returned `payment.memo`, which includes the checkout UUID.
    4. Register the transaction signature at `POST /raffles/{raffleId}/checkouts/{checkoutId}/payment` promptly, before `payment_deadline`. Do not wait for finalization for this flow.
    5. Poll the checkout or use `payment.updated` to fetch its state. Only purchase status `completed` means tickets were issued.

    Reuse the same checkout key and signature after timeouts. Never charge again while a payment is unresolved. `refund_only` means a later successful payment will be refunded instead of issuing tickets. Track `refund_operation_id` when returned. A refund operation reaches `completed` when the refund completes; do not confuse this with a completed purchase.

    ## Existing payment-first flow

    `POST /raffles/{raffleId}/tickets` remains supported. Read the raffle's payment block, pay using memo `{memo_prefix}:{external_user_ref}`, wait for finalized commitment, then submit the signature. Do not send an Idempotency-Key on this endpoint in Verity-collected mode. Do not submit one payment through both flows.

    Refunds are asynchronous. `refund_status` is `in_flight`, `paid` or `failed`; a signature alone does not confirm completion. Re-post the identical tickets request to read its refund outcome. Do not promise a refund for an unattributable payment or unresolved payer.

    ## Prizes and redemption

    Items are minted before being offered. In Verity-collected mode, `prize.won` follows delivery of the existing NFT to the winner's wallet. Use `redeemable` and `redeem_blocked_reason` for shipping; do not add Keep Item or Mint actions. For sellback, authenticate the owner wallet and use GET /buybacks on the wallet API. The older API-key buyback endpoint cannot authorize a wallet signature.

    Redemption uses the separate `/api/v1/redeem` base URL and a wallet session. The partner key must never be sent to the browser. Keep server credentials out of shared API clients and published examples.

    ## Shared raffles and webhooks

    Raffles share a ticket pool across platforms. Availability is advisory until reserved; no partial fills. Buyer references and wallet limits are scoped to your platform. Draw results and public winner names are shared; another platform's external buyer reference is not.

    Verify webhook signatures over the raw body; handle duplicates and out-of-order delivery. Fetch current state rather than treating an event as permission to charge again. Branch on error codes as well as HTTP status. Responses use `{ success, data?, error?, details? }`.
  contact:
    name: Verity engineering
  x-logo:
    altText: Verity

servers:
  - url: https://api-staging.theverity.xyz/api/v1/platform
    description: Staging — integrate and test here first
  - url: https://api.theverity.xyz/api/v1/platform
    description: Production

security:
  - PartnerApiKey: []

tags:
  - name: Raffles
    description: Discover the raffles your platform is attached to.
  - name: Checkouts
    description: Reserve tickets, submit payment and track confirmation.
  - name: Payments
    description: Track purchase and refund outcomes.
  - name: Tickets
    description: The money path. Issue tickets against a settled USDC payment.
  - name: Buyback
    description: Currently unavailable for partner winners. Respect the returned availability flags.
  - name: Redemption
    description: >
      Let a winner ship the physical prize home. **Optional.** A partner that builds

      nothing can link winners to Verity, who redeem there with the same wallet.


      The prize NFT is the ticket. Verity transfers it to the wallet that paid for the

      winning ticket at prize distribution, right after the draw, so it is already there

      when `prize.won` fires. The holder approves the returned transaction to hand over

      the prize token and pay the redemption fee. Use the returned order status to show

      progress; payment confirmation does not mean the shipment has been dispatched.


      These endpoints are called **from the winner's browser**, not from your server,

      and they do not use your API key. Base URL: `/api/v1/redeem`.


      1. `POST /auth/nonce`, then ask the wallet to sign the returned message.

      2. `POST /auth/verify` with the signature. Use the `accessToken` as a bearer token.

      3. `GET /prizes` to list what the wallet can ship.

      4. `POST /quote` with the shipping address to show the fee. Send `prizeId` for one
         prize, or `prizeIds` containing up to 50 distinct prizes. Mixed product types ship in one order with multiple packages.
      5. `POST /prepare` with the same selection. Ask the wallet to sign the
         returned transaction: each transaction hands over up to eight prize NFTs and pays its share of the total fee.
         Do not change it: Verity has already signed as fee payer, and any edit is refused.
      6. `POST /{redemptionId}/submit` with the signed transaction, inside about one
         minute of step 5.
      7. Save the redemption ID. `GET /redemptions/{redemptionId}` resumes that order
         after a reload or uncertain submit. Never prepare a new order while it is pending.
         For a batch with `payment_step_count > 1`, call `POST /{redemptionId}/next-transaction`,
         sign its `next.transaction`, and submit to the same order. Repeat until all steps are confirmed.
         If a submitted transaction is still pending, `next` is null. Do not show repeated
         wallet prompts while waiting for it to confirm; poll the order first. `payment_confirmed_steps`,
         `payment_paid_usd` and `payment_signatures` describe confirmed progress. Rejected or expired
         steps can be resumed without repeating finalized payments. Never assume a pending batch is unpaid.
         Booking starts only after every step finalizes. Keep a visible Resume redemption action after reload.
         Payment confirmation can return `paid_awaiting_label`; shipment booking continues asynchronously.
         Poll one request at a time with backoff (5–30 seconds), pause in hidden tabs,
         and honor `Retry-After` on 429. After a bounded window offer Check status.
         Reads (`GET`) have their own allowance of 30 a minute per wallet, separate from the
         60-an-hour budget for writes; a 429 carries `Retry-After`. A label means ready to ship,
         not shipped. A cancellation does not itself prove a refund or no charge.

      Give Verity your site's origin so the browser calls pass CORS. The shipping

      address goes from the browser to Verity. It never passes through you.


      Every reply uses the envelope `{ success, data }` on success and `{ success, error }` on
      failure. The fields named below sit inside `data`.

paths:
  /raffles:
    get:
      operationId: listRaffles
      tags: [ Raffles ]
      summary: List your raffles
      description: |
        Raffles your platform is attached to. Raffles belonging to other platforms are
        never visible and are indistinguishable from raffles that do not exist.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: true }
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: { $ref: "#/components/schemas/Raffle" }
                      total: { type: integer, description: Total across all pages. }
                    required: [ items, total ]
                required: [ success, data ]
              example:
                success: true
                data:
                  total: 1
                  items:
                    - id: 77
                      name: Submariner Drop
                      description: One Rolex Submariner. 5,000 tickets.
                      ticket_price_usd: "50.00"
                      start_time: "2026-09-10T12:00:00.000Z"
                      end_time: "2026-09-14T18:00:00.000Z"
                      min_total_entries: 1000
                      max_total_entries: 5000
                      per_wallet_limit: 50
                      buyback_enabled: true
                      buyback_rate_pct: 85
                      buyback_window_hours: 24
                      status: live
                      tickets_sold: 4210
                      published_at: "2026-09-10T12:00:00.000Z"
                      closed_at: null
                      cancelled_at: null
                      completed_at: null
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /raffles/{raffleId}:
    get:
      operationId: getRaffle
      tags: [ Raffles ]
      summary: Get a raffle, with payment parameters
      description: |
        Full raffle detail including prizes and the **`payment`** block.

        Read `payment` at purchase time. Never hardcode `treasury_ata`, `usdc_mint`
        or `memo_prefix`. For the checkout flow, use the checkout response's exact
        `payment.memo` instead of constructing a memo from this raffle response.
      parameters:
        - $ref: "#/components/parameters/RaffleId"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: true }
                  data: { $ref: "#/components/schemas/RaffleDetail" }
                required: [ success, data ]
              example:
                success: true
                data:
                  id: 77
                  name: Submariner Drop
                  ticket_price_usd: "50.00"
                  status: live
                  tickets_sold: 4210
                  buyback_enabled: true
                  buyback_rate_pct: 85
                  buyback_window_hours: 24
                  per_wallet_limit: 50
                  prizes:
                    - id: 41
                      draw_order: 1
                      prize_item:
                        id: 9
                        name: Rolex Submariner 124060
                        description: null
                        item_type: watch
                        category: Rolex
                        attributes:
                          - { key: reference, label: Reference, value: "124060" }
                          - { key: case_diameter, label: Case Diameter, value: 41 mm }
                          - { key: box_papers, label: Box & Papers, value: Full Set }
                        market_value_usd: 12500
                        image_urls: [ https://cdn.example.com/sub.png ]
                      winner: null
                      drawn_at: null
                      distributed_at: null
                      bought_back_at: null
                      mint_status: pending
                      mint_address: null
                      distribution_status: not_started
                      fairness: null
                  payment:
                    treasury_ata: 9xQeWvG816bUx9EPa2p1vN1ojrEkG1sPjW6MjdrHTGqm
                    usdc_mint: EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
                    decimals: 6
                    memo_prefix: rarible:77
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RaffleNotFound" }

  /raffles/{raffleId}/users/{externalUserRef}:
    get:
      operationId: getUserPosition
      tags: [ Raffles ]
      summary: A buyer's tickets and odds
      description: |
        One of your buyers' position in a raffle, for your own UI. Counts confirmed
        purchases made through **your** platform only; `odds_pct` is measured against
        total tickets sold across all platforms.

        An unknown ref returns zeros rather than a 404 — you cannot probe another
        platform's buyers with it.
      parameters:
        - $ref: "#/components/parameters/RaffleId"
        - name: externalUserRef
          in: path
          required: true
          description: The buyer's `external_user_ref`.
          schema: { type: string }
          example: usr_8812
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: true }
                  data:
                    type: object
                    properties:
                      tickets: { type: integer, example: 12 }
                      odds_pct: { type: number, format: double, example: 0.285 }
                    required: [ tickets, odds_pct ]
                required: [ success, data ]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RaffleNotFound" }

  /users/{externalUserRef}/checkouts:
    get:
      operationId: listBuyerCheckouts
      tags: [ Checkouts ]
      summary: Recover a buyer's checkout history
      description: Durable reservation-first history for this partner's buyer, newest first. Unknown buyers return an empty list. Refresh each checkout for its latest status. This does not include payment-first ticket purchases. Never create a new payment for an unresolved checkout after browser storage is cleared; reconcile its original payment first.
      parameters:
        - { name: externalUserRef, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, maximum: 100000, default: 0 } }
      responses:
        "200":
          description: A page of checkouts; add the number of returned items to offset while has_more is true.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { const: true }
                  data:
                    type: object
                    properties:
                      items: { type: array, items: { $ref: '#/components/schemas/PaymentOperation' } }
                      has_more: { type: boolean }
        "400": { description: Invalid pagination. }
        "401": { description: Partner API key required. }

  /users/{externalUserRef}/prizes:
    get:
      operationId: getUserPrizes
      tags: [ Raffles ]
      summary: A buyer's prizes, with buyback and redeem state
      description: |-
        Your buyer's prize history across your raffles, newest first, including bought-back and redeemed prizes. An unknown reference returns an empty array. References are scoped to your platform. Use redeemable and redeem_blocked_reason for shipping eligibility; the wallet API checks current ownership before preparing payment. For wallet-signed sellback, authenticate the wallet and fetch GET /buybacks. These legacy buyback fields describe the API-key endpoint only. Refresh when the buyer opens their prizes or receives prize.won.
      parameters:
        - name: externalUserRef
          in: path
          required: true
          description: The buyer's `external_user_ref`.
          schema: { type: string }
          example: usr_8812
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: true }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WonPrize" }
                required: [ success, data ]
        "401": { $ref: "#/components/responses/Unauthorized" }

  /raffles/{raffleId}/tickets:
    post:
      operationId: issueTickets
      tags: [ Tickets ]
      summary: Issue tickets against a settled USDC payment
      description: |
        **Existing payment-first flow.** Submit the signature of a finalized USDC transfer and receive
        ticket codes.

        ### Building the payment

        ```js
        import { createTransferCheckedInstruction, getAssociatedTokenAddressSync } from "@solana/spl-token";
        import { PublicKey, Transaction, TransactionInstruction, sendAndConfirmTransaction } from "@solana/web3.js";

        const MEMO_PROGRAM_ID = new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr");

        // Public payment details fetched by your server. Never expose your partner API key.
        const { treasury_ata, usdc_mint, decimals, memo_prefix } = detail.data.payment;

        // 2. Exact amount, fixed-point. Floats will drift you off by base units.
        function decimalToBaseUnits(value, decimals = 6) {
          const match = /^(\d+)(?:\.(\d+))?$/.exec(value);
          if (!match || !Number.isInteger(decimals) || decimals < 0 || decimals > 9) {
            throw new Error("Invalid token amount");
          }
          const fraction = match[2] ?? "";
          if (fraction.length > decimals && /[1-9]/.test(fraction.slice(decimals))) {
            throw new Error("Amount exceeds token precision");
          }
          return BigInt(match[1]) * 10n ** BigInt(decimals)
            + BigInt(fraction.slice(0, decimals).padEnd(decimals, "0") || "0");
        }

        const baseUnits = decimalToBaseUnits(detail.data.ticket_price_usd, decimals) * BigInt(qty);

        // 3. transferChecked (NOT transfer) + exactly one memo.
        const mint = new PublicKey(usdc_mint);
        const tx = new Transaction().add(
          createTransferCheckedInstruction(
            getAssociatedTokenAddressSync(mint, payer.publicKey),
            mint,
            new PublicKey(treasury_ata),
            payer.publicKey,
            baseUnits,
            decimals,
          ),
          new TransactionInstruction({
            keys: [],
            programId: MEMO_PROGRAM_ID,
            data: Buffer.from(`${memo_prefix}:${externalUserRef}`, "utf8"),
          }),
        );

        // 4. Wait for FINALIZED before submitting to Verity.
        const signature = await sendAndConfirmTransaction(connection, tx, [payer], {
          commitment: "finalized",
        });
        ```

        ### Timing

        Verity verifies the transaction at **`finalized`** commitment. Network confirmation
        can take time; a client timeout does not prove the purchase failed.

        - Wait for `finalized` yourself before calling this endpoint.
        - Set your HTTP timeout to **at least 30 seconds**. This is not a fast call.
        - `409 TX_NOT_FOUND` means "not visible yet, retry", not "invalid".

        ### Two `200` outcomes

        `replayed: false` — tickets issued now. `replayed: true` — this signature was
        already issued and you are receiving the original result. Both are successes.

        Retry only with the same signature, raffle, quantity and buyer. A changed purchase identity returns IDEMPOTENCY_KEY_REUSED. Do not send this payment through the checkout API as well.
      parameters:
        - $ref: "#/components/parameters/RaffleId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IssueTicketsRequest" }
            examples:
              standard:
                summary: A five-ticket purchase
                value:
                  tx_signature: 5NxJZ8bTqf9vK2mR7wYpL4dHnQ3sXcV6aB1eG8tU9yWfM2kPjR5hN7xC4vD6zA3bQ8sT1mL9pK2wY7nH4gF6
                  external_user_ref: usr_8812
                  display_name: alice
                  qty: 5
      responses:
        "200":
          description: Tickets issued, or replayed from a previous identical request.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: true }
                  data: { $ref: "#/components/schemas/IssueResult" }
                required: [ success, data ]
              examples:
                issued:
                  summary: Issued now
                  value:
                    success: true
                    data:
                      purchase_id: 9134
                      raffle_id: 77
                      qty: 5
                      total_usd: 250
                      tickets: [ R077-T00410, R077-T00411, R077-T00412, R077-T00413, R077-T00414 ]
                      replayed: false
                replayed:
                  summary: Retry of a request that already succeeded — also a success
                  value:
                    success: true
                    data:
                      purchase_id: 9134
                      raffle_id: 77
                      qty: 5
                      total_usd: 250
                      tickets: [ R077-T00410, R077-T00411, R077-T00412, R077-T00413, R077-T00414 ]
                      replayed: true
        "400":
          description: |
            Malformed request, or a terminal payment defect. `TX_USED_UNCHECKED_TRANSFER`,
            `TX_AMBIGUOUS_TRANSFER` and `TX_WRONG_MINT` **are refunded** despite the 4xx —
            check `refund_status`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              examples:
                unchecked_transfer:
                  summary: Used `transfer` instead of `transferChecked` — refunded
                  value:
                    success: false
                    error: TX_USED_UNCHECKED_TRANSFER
                    refund_status: in_flight
                    refund_tx_signature: null
                memo_mismatch:
                  summary: Memo missing, duplicated, or wrong — NOT refunded
                  value:
                    success: false
                    error: TX_MEMO_MISMATCH
                idempotency_key_sent:
                  summary: Sent an Idempotency-Key header
                  value: { success: false, error: TX_SIGNATURE_NOT_ALLOWED }
                validation:
                  summary: Bad body
                  value:
                    success: false
                    error: VALIDATION_ERROR
                    details:
                      - code: too_small
                        path: [ qty ]
                        message: Number must be greater than 0
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RaffleNotFound" }
        "409":
          description: |
            Conflicts with state. Some are retryable (`TX_NOT_FOUND`), some are refunds
            queued for refund processing (`TX_AMOUNT_MISMATCH`, sold-out cases), one is terminal
            (`IDEMPOTENCY_KEY_REUSED`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              examples:
                not_found_yet:
                  summary: Not finalized yet — retry the same signature
                  value: { success: false, error: TX_NOT_FOUND }
                amount_mismatch:
                  summary: Wrong amount (including overpayment) — refunded in full
                  value:
                    success: false
                    error: TX_AMOUNT_MISMATCH
                    refund_status: in_flight
                    refund_tx_signature: null
                sold_out:
                  summary: Paid, but the raffle filled mid-checkout — refund queued
                  value:
                    success: false
                    error: RAFFLE_MAX_ENTRIES_EXCEEDED
                    refund_status: in_flight
                    refund_tx_signature: null
                already_refunded:
                  summary: A previous attempt on this signature was already refunded
                  value:
                    success: false
                    error: PAYMENT_REFUNDED
                    reason: RAFFLE_MAX_ENTRIES_EXCEEDED
                    refund_status: paid
                    refund_tx_signature: 2VbQ8sT1mL9pK2wY7nH4gF6xJ5cR3dN8vB6aE9tU4yW7
                refund_in_flight:
                  summary: Re-posted before the refund completed — keep polling, never pay again
                  value:
                    success: false
                    error: PAYMENT_REFUND_IN_FLIGHT
                    reason: RAFFLE_MAX_ENTRIES_EXCEEDED
                    refund_status: in_flight
                    refund_tx_signature: null
                key_reused:
                  summary: This signature already bought tickets on another raffle
                  value: { success: false, error: IDEMPOTENCY_KEY_REUSED }
        "500": { $ref: "#/components/responses/ServerError" }
        "503": { $ref: "#/components/responses/TreasuryUnavailable" }

  /raffles/{raffleId}/prizes/{prizeId}/buyback:
    post:
      operationId: claimBuyback
      tags: [ Buyback ]
      summary: Compatibility buyback (API key cannot sign for a wallet)
      description: |
        Retained for existing integrations. Use the wallet-signed sellback endpoints for new integrations; this API-key endpoint cannot sign for the holder.
        Hide the action when `prize.won`
        reports `buyback.available: false` or the prize response reports
        `buyback.eligible: false`. An amount or deadline alone does not enable buyback.
        An otherwise eligible request returns `409 ITEM_WALLET_SIGNATURE_REQUIRED`.
        Processing, unavailable, expired or shipping-related errors may be returned first.
        Offer redemption when the prize response says it is redeemable.

        Existing clients can use the response formats below to recognize previous
        requests. They should not offer new buybacks through this endpoint.
      parameters:
        - $ref: "#/components/parameters/RaffleId"
        - name: prizeId
          in: path
          required: true
          schema: { type: integer, minimum: 1 }
          example: 41
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                external_user_ref:
                  type: string
                  minLength: 1
                  maxLength: 255
                  pattern: '^\S(.*\S)?$'
                  description: The winning buyer's ref, as sent at purchase time.
              required: [ external_user_ref ]
            example: { external_user_ref: usr_8812 }
      responses:
        "202":
          description: Payout processing; operation_id and status (`processing`) are returned with
            a null signature. Repeat the call to see progress (it replays, never pays twice); do not
            claim payment has completed until status is `completed` and signature is set.
        "200":
          description: |
            Paid. `replayed: true` means a previous identical call already paid — the
            amount and signature are the original ones, and no second payout happened.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: true }
                  data: { $ref: "#/components/schemas/BuybackResult" }
                required: [ success, data ]
              example:
                success: true
                data:
                  amount_usd: 10625
                  signature: 3QkR7wYpL4dHnQ3sXcV6aB1eG8tU9yWfM2kPjR5hN7xC
                  replayed: false
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Not entitled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              examples:
                not_winner:
                  summary: That ref did not win that prize
                  value: { success: false, error: RAFFLE_NOT_WINNER }
                unavailable:
                  summary: Your platform is not verity_collected
                  value: { success: false, error: RAFFLE_BUYBACK_UNAVAILABLE }
                not_owned:
                  summary: The prize token belongs to another buyer
                  value: { success: false, error: ITEM_NOT_OWNED }
        "404": { $ref: "#/components/responses/RaffleNotFound" }
        "409":
          description: |
            The offer cannot be taken right now. `ITEM_WALLET_SIGNATURE_REQUIRED` — the
            wallet is not supported for buyback through this endpoint. Other eligibility
            checks may return the following errors first.
            `ITEM_AWARD_PROCESSING` — the prize is still being transferred to the winner;
            retry after `prize.won`. `ITEM_BUYBACK_UNAVAILABLE` — no offer on this prize:
            buyback is off for the raffle or the item is held. `ITEM_BUYBACK_EXPIRED` —
            `expires_at` has passed; permanent. `STOCK_REDEMPTION_IN_PROGRESS` — the winner
            already started shipping this prize.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              examples:
                wallet_signature_required:
                  summary: Partner winner holding the token in their own wallet
                  value: { success: false, error: ITEM_WALLET_SIGNATURE_REQUIRED }
                expired:
                  value: { success: false, error: ITEM_BUYBACK_EXPIRED }
                award_processing:
                  value: { success: false, error: ITEM_AWARD_PROCESSING }
        "500": { $ref: "#/components/responses/ServerError" }

  /auth/nonce:
    servers: &redeemServers
      - url: https://api-staging.theverity.xyz/api/v1/redeem
        description: Staging
      - url: https://api.theverity.xyz/api/v1/redeem
        description: Production
    post:
      operationId: redeemAuthNonce
      tags: [ Redemption ]
      summary: Get the message a wallet signs to sign in
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ wallet, platform, domain, uri ]
              properties:
                wallet: { type: string, description: Base58 Solana address. }
                platform: { type: string, description: Your platform slug. }
                domain: { type: string, description: "Host of the page the winner is on, e.g. `shop.example.com`." }
                uri: { type: string, format: uri, description: Full URL of that page. Its host must equal `domain`. }
      responses:
        "200":
          description: The message to pass to the wallet's `signMessage`, unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      message: { type: string }
        "401": { description: "`WALLET_SIGNATURE_INVALID`. `wallet` is not a Solana address." }
        "403": { description: "`ORIGIN_NOT_ALLOWED`. The domain is not on your platform's allowlist." }
        "404": { description: "`PLATFORM_NOT_FOUND`." }

  /auth/verify:
    servers: *redeemServers
    post:
      operationId: redeemAuthVerify
      tags: [ Redemption ]
      summary: Trade the signature for a session token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ wallet, message, signature ]
              properties:
                wallet: { type: string }
                message: { type: string, description: Exactly as returned by `/auth/nonce`. }
                signature: { type: string, description: Base58 signature from the wallet. }
      responses:
        "200":
          description: A bearer token valid for one hour. There is no refresh token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      accessToken: { type: string }
                      expiresIn: { type: integer, example: 3600 }
                      wallet: { type: string }
        "401": { description: "`WALLET_SIGNATURE_INVALID`. Also returned for a message older than five minutes." }

  /prizes:
    servers: *redeemServers
    get:
      operationId: redeemListPrizes
      tags: [ Redemption ]
      summary: Prizes the wallet holds, has won, or has redeemed
      security: [ { WalletSession: [] } ]
      responses:
        "200":
          description: |-
            Each prize carries redeemable, can_ship_now and redeem_blocked_reason. Use redeemable for shipping eligibility. Pre-minted items have no Keep Item or Mint action; can_ship_now is retained for compatibility. Prizes shipped together share the same redemption order. The quote/prepare endpoints recheck eligibility and wallet ownership.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, const: true }
                  data:
                    type: object
                    properties:
                      prizes:
                        type: array
                        description: The `WonPrize` shape without `buyback`, `distributed_at` and `distribution_status`.
                        items: { $ref: "#/components/schemas/WonPrize" }
                    required: [ prizes ]
                required: [ success, data ]
        "401": { description: "`WALLET_SESSION_EXPIRED` or `WALLET_SIGNATURE_INVALID`." }

  /buybacks:
    servers: *redeemServers
    get:
      operationId: walletBuybackOffers
      tags: [ Buyback ]
      summary: Sellback offers for the signed-in wallet
      security: [ { WalletSession: [] } ]
      description: Use available and expires_at to enable sellback. Resume operation_id when awaiting_signature or processing. The prepare endpoint rechecks ownership and eligibility.
      responses:
        "200":
          description: Current offers and any previous attempts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { const: true }
                  data:
                    type: object
                    properties:
                      offers:
                        type: array
                        items:
                          type: object
                          required: [ prize_id, amount_usd, expires_at, available, operation_id, status ]
                          properties:
                            prize_id: { type: integer }
                            amount_usd: { type: number }
                            expires_at: { type: string, format: date-time }
                            available: { type: boolean }
                            operation_id: { type: [string, 'null'] }
                            status: { type: [string, 'null'], enum: [awaiting_signature, processing, completed, failed, null] }
        "401": { description: Wallet session required or expired. }

  /prizes/{prizeId}/sellback/prepare:
    servers: *redeemServers
    post:
      operationId: walletBuybackPrepare
      tags: [ Buyback ]
      summary: Prepare a wallet-signed sellback
      description: Returns the same active operation on retry. The NFT return and payout are atomic. Sign the returned transaction unchanged; do not broadcast it from the browser. Verity pays the network fee.
      security: [ { WalletSession: [] } ]
      parameters:
        - { name: prizeId, in: path, required: true, schema: { type: integer, minimum: 1 } }
      responses:
        "200": { $ref: '#/components/responses/WalletSellback' }
        "401": { description: Wallet session required or expired. }
        "404": { description: ITEM_NOT_FOUND — no offer for this wallet and prize. }
        "409": { description: ITEM_BUYBACK_EXPIRED, ITEM_BUYBACK_UNAVAILABLE, ITEM_OPERATION_IN_PROGRESS, STOCK_REDEMPTION_IN_PROGRESS or ITEM_AWARD_PROCESSING. Refresh offers and ownership. }

  /sellbacks/{sellbackId}:
    servers: *redeemServers
    get:
      operationId: walletBuybackStatus
      tags: [ Buyback ]
      summary: Check a sellback
      security: [ { WalletSession: [] } ]
      parameters:
        - { name: sellbackId, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,17}$' } }
      responses:
        "200": { $ref: '#/components/responses/WalletSellback' }
        "400": { description: Invalid input, ITEM_SIGNATURE_REQUIRED or ITEM_TRANSACTION_CHANGED. Nothing new was submitted. }
        "401": { description: Wallet session required or expired. }
        "404": { description: ITEM_NOT_FOUND — operation does not belong to this wallet. }
        "409": { description: ITEM_SIGNING_EXPIRED or ITEM_OPERATION_IN_PROGRESS. Refresh the operation before retrying. }

  /sellbacks/{sellbackId}/submit:
    servers: *redeemServers
    post:
      operationId: walletBuybackSubmit
      tags: [ Buyback ]
      summary: Submit the owner-signed sellback
      security: [ { WalletSession: [] } ]
      parameters:
        - { name: sellbackId, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,17}$' } }
      description: Save signed bytes before sending. Retries retain the original accepted transaction. After timeout, poll this operation; never create a new transfer. Once submitted, cancellation is unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ signedTransaction ]
              properties:
                signedTransaction: { type: string, minLength: 1, maxLength: 16000, description: Base64 transaction signed by the owner wallet. }
      responses:
        "200": { $ref: '#/components/responses/WalletSellback' }
        "400": { description: Invalid input, ITEM_SIGNATURE_REQUIRED or ITEM_TRANSACTION_CHANGED. Nothing new was submitted. }
        "401": { description: Wallet session required or expired. }
        "404": { description: ITEM_NOT_FOUND — operation does not belong to this wallet. }
        "409": { description: ITEM_SIGNING_EXPIRED or ITEM_OPERATION_IN_PROGRESS. Refresh the operation before retrying. }

  /sellbacks/{sellbackId}/cancel:
    servers: *redeemServers
    post:
      operationId: walletBuybackCancel
      tags: [ Buyback ]
      summary: Cancel an unsigned sellback
      security: [ { WalletSession: [] } ]
      parameters:
        - { name: sellbackId, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,17}$' } }
      responses:
        "200": { $ref: '#/components/responses/WalletSellback' }
        "400": { description: Invalid input, ITEM_SIGNATURE_REQUIRED or ITEM_TRANSACTION_CHANGED. Nothing new was submitted. }
        "401": { description: Wallet session required or expired. }
        "404": { description: ITEM_NOT_FOUND — operation does not belong to this wallet. }
        "409": { description: ITEM_SIGNING_EXPIRED or ITEM_OPERATION_IN_PROGRESS. Refresh the operation before retrying. }

  /prizes/{prizeId}/mint-now:
    servers: *redeemServers
    post:
      operationId: redeemMintNow
      tags: [ Redemption ]
      summary: Compatibility endpoint; not needed for pre-minted items
      description: New integrations should not call this endpoint or expose a Mint/Keep Item button. For
        already minted prizes it returns the existing mint_status and mint_address. Older prize
        records may still return the eligibility or processing errors below. Use redeemable from GET
        /prizes to offer shipping.
      security: [ { WalletSession: [] } ]
      parameters:
        - { name: prizeId, in: path, required: true, schema: { type: integer, minimum: 1 } }
      responses:
        "200": { description: "`{ mint_status, mint_address }`. Takes a few seconds." }
        "401": { description: "`WALLET_SESSION_EXPIRED` or `WALLET_SIGNATURE_INVALID`. Sign in again." }
        "403": { description: "`RAFFLE_NOT_WINNER`. Only the wallet that paid for the winning ticket may mint." }
        "404": { description: "`RAFFLE_NOT_FOUND`." }
        "409":
          {
            description: "`PRIZE_NOT_REDEEMABLE_ONLINE`, `PRIZE_VALUE_EXCEEDS_LIMIT`,
              `RAFFLE_BUYBACK_ALREADY_CLAIMED`, `RAFFLE_INVALID_STATUS` (the prize has not been
              distributed yet), or `PRIZE_MINT_IN_PROGRESS`."
          }
        "503": { description: "`PRIZE_MINT_FAILED`. The mint did not complete. Safe to call again." }
      deprecated: true

  /quote:
    servers: *redeemServers
    post:
      operationId: redeemQuote
      tags: [ Redemption ]
      summary: Fee lines for shipping a prize to an address
      security: [ { WalletSession: [] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Exactly one of `prizeId` and `prizeIds`.
              required: [ address ]
              properties:
                prizeId: { type: integer, description: One prize. }
                prizeIds:
                  type: array
                  description: Up to 50 distinct prizes held by the same wallet, including mixed product types.
                  minItems: 1
                  maxItems: 50
                  uniqueItems: true
                  items: { type: integer }
                address: { $ref: "#/components/schemas/RedeemShippingAddress" }
      responses:
        "200":
          {
            description: "`options[]`, one per DHL product, each with `packages` (weight in kg, dimensions in
              cm, and zero-based itemIndexes), `insuredValueUsd` (sum of all selected item values),
              `productCode`, `shippingFeeUsd`, `insuranceFeeUsd` (the insurance part of
              `shippingFeeUsd`, already inside it; null when DHL itemises none), `redemptionFeeUsd`
              (2 USD per prize), `totalFeeUsd` (shipping plus that fee; one DHL quote covers every
              package in the shipment) and `estDutiesTaxesUsd` (estimated import charges, excluded
              from totalFeeUsd and collected separately by DHL; null means unavailable, not zero).
              Malaysia uses Verity’s configured customs-category rates and whole-shipment CIF value
              (items, shipping and insurance without double-counting insurance). With the
              configurable RM500 exemption enabled, eligible shipments at or below the threshold are
              exempt from import duty and sales tax; excise still applies regardless of amount.
              Rates and exemptions can change: display the returned estimate rather than calculating
              it on the client. Actual customs charges can differ."
          }
        "400":
          {
            description: "`DHL_VALIDATION_FAILED`: DHL rejected the address, most often the postal code or city.
              `DHL_ROUTE_UNAVAILABLE`: the address is fine but DHL carries nothing from our vault to
              that destination; ask for another. Also `VALIDATION_ERROR` with `details` for a
              malformed body: both or neither of `prizeId` and `prizeIds`, more than 50 prizes, an
              empty `prizeIds`, or a prize named twice."
          }
        "401": { description: "`WALLET_SESSION_EXPIRED` or `WALLET_SIGNATURE_INVALID`. Sign in again." }
        "404":
          {
            description: "`RAFFLE_NOT_FOUND`. No prize has this id. Carries `details: { item: { prizeId } }`
              naming it."
          }
        "409":
          {
            description: "`PRIZE_NOT_MINTED`, `PRIZE_NOT_IN_WALLET`, `PRIZE_ALREADY_REDEEMED`,
              `PRIZE_NOT_REDEEMABLE_ONLINE` or `PRIZE_VALUE_EXCEEDS_LIMIT`. All but
              `PRIZE_NOT_REDEEMABLE_ONLINE` carry `details: { item: { prizeId } }` naming the prize;
              one refused prize refuses the whole order. `ORDER_VALUE_EXCEEDS_LIMIT` when the
              order's declared values add up to more than 25,000 USD (there are no per-profile
              limits). Selections may use multiple packages; use shipping-groups and the returned
              quote to describe the packing. `SHIPPING_DESTINATION_UNSUPPORTED` means the profile
              does not allow the country; `SHIPPING_PACKAGE_UNSUPPORTED` means the selected quantity
              exceeds its configured box capacities. Additional prize eligibility errors:
              `ITEM_NOT_IN_WALLET` (the token is not in the signed-in wallet),
              `ITEM_NOT_AVAILABLE_FOR_SHIPPING` (the item is not currently available for shipping)
              or `ITEM_OPERATION_IN_PROGRESS` (another item request is still processing; retry
              later). These refusals happen before payment preparation."
          }
        "429": { description: "`RATE_LIMITED`. Quote and prepare share 30 calls per wallet per hour." }
        "503": { description: "`DHL_UNAVAILABLE`. Try again shortly." }

  /prepare:
    servers: *redeemServers
    post:
      operationId: redeemPrepare
      tags: [ Redemption ]
      summary: Prepare the redemption transaction for wallet approval
      security: [ { WalletSession: [] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Exactly one of `prizeId` and `prizeIds`.
              required: [ address, productCode, shownShippingFeeUsd ]
              properties:
                prizeId: { type: integer, description: One prize. }
                prizeIds:
                  type: array
                  description: Up to 50 distinct prizes held by the same wallet, including mixed product types.
                  minItems: 1
                  maxItems: 50
                  uniqueItems: true
                  items: { type: integer }
                address: { $ref: "#/components/schemas/RedeemShippingAddress" }
                productCode: { type: string, description: From the quote option the winner chose. }
                shownShippingFeeUsd:
                  {
                    type: number,
                    description: Maximum shipping fee confirmed by the winner. The server charges its current DHL rate
                      if at or below this amount; a higher rate returns RATE_CHANGED and requires a
                      new quote and confirmation.
                  }
      responses:
        "200":
          description: |
            `{ redemption, transaction, lastValidBlockHeight }`. `transaction` is base64 and
            already signed by Verity as fee payer, so the winner needs no SOL. Pass it unchanged
            to the wallet's `signTransaction` and send the
            result to submit. It expires in about a minute. If the winner rejects the wallet
            prompt, offer the same transaction again. `redemption.items` lists the order's prizes;
            `redemption.raffle_prize_id` is the first one's.
        "400":
          {
            description: "`DHL_VALIDATION_FAILED`: DHL rejected the address, most often the postal code or city.
              `DHL_ROUTE_UNAVAILABLE`: the address is fine but DHL carries nothing from our vault to
              that destination; ask for another. Also `VALIDATION_ERROR` with `details` for a
              malformed body: both or neither of `prizeId` and `prizeIds`, more than 50 prizes, an
              empty `prizeIds`, or a prize named twice."
          }
        "401": { description: "`WALLET_SESSION_EXPIRED` or `WALLET_SIGNATURE_INVALID`. Sign in again." }
        "402": { description: "`INSUFFICIENT_USDC`." }
        "404":
          {
            description: "`RAFFLE_NOT_FOUND`. No prize has this id. Carries `details: { item: { prizeId } }`
              naming it."
          }
        "409":
          {
            description: "`RATE_CHANGED`, `REDEMPTION_IN_PROGRESS` (with `details.item` naming the prize already
              in an open order, including when two orders race for it), or any `/quote` refusal."
          }
        "429": { description: "`RATE_LIMITED`. Quote and prepare share 30 calls per wallet per hour." }
        "503": { description: "`DHL_UNAVAILABLE`. Try again shortly." }

  /{redemptionId}/next-transaction:
    servers: *redeemServers
    post:
      tags: [ Redemption ]
      summary: Resume an existing batch without repeating confirmed payments
      security: [ { WalletSession: [] } ]
      parameters:
        - { name: redemptionId, in: path, required: true, schema: { type: integer, minimum: 1 } }
      responses:
        "200":
          {
            description: "`{ next }`. next is null while a submitted step is confirming or no approval remains;
              poll the order to distinguish them. Otherwise `{ transaction, lastValidBlockHeight,
              stepIndex }`. Sign the returned transaction unchanged and POST it to the existing
              submit endpoint. A pending transaction is reused until finalized failure or expiry
              permits replacement."
          }
        "404": { description: "Order missing or belongs to another wallet." }
      operationId: redeemNextTransaction
  /{redemptionId}/submit:
    servers: *redeemServers
    post:
      operationId: redeemSubmit
      tags: [ Redemption ]
      summary: Send the signed transaction
      security: [ { WalletSession: [] } ]
      parameters:
        - { name: redemptionId, in: path, required: true, schema: { type: integer, minimum: 1 } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ signedTransaction ]
              properties:
                signedTransaction: { type: string, description: Base64 of the fully signed transaction. }
      responses:
        "200":
          description: |
            The redemption. `status` is one of: `label_created` once paid and booked;
            `paid_awaiting_label` when payment confirmed but the DHL booking has not completed yet,
            in which case payment is complete and Verity completes
            the booking, so keep polling `GET /redemptions/{redemptionId}`;
            `pending_payment` when the chain has not confirmed yet, in which case poll
            `GET /redemptions/{redemptionId}`.
        "400": { description: "`REDEEM_TX_MISMATCH`. The transaction is not the one Verity prepared." }
        "401": { description: "`WALLET_SESSION_EXPIRED` or `WALLET_SIGNATURE_INVALID`. Sign in again." }
        "404": { description: "`REDEMPTION_NOT_FOUND`. Also returned for another wallet's redemption." }
        "409":
          {
            description: "`REDEEM_TX_EXPIRED` or `REDEEM_TX_FAILED`. Nothing was charged. Call `/prepare`
              again."
          }

  /redemptions:
    servers: *redeemServers
    get:
      operationId: redeemListRedemptions
      tags: [ Redemption ]
      summary: The wallet's redemptions
      security: [ { WalletSession: [] } ]
      responses:
        "200":
          {
            description: "data.redemptions is an array, newest first. Each order includes items and payment-step
              progress."
          }
        "401": { description: "`WALLET_SESSION_EXPIRED` or `WALLET_SIGNATURE_INVALID`. Sign in again." }

  /redemptions/{redemptionId}:
    servers: *redeemServers
    get:
      operationId: redeemGetRedemption
      tags: [ Redemption ]
      summary: Status and tracking for one redemption
      security: [ { WalletSession: [] } ]
      parameters:
        - { name: redemptionId, in: path, required: true, schema: { type: integer, minimum: 1 } }
      responses:
        "200":
          {
            description: "data.redemption contains status, dhl_tracking_number, tracking_status, items and
              payment-step progress. Payment confirmation and label creation do not mean the parcel
              has been dispatched."
          }
        "401": { description: "`WALLET_SESSION_EXPIRED` or `WALLET_SIGNATURE_INVALID`. Sign in again." }
        "404": { description: "`REDEMPTION_NOT_FOUND`. Also returned for another wallet's redemption." }

  /raffles/{raffleId}/checkouts:
    post:
      operationId: createCheckout
      summary: Reserve tickets before collecting payment
      description: |
        Supported for both collection modes. Reuse the same Idempotency-Key on request
        retries. The response contains the immutable quote, payment_deadline and
        settlement_deadline. Verity-collected buyers must use the exact returned memo,
        which includes the checkout UUID. Never initiate payment after payment_deadline.
        The verified payer wallet is checked against the wallet limit before ticket
        confirmation; a paid checkout exceeding that limit is refunded.
        After settlement expires, inventory is released while unknown payments remain
        tracked. refund_pending/refunded includes refund_operation_id, which can be polled
        at GET /payments/{operationId} or acknowledged for partner-collected refunds.
        refund_only=true means a later successful payment will be refunded,
        not issued tickets. Keep polling; do not start a replacement payment.
      parameters:
        - $ref: "#/components/parameters/RaffleId"
        - in: header
          name: Idempotency-Key
          required: true
          schema: { type: string, maxLength: 200 }
          example: checkout-example-001
      requestBody:
        required: true
        content:
          application/json:
            example: { external_user_ref: buyer_123, display_name: Alex, qty: 2 }
            schema:
              type: object
              required: [ external_user_ref, qty ]
              properties:
                external_user_ref:
                  type: string
                  minLength: 1
                  maxLength: 255
                display_name:
                  type:
                    - string
                    - "null"
                  minLength: 1
                  maxLength: 64
                qty:
                  type: integer
                  minimum: 1
                  maximum: 1000
      responses:
        "201":
          {
            description: "Checkout reserved, or the original checkout replayed. Inspect status; no payment is
              implied.",
            content:
              {
                application/json:
                  {
                    example: {
                      success: true,
                      data: {
                        id: "a1765f38-8c06-46e4-bb5b-9913aa0c8ae9", request_key: checkout-example-001,
                        raffle_id: 77, purchase_id: null, status: awaiting_payment, qty: 2, total_usd: 100,
                        payment_deadline: "2026-10-28T10:05:00Z", settlement_deadline: "2026-10-28T10:15:00Z",
                        created_at: "2026-10-28T10:00:00Z", tickets: [], retry_allowed: false, error: null,
                        refund_only: false, refund_operation_id: null, source_payment_signature: null,
                        payment: {
                          treasury_ata: "<destination token account from API>", usdc_mint: "<token mint from API>",
                          decimals: 6, memo_prefix: "your-platform:77",
                          memo: "your-platform:77:buyer_123:a1765f38-8c06-46e4-bb5b-9913aa0c8ae9"
                        }
                      }
                    },
                    schema:
                      {
                        type: object,
                        properties: { success: { type: boolean, const: true }, data: { $ref: "#/components/schemas/CheckoutResult" } },
                        required: [ success, data ]
                      }
                  }
              }
          }
        "409":
          description: RAFFLE_PURCHASE_IN_PROGRESS, IDEMPOTENCY_KEY_REUSED, or raffle availability/limit
            errors. Resume an existing unresolved checkout instead of charging again.
        "503": { description: Payments temporarily unavailable; no payment initiated. }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "400":
          description: VALIDATION_ERROR or IDEMPOTENCY_KEY_REQUIRED (non-empty, at most 200 characters).
      tags:
        - Checkouts

  /raffles/{raffleId}/checkouts/{checkoutId}:
    get:
      operationId: getCheckout
      summary: Read checkout status without sending money
      parameters:
        - $ref: "#/components/parameters/RaffleId"
        - in: path
          name: checkoutId
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          {
            description: "Current payment operation. Tickets are present only when completed.",
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            success: { type: boolean, const: true },
                            data: { $ref: "#/components/schemas/PaymentOperation" }
                          },
                        required: [ success, data ]
                      }
                  }
              }
          }
        "404": { description: Checkout not found for this platform and raffle. }
        "401":
          $ref: "#/components/responses/Unauthorized"
      tags:
        - Checkouts

  /raffles/{raffleId}/checkouts/{checkoutId}/payment:
    post:
      operationId: submitCheckoutPayment
      summary: Register a payment for asynchronous verification
      description: |
        Verity-collected: send tx_signature as soon as the transaction is prepared/submitted,
        before payment_deadline; do not wait for finalization. Repeat with the same signature
        after timeouts. A different signature is accepted only after the previous attempt is
        proven invalid/failed and before cutoff. A late first receipt needs finalized block-time
        evidence of payment before cutoff; otherwise the verified payment is refund-only.
        Partner-collected: send an empty body to attest that this checkout was collected once;
        late attestations create a partner refund obligation. Never charge again on HTTP retries.
      parameters:
        - $ref: "#/components/parameters/RaffleId"
        - in: path
          name: checkoutId
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                tx_signature: { type: string, minLength: 64, maxLength: 88, pattern: "^[1-9A-HJ-NP-Za-km-z]+$" }
        required: true
      responses:
        "202":
          {
            description: Receipt recorded; poll the checkout or consume payment.updated.,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            success: { type: boolean, const: true },
                            data: { $ref: "#/components/schemas/PaymentOperation" }
                          },
                        required: [ success, data ]
                      }
                  }
              }
          }
        "409": { description: Attempt replacement is not allowed. }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "400":
          description: VALIDATION_ERROR, PAYMENT_SIGNATURE_REQUIRED, or TX_SIGNATURE_NOT_ALLOWED for the wrong
            settlement mode.
      tags:
        - Checkouts

  /payments/{operationId}:
    get:
      operationId: getPaymentOperation
      summary: Read your payment or refund obligation
      parameters:
        - in: path
          name: operationId
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          {
            description: "Operation status, amount, external_user_ref and parent_id. No signed transaction bytes
              are exposed.",
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            success: { type: boolean, const: true },
                            data: { $ref: "#/components/schemas/PaymentOperationDetail" }
                          },
                        required: [ success, data ]
                      }
                  }
              }
          }
        "404": { description: Operation not found for this platform. }
        "401":
          $ref: "#/components/responses/Unauthorized"
      tags:
        - Payments

  /payments/{operationId}/refund-confirmation:
    post:
      operationId: acknowledgePartnerRefund
      summary: Confirm a partner-collected refund was paid by your system
      description: |
        Only for partner-collected refund obligations. Call after your own idempotent refund
        succeeds. Repeating the same acknowledgement is safe. Never use this for Verity
        treasury refunds; Verity verifies those on-chain itself.
      parameters:
        - in: path
          name: operationId
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          {
            description: Refund acknowledgement recorded.,
            content:
              {
                application/json:
                  {
                    schema:
                      {
                        type: object,
                        properties:
                          {
                            success: { type: boolean, const: true },
                            data: { $ref: "#/components/schemas/PaymentOperation" }
                          },
                        required: [ success, data ]
                      }
                  }
              }
          }
        "404": { description: No matching partner-collected refund obligation. }
        "401":
          $ref: "#/components/responses/Unauthorized"
      tags:
        - Payments
  /shipping-groups:
    servers:
      - url: https://api-staging.theverity.xyz/api/v1/redeem
        description: Staging
      - url: https://api.theverity.xyz/api/v1/redeem
        description: Production
    post:
      operationId: redeemShippingGroups
      tags:
        - Redemption
      summary: Check packaging groups before requesting a quote
      security:
        - WalletSession: []
      description: Optional preview for a selection of owned prizes. Send exactly one of prizeId and
        prizeIds. The quote remains authoritative for destination eligibility and cost.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Exactly one of prizeId and prizeIds.
              properties:
                prizeId:
                  type: integer
                  minimum: 1
                prizeIds:
                  type: array
                  minItems: 1
                  maxItems: 50
                  uniqueItems: true
                  items:
                    type: integer
                    minimum: 1
      responses:
        "200":
          description: Packaging preview in data.groups; no payment or shipment is created.
        "400":
          description: VALIDATION_ERROR for an invalid selection.
        "401":
          description: WALLET_SESSION_EXPIRED or WALLET_SIGNATURE_INVALID.
        "409":
          description: One or more items are unavailable for shipping. Refusals match the quote eligibility
            checks.

webhooks:
  payment.updated:
    post:
      operationId: onPaymentUpdated
      summary: A durable purchase, refund or payout changed status
      description: |
        Deliveries may repeat or arrive out of order. Fetch GET /payments/{operationId}
        for authoritative state; an event is never permission to charge again.
        refund_pending includes refund_operation_id. For partner-collected refunds,
        pay once in your system then acknowledge that ID. Inventory release may emit
        reservation_released=true and refund_only=true while payment remains processing
        or review; this does not mean the payment failed or a refund has been sent.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
      responses:
        "200": { description: Acknowledged. }

  raffle.live:
    post:
      operationId: onRaffleLive
      summary: Selling has opened
      description: The raffle is now accepting ticket purchases.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelope"
            example:
              event: raffle.live
              data: { raffle_id: 77, platform_id: 3 }
      responses:
        "200": { description: Acknowledged. Any non-2xx is retried with backoff. }

  raffle.closed:
    post:
      operationId: onRaffleClosed
      summary: Selling has ended
      description: Stop accepting payments for this raffle immediately.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
            example:
              event: raffle.closed
              data: { raffle_id: 77, platform_id: 3 }
      responses:
        "200": { description: Acknowledged. }

  raffle.drawn:
    post:
      operationId: onRaffleDrawn
      summary: A prize winner has been drawn
      description: |
        Carries the VRF fairness proof, identical for every platform on the raffle.

        `winner_external_user_ref` is `null` when the winner is not one of your buyers,
        and no partner payload ever contains a wallet address. `winner_display_name` is
        the exception: it is shared with every platform so the result reads the same
        everywhere.

        Verify `vrf.provider` before presenting this as a fair draw — see the
        `fairness` schema.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
            example:
              event: raffle.drawn
              data:
                raffle_id: 77
                prize_id: 41
                winning_ticket: R077-T00412
                winner_display_name: alice
                winner_external_user_ref: usr_8812
                eligible_ticket_count: 4210
                vrf:
                  provider: orao
                  randomness_account: 7bXk...
                  tx_signature: 4Nc9...
                  random_value: "88231904772"
      responses:
        "200": { description: Acknowledged. }

  prize.won:
    post:
      operationId: onPrizeWon
      summary: One of your buyers won — your fulfilment handoff
      description: |
        **This is the only handoff you get for a winner.** When it arrives, present the
        buyback offer, and:

        - `verity_collected`: the prize token is already in the wallet that paid for the
          winning ticket when this event fires. The winner ships the prize themselves
          from their browser, by signing with that wallet against the Redemption
          endpoints below. Link them to your prizes page;
          `GET /users/{externalUserRef}/prizes` shows the state of each prize.
        - `partner_collected`: collect the shipping address yourself. No token is
          minted, and the Redemption endpoints refuse the prize.

        For wallet-signed sellback, sign in with the winning wallet and fetch
        `GET /buybacks` on the wallet API. Its `available` flag and expiry determine
        whether to show the action. The event's legacy buyback fields describe the
        older API-key endpoint only.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
            example:
              event: prize.won
              data:
                raffle_id: 77
                prize_id: 41
                prize_name: Rolex Submariner 124060
                external_user_ref: usr_8812
                display_name: alice
                buyback:
                  available: false
                  amount_usd: null
                  expires_at: null
      responses:
        "200": { description: Acknowledged. }

  raffle.cancelled:
    post:
      operationId: onRaffleCancelled
      summary: The raffle was cancelled — refunds are owed
      description: |
        Lists your buyers owed a refund. `refund_tx_signature` is **always `null`** here:
        this fires in the same transaction that creates the refund rows, before any money
        has moved. The real signature arrives later on `refund.sent`.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
            example:
              event: raffle.cancelled
              data:
                raffle_id: 77
                refunds:
                  - external_user_ref: usr_8812
                    tickets_purchased: 5
                    refund_amount_usd: 250
                    refund_tx_signature: null
      responses:
        "200": { description: Acknowledged. }

  refund.sent:
    post:
      operationId: onRefundSent
      summary: A refund landed on-chain
      description: A cancellation refund has confirmed for this buyer. For checkout refunds, track payment.updated and the refund payment operation instead.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelope" }
            example:
              event: refund.sent
              data:
                raffle_id: 77
                external_user_ref: usr_8812
                refund_amount_usd: 250
                signature: 8sT1mL9pK2wY7nH4gF6xJ5cR3dN8vB6aE9tU4yW7QbZ2
      responses:
        "200": { description: Acknowledged. }

components:
  securitySchemes:
    PartnerApiKey:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <your_api_key>`

        Keep this credential on your server, never in browser or mobile app code.
        A missing, invalid or disabled key returns `401 PLATFORM_KEY_INVALID`.
        Ask Verity to replace a lost or exposed key.

    WalletSession:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <accessToken>` from `POST /auth/verify`. Proves control of
        one wallet for one hour. Used only by the Redemption endpoints, from the browser.

  parameters:
    RaffleId:
      name: raffleId
      in: path
      required: true
      schema: { type: integer, minimum: 1 }
      example: 77

  responses:
    WalletSellback:
      description: The current sellback operation. Only completed confirms the payout. Processing includes uncertain confirmation; keep polling this ID. Failed attempts can be prepared again while the offer remains open.
      content:
        application/json:
          schema:
            type: object
            required: [success, data]
            properties:
              success: { const: true }
              data:
                type: object
                required: [id, prize_id, amount_usd, status, expires_at, signature, transaction, error]
                properties:
                  id: { type: string }
                  prize_id: { type: integer }
                  amount_usd: { type: number }
                  status: { type: string, enum: [awaiting_signature, processing, completed, failed] }
                  expires_at: { type: string, format: date-time, description: Deadline to submit the wallet approval; does not expire a submitted transaction. }
                  signature: { type: [string, 'null'] }
                  transaction: { type: [string, 'null'], description: Base64 transaction only while awaiting_signature. Sign unchanged and submit through the API. }
                  error: { type: [string, 'null'] }
    Unauthorized:
      description: Missing, malformed, wrong, or disabled API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example: { success: false, error: PLATFORM_KEY_INVALID }
    RaffleNotFound:
      description: |
        No such raffle — **or** a raffle your platform is not attached to, or one not yet
        announced. The three are intentionally indistinguishable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example: { success: false, error: RAFFLE_NOT_FOUND }
    ValidationError:
      description: Request body or parameters failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            success: false
            error: VALIDATION_ERROR
            details:
              - code: invalid_string
                path: [ external_user_ref ]
                message: external_user_ref must not have leading or trailing whitespace
    ServerError:
      description: |
        Verity-side failure. Safe to retry with the same body — every endpoint here is
        idempotent.

        **`error` on a 500 is a diagnostic string, not one of the documented codes.** Do
        not branch on its value; treat any 500 as "retry, then escalate".
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, const: false }
              error:
                type: string
                description: Unstructured diagnostic text. Log it; never match on it.
            required: [ success, error ]
          example: { success: false, error: Internal Server Error }
    TreasuryUnavailable:
      description: Verity's treasury cannot fund the payout right now. Retry shortly.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example: { success: false, error: INSUFFICIENT_TREASURY_USDC }

  schemas:
    RedeemShippingAddress:
      type: object
      required: [ recipientName, recipientEmail, recipientPhone, addressLine1, city, countryCode ]
      properties:
        recipientName: { type: string, maxLength: 100 }
        recipientEmail: { type: string, format: email, maxLength: 70 }
        recipientPhone: { type: string, minLength: 5, maxLength: 30 }
        addressLine1: { type: string, maxLength: 120 }
        addressLine2: { type: [ string, "null" ], maxLength: 120 }
        city: { type: string, maxLength: 45 }
        stateProvince: { type: [ string, "null" ], maxLength: 35 }
        postalCode: { type: [ string, "null" ], maxLength: 12 }
        countryCode:
          {
            type: string,
            description: ISO 3166 alpha-2,
            minLength: 2,
            maxLength: 2,
            example: US,
            pattern: "^[A-Za-z]{2}$"
          }
      description: Preserve building, unit and street information. Street lines are reflowed for DHL without
        dropping details; together they must fit into at most three 45-character carrier lines
        without splitting words. Address fields can pass individual length limits yet fail this
        combined check. Return validation messages to the user; do not silently truncate their
        address.

    WonPrize:
      type: object
      description: One prize a buyer has won, as `GET /users/{externalUserRef}/prizes` and the wallet
        API's `GET /prizes` return it.
      properties:
        id: { type: integer, description: The prize id used by the buyback and Redemption endpoints. }
        raffle_id: { type: integer }
        raffle_name: { type: string }
        prize_item:
          type: object
          properties:
            name: { type: string }
            description: { type: [ string, "null" ] }
            category: { type: [ string, "null" ] }
            market_value_usd: { type: number }
            image_urls: { type: array, items: { type: string } }
        won_at: { type: [ string, "null" ], format: date-time }
        distributed_at: { type: [ string, "null" ], format: date-time }
        distribution_status: { type: string, example: completed }
        mint_status:
          type: string
          enum: [ pending, minting, minted, failed, skipped ]
          description: "Prize delivery/mint compatibility state. Use redeemable for shipping eligibility
            rather than assuming a minted token is currently held by this wallet."
        mint_address: { type: [ string, "null" ] }
        redeemable:
          type: boolean
          description: The token exists and the holder can start a shipping request now.
        can_ship_now:
          type: boolean
          description: Compatibility field. False for pre-minted items; use redeemable and
            redeem_blocked_reason for shipping. Do not expose a Mint/Keep Item action.
        redeem_blocked_reason:
          type: [ string, "null" ]
          enum:
            [
              VALUE_EXCEEDS_LIMIT,
              SHIPPING_NOT_CONFIGURED,
              ITEM_NOT_READY,
              NOT_REDEEMABLE_ONLINE,
              BOUGHT_BACK,
              ALREADY_REDEEMED,
              REDEMPTION_IN_PROGRESS,
              NOT_DISTRIBUTED,
              NOT_MINTED,
              MINT_IN_PROGRESS,
              null
            ]
        redemption:
          type: [ object, "null" ]
          description: The buyer's live shipping request. Null when there is none, or when someone else
            holding the token made it.
          properties:
            id: { type: integer }
            status: { type: string, example: label_created }
            tracking_number: { type: [ string, "null" ] }
        buyback:
          type: object
          properties:
            enabled: { type: boolean }
            rate_pct: { type: [ number, "null" ] }
            amount_usd: { type: [ number, "null" ] }
            deadline: { type: [ string, "null" ], format: date-time }
            bought_back_at: { type: [ string, "null" ], format: date-time }
            amount_paid_usd: { type: [ number, "null" ] }
            eligible:
              {
                type: boolean,
                description: True exactly when the buyback call would succeed now. False for every partner buyer
                  today; see the Buyback endpoint.
              }

    Raffle:
      type: object
      description: A raffle as your platform sees it. Verity's own margin config is never exposed.
      properties:
        id: { type: integer, example: 77 }
        name: { type: string, example: Submariner Drop }
        description: { type: [ string, "null" ] }
        ticket_price_usd:
          type: string
          description: Decimal string. Multiply by `qty` in fixed-point, never floats.
          example: "50.00"
        start_time: { type: string, format: date-time }
        end_time: { type: string, format: date-time }
        min_total_entries:
          type: [ integer, "null" ]
          description: |
            Below this at close, the raffle does not draw: Verity either extends `end_time`
            (the raffle goes `live` again and you receive `raffle.live`: resume selling until
            the next `raffle.closed`) or cancels it (`raffle.cancelled`, every buyer
            refunded). Keep selling closed until one of those arrives.
        max_total_entries:
          type: [ integer, "null" ]
          description: Hard cap across all platforms. A sell-out mid-checkout is refunded.
        per_wallet_limit:
          type: [ integer, "null" ]
          description: |
            Counted **per paying wallet, within your platform**, in `verity_collected`
            mode. Not a global cap: the same wallet can buy up to this limit again on
            another platform selling the same raffle.
        buyback_enabled: { type: boolean }
        buyback_rate_pct:
          type: [ number, "null" ]
          description: Percent of prize market value paid on buyback.
          example: 85
        buyback_window_hours:
          type: [ integer, "null" ]
          description: Hours from prize distribution before the offer expires.
          example: 24
        status:
          type: string
          enum: [ scheduled, live, closed, completed, cancelled ]
          description: |
            Only `live` accepts ticket purchases.
            `scheduled` = published but not yet selling; it appears in `GET /raffles`, but
            `GET /raffles/{id}` returns 404 until it goes live. `closed` = selling has ended; Verity then draws, extends (the raffle
            returns to `live` and you receive `raffle.live` again) or cancels. The spec 1.1.x
            values `pending_decision`, `ready_to_draw` and `drawing` no longer occur; see
            `draw_in_progress`.
        draw_in_progress:
          type: boolean
          description: A draw is running right now.
        draw_started:
          type: boolean
          description: "Eligibility is frozen: a prize has been drawn or a draw is under way."
        can_draw:
          type: boolean
          description: "`closed`, not drawing, and `tickets_sold` meets `min_total_entries`."
        sales_enabled:
          type: boolean
          description: "Whether your platform may start new purchases. Requires live status too. When false, keep existing checkouts (until their original deadline), tickets, results and refunds accessible. New purchases return 409 RAFFLE_SALES_DISABLED."
        tickets_sold: { type: integer, description: Across all platforms. }
        published_at: { type: [ string, "null" ], format: date-time }
        closed_at: { type: [ string, "null" ], format: date-time }
        cancelled_at: { type: [ string, "null" ], format: date-time }
        completed_at: { type: [ string, "null" ], format: date-time }
      required: [ id, name, ticket_price_usd, status, tickets_sold ]

    RaffleDetail:
      allOf:
        - $ref: "#/components/schemas/Raffle"
        - type: object
          properties:
            prizes:
              type: array
              items: { $ref: "#/components/schemas/Prize" }
            payment:
              $ref: "#/components/schemas/PaymentBlock"

    PaymentBlock:
      type: object
      description: |
        Where and how to pay. **Read this per purchase; never hardcode it.** Present only
        for `verity_collected` platforms.
      properties:
        treasury_ata:
          type: string
          description: Destination token account. The single transfer leg must land here.
        usdc_mint:
          type: string
          description: The USDC mint to pass to `transferChecked`.
        decimals: { type: integer, const: 6 }
        memo_prefix:
          type: string
          description: |
            `{platform_slug}:{raffle_id}`. Your memo is this, then `:`, then the buyer's
            `external_user_ref`.
          example: rarible:77
      required: [ treasury_ata, usdc_mint, decimals, memo_prefix ]

    PrizeItem:
      type: object
      description: |
        The physical item behind a prize. Treat unknown fields as additive: more may
        appear, existing ones keep their meaning.
      properties:
        id: { type: integer }
        name: { type: string }
        description: { type: [ string, "null" ] }
        item_type:
          type: string
          pattern: "^[a-z][a-z0-9_]{0,31}$"
          description: What kind of item it is. Open-ended; today `tcg` or `watch`.
          example: tcg
        category:
          type: [ string, "null" ]
          description: The line within the type, e.g. `Pokemon`, `One Piece`, `Rolex`.
        attributes:
          type: array
          description: |
            Display details in the order to show them. Each row carries its own label,
            so render `label` / `value` as-is rather than switching on `key`; the set of
            keys differs by `item_type` and grows without notice.
          items:
            type: object
            required: [ key, label, value ]
            properties:
              key: { type: string, pattern: "^[a-z][a-z0-9_]{0,39}$", example: grade }
              label: { type: string, maxLength: 40, example: Grade }
              value: { type: string, maxLength: 200, example: PSA 10 }
        market_value_usd: { type: number }
        image_urls: { type: array, items: { type: string } }

    Prize:
      type: object
      properties:
        id: { type: integer }
        draw_order: { type: [ integer, "null" ] }
        prize_item: { $ref: "#/components/schemas/PrizeItem" }
        winner:
          type: [ object, "null" ]
          description: Null until drawn. `user_id` is opaque for a partner buyer.
          properties:
            user_id: { type: string, example: "p3:usr_8812" }
            display_name: { type: string }
            username:
              type: [ string, "null" ]
              description: Verity-native winners only; null for every partner buyer.
            avatar_url:
              type: [ string, "null" ]
              description: Verity-native winners only; null for every partner buyer.
        drawn_at: { type: [ string, "null" ], format: date-time }
        distributed_at:
          type: [ string, "null" ]
          format: date-time
          description: The token reaches the winner's wallet and the buyback window opens here.
        bought_back_at: { type: [ string, "null" ], format: date-time }
        mint_status:
          type: string
          description: |
            `minted` from distribution onward, with `mint_address` set; `pending` only
            between the draw and distribution.
        mint_address: { type: [ string, "null" ] }
        distribution_status: { type: string }
        fairness:
          type: [ object, "null" ]
          description: |
            VRF proof. Null until drawn.

            The selection rule is fixed and public: eligible tickets are ordered by ticket
            number and the winner sits at index
            `random_value % eligible_ticket_count`. Recomputing that independently needs
            the ordered eligible list, which is not published before the draw — so today
            the proof is evidence about the randomness, not an end-to-end proof of
            selection.
          properties:
            provider:
              type: string
              enum: [ orao, mock ]
              description: |
                `orao` — ORAO VRF on Solana. `tx_signature` is Verity's request transaction
                and `randomness_account` the account ORAO's fulfilment quorum signed into;
                `random_value` is the first 8 bytes of that 64-byte ed25519 signature,
                read as an unsigned big-endian integer. Read the account from any RPC and
                confirm it. (Switchboard, the previous provider, shut down in September 2026.)

                `mock` — locally generated, both proof fields `null`. Expected on staging.
                Proves nothing; never present it to users as a fair draw.
            randomness_account:
              type: [ string, "null" ]
              description: Null when `provider` is `mock`.
            tx_signature:
              type: [ string, "null" ]
              description: Null when `provider` is `mock`.
            random_value: { type: string }
            eligible_ticket_count:
              type: integer
              description: Across all platforms.
            winning_ticket_code: { type: string }

    IssueTicketsRequest:
      type: object
      properties:
        tx_signature:
          type: string
          minLength: 64
          maxLength: 88
          pattern: "^[1-9A-HJ-NP-Za-km-z]+$"
          description: |
            Base58 signature of the **finalized** USDC transfer. This doubles as the
            idempotency key — do not send an `Idempotency-Key` header.
        external_user_ref:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '^\S(.*\S)?$'
          description: |
            Your stable, unique, permanent id for this buyer — it is their identity for
            tickets, winning, buyback and refunds. A Rarible user id is ideal; an email or
            display name is not.

            No leading or trailing whitespace: `"alice"` and `" alice"` would otherwise
            collide onto one memo but two different users.
        display_name:
          type: [ string, "null" ]
          maxLength: 64
          description: Optional. Shown on public leaderboards.
        qty:
          type: integer
          minimum: 1
          maximum: 1000
          description: The paid amount must equal `ticket_price_usd × qty` exactly.
      required: [ tx_signature, external_user_ref, qty ]

    IssueResult:
      type: object
      properties:
        purchase_id: { type: integer }
        raffle_id:
          type: integer
          description: |
            Which raffle these tickets belong to — present so a `replayed` response is
            self-describing.
        qty: { type: integer }
        total_usd: { type: number }
        tickets:
          type: array
          items: { type: string, example: R077-T00410 }
          description: Ticket codes, formatted `R{raffle:3}-T{ticket:5}`.
        replayed:
          type: boolean
          description: |
            `true` when this signature had already been issued and you are receiving the
            original purchase. A success, not an error.
      required: [ purchase_id, raffle_id, qty, total_usd, tickets, replayed ]

    BuybackResult:
      type: object
      properties:
        amount_usd:
          type: number
          description: USDC paid to the winner.
        operation_id:
          {
            type: string,
            description: Identifier of the queued payout. Repeat the buyback call to see its progress.
          }
        status: { type: string, description: "`processing`, `completed` or `failed`." }
        signature:
          type: [ string, "null" ]
          description: On-chain payout signature, to the wallet that paid for the winning ticket.
        replayed: { type: boolean }
      required: [ amount_usd, signature, replayed ]

    WebhookEnvelope:
      type: object
      description: |
        Every webhook uses this envelope.

        **Headers:** `x-verity-event: <event name>` and `x-verity-signature: <hex>`.

        The signature is HMAC-SHA256 of the **exact raw body bytes** with your webhook
        secret, hex encoded. Verify before parsing:

        ```js
        import { createHmac, timingSafeEqual } from "node:crypto";

        function verify(rawBody, header, secret) {
          const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
          const a = Buffer.from(expected), b = Buffer.from(header ?? "");
          return a.length === b.length && timingSafeEqual(a, b);
        }
        ```

        Respond **2xx** to acknowledge. Non-2xx and timeouts are retried with backoff, so
        your handler must be idempotent. You only ever receive data about your own buyers.
      properties:
        event:
          type: string
          enum:
            [
              payment.updated,
              raffle.live,
              raffle.closed,
              raffle.drawn,
              prize.won,
              raffle.cancelled,
              refund.sent
            ]
        data: { type: object }
      required: [ event, data ]

    ErrorResponse:
      type: object
      description: |-
        Error envelope. Branch on error and inspect refund_status where present; validation errors may include details. Not every rejected request creates a refund.
      properties:
        success: { type: boolean, const: false }
        error:
          type: string
          description: |-
            Machine-readable error code. Handle known codes and provide a fallback for new codes. Attributed, refundable payment-first failures carry refund_status; refund_tx_signature alone is not proof of completion. PAYMENT_REFUNDED means paid; PAYMENT_REFUND_IN_FLIGHT means wait; PAYMENT_REFUND_FAILED requires support. TX_NOT_FOUND may be retried with the same signature. TX_MEMO_MISMATCH and an unresolved payer require support if funds moved.
          examples:
            - VALIDATION_ERROR
            - PAYMENT_NOT_FOUND
            - PAYMENT_RETRY_NOT_ALLOWED
            - RAFFLE_PURCHASE_IN_PROGRESS
            - ITEM_WALLET_SIGNATURE_REQUIRED
            - PAYMENT_REFUND_IN_FLIGHT
        details:
          type: array
          description: Validation details, when supplied.
          items: { type: object }
        reason:
          type: string
          description: |
            On a re-post of an already-refunded signature: the `error` the first response
            carried (why tickets could not be issued).
        refund_status:
          type: string
          enum: [ in_flight, paid, failed ]
          description: |
            Present on every refundable failure. `in_flight`: queued or sent, not yet
            confirmed. `paid`: confirmed, `refund_tx_signature` set. `failed`: the payout
            failed and Verity ops will retry it; the obligation stands.
        refund_tx_signature:
          type: [ string, "null" ]
          description: On-chain signature of the automatic refund once it is `paid`; `null` before.
      required: [ success, error ]
    PaymentOperation:
      type: object
      properties:
        id:
          type: string
          format: uuid
        request_key:
          type: string
        raffle_id:
          type:
            - integer
            - "null"
        purchase_id:
          type:
            - integer
            - "null"
        status:
          type: string
          enum:
            - queued
            - awaiting_payment
            - processing
            - paid
            - completed
            - failed
            - cancelled
            - refund_pending
            - refunded
            - review
        qty:
          type:
            - integer
            - "null"
        total_usd:
          type: number
        payment_deadline:
          type: string
          format: date-time
        settlement_deadline:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        tickets:
          type: array
          description: Only populated for a completed ticket purchase.
          items:
            type: object
            properties:
              ticket_no:
                type: integer
              code:
                type: string
            required:
              - ticket_no
              - code
        retry_allowed:
          type: boolean
          description: Inspect the current error and deadline before retrying; never replace an unresolved
            payment.
        error:
          type:
            - string
            - "null"
        refund_only:
          type: boolean
        refund_operation_id:
          type:
            - string
            - "null"
          format: uuid
        source_payment_signature:
          type:
            - string
            - "null"
          description: Source payment identity when available on a refund operation.
      required:
        - id
        - request_key
        - raffle_id
        - purchase_id
        - status
        - qty
        - total_usd
        - payment_deadline
        - settlement_deadline
        - created_at
        - tickets
        - retry_allowed
        - error
        - refund_only
        - refund_operation_id
        - source_payment_signature
    CheckoutResult:
      allOf:
        - $ref: "#/components/schemas/PaymentOperation"
        - type: object
          properties:
            payment:
              oneOf:
                - allOf:
                    - $ref: "#/components/schemas/PaymentBlock"
                    - type: object
                      properties:
                        memo:
                          type: string
                          description: Use verbatim; includes the checkout UUID.
                      required:
                        - memo
                - type: "null"
          required:
            - payment
    PaymentOperationDetail:
      allOf:
        - $ref: "#/components/schemas/PaymentOperation"
        - type: object
          properties:
            kind:
              type: string
              enum:
                - native_purchase
                - partner_purchase
                - refund
                - payout
            parent_id:
              type:
                - string
                - "null"
              format: uuid
            external_user_ref:
              type:
                - string
                - "null"
          required:
            - kind
            - parent_id
            - external_user_ref
