> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wizzgift.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Full product catalog

> Same payload as `GET /b2b/products` — prices before your markup.



## OpenAPI

````yaml /api-reference/b2b.openapi.yaml get /retailer/v1/catalog
openapi: 3.1.0
info:
  title: Wizzgift B2B & Retailer API
  version: 1.0.0
  contact:
    name: Wizzgift Support
    url: https://www.wizzgift.com
  description: >
    # Overview


    Wizzgift sells digital products (gift cards, game top-ups, vouchers). This

    document describes the two **machine-to-machine surfaces** available to

    business partners. Both use the same API key and the same account — they are

    two *modes* of one business account:


    | Surface | Base path | Who pays | Typical use |

    |---|---|---|---|

    | **B2B** | `/b2b` | **You**, from your prepaid USD balance | Buy codes in
    bulk for your own use or distribution |

    | **Retailer** | `/retailer/v1` | **Your end-customer** (crypto invoice /
    hosted page) — or you, prepaid | Embed our catalog in your storefront; we
    fulfill, you relay the codes |


    ---


    # 1. Getting access


    1. **Create a Wizzgift account** at https://www.wizzgift.com and sign in.

    2. **Create an API key** in the dashboard (*Account → API Keys*). Creating a
       key automatically enrolls you as a **starter-tier** business with the
       **b2b** capability enabled.
    3. **Save the key immediately** — it looks like `wg_live_XXXXXXXX…` and is
       shown **exactly once**. Only a hash is stored on our side.
    4. To use the **retailer** surface, enable the retailer capability from the
       dashboard business settings (`POST /user/business/enable` with
       `{"capabilities": ["b2b", "retailer"]}`) or contact support. Calling a
       surface whose capability is off returns `403 FORBIDDEN` with a hint.
    5. Higher tiers (bigger limits, better pricing) are assigned by our team —
       contact support with your expected volumes.

    ## Authentication


    Send the key on **every request** in the `X-API-Key` header:


    ```

    curl https://api.wizzgift.com/b2b/account -H "X-API-Key: wg_live_..."

    ```


    Missing/invalid key → `401`. Suspended account → `403` on everything.


    ## API key permissions (scopes)


    Keys carry a scope list. Default scopes for a new key:

    `orders:create`, `orders:read`, `balance:read`, `products:read`,

    `refunds:create`, `refunds:read`, `deposits:create`, `deposits:read`.

    A key with `*` (or legacy `null`) has all scopes. Each operation below

    declares the scope it requires (`x-required-permission`). A missing scope

    returns `403 FORBIDDEN`.


    ---


    # 2. Quickstart — B2B (prepaid, 4 steps)


    **Step 1 — Fund your balance.** Create a deposit (amount is the USD value to

    credit). Pass `paymentMethodId` to get the payment invoice in the same call:


    ```

    POST /b2b/deposits

    { "amount": 500, "paymentCurrency": "USDT", "paymentMethodId": "pm_..." }

    ```


    → `201` with `payment.paymentAddress` / `payment.paymentUrl` and the exact

    crypto `payment.amount` to send. Deposits expire after ~60 minutes. (List

    methods first via `GET /retailer/v1/payment-methods`, or omit

    `paymentMethodId` and pick from the returned `availablePaymentMethods`.)


    **Step 2 — Wait for the credit.** Poll `GET /b2b/deposits/{depositId}` until

    `deposit.status` is `confirmed` (then your `GET /b2b/balance` goes up).


    **Step 3 — Browse the catalog.** `GET /b2b/products` returns every active

    product with SKUs (denomination ranges) and customer prices.


    **Step 4 — Place an order.** One call creates the order **and pays it from

    your balance**:


    ```

    POST /b2b/orders

    {
      "externalRef": "your-order-1042",
      "items": [
        { "productId": "prod_x", "skuId": "sku_y", "amount": 50, "quantity": 3 }
      ]
    }

    ```


    → `201` when the balance payment confirmed. Fulfillment is asynchronous —

    poll `GET /b2b/orders/{checkoutId}` until `status` is `completed` (or

    `partial`/`failed`), then read the card codes from

    `items[].fulfillments[]` (`cardCode`, `cardPin`, `cardUrl`).

    Failed items are refundable via `POST /b2b/refunds`.


    Optionally pass `callbackUrl` on the order to get a plain (unsigned,

    fire-and-forget) JSON POST when the order finishes — see *Webhooks* below.


    ---


    # 3. Quickstart — Retailer (your customer pays, 4 calls)


    **Step 1 — Catalog.** `GET /retailer/v1/catalog` → products + prices

    (same shape as `/b2b/products`). Add your own margin with `markupPercent`.


    **Step 2 — Create a checkout for your end-customer:**


    ```

    POST /retailer/v1/checkouts

    {
      "customerEmail": "buyer@example.com",
      "externalRef": "shop-order-889",
      "customerCountry": "DE",
      "markupPercent": 5,
      "items": [
        { "productId": "prod_x", "skuId": "sku_y", "amount": 25, "quantity": 1 }
      ]
    }

    ```


    **Step 3 — Let the customer pay.** Three options, pick one:


    * **Zero-code:** redirect the customer to `links.hostedPaymentUrl` — our
      hosted checkout page handles method choice and payment UI.
    * **One-call invoice:** include `"payment": { "paymentMethodId": "pm_..." }`
      in the create call — the response embeds the payment invoice
      (`paymentAddress`, crypto `amount`, `qr`, `expiresAt`, …) to render in
      your own UI.
    * **Two-step invoice:** `POST /retailer/v1/checkouts/{id}/payment` after
      creation. Call it again to re-quote an expired crypto invoice.
    * **Prepaid mode:** pass the *balance* payment method — the total is
      deducted from **your** business balance instantly and fulfillment starts
      (no customer invoice at all).

    **Step 4 — Get the codes.** Either poll `GET /retailer/v1/checkouts/{id}`

    (one endpoint for order status + payment state + codes once fulfilled), or

    configure **signed webhooks** (below) and fetch the checkout when you

    receive `order.completed`. Card codes are **never** included in webhook

    payloads — always fetch them via the authenticated GET.


    **Your margin:** `markupPercent` (per checkout, or account default via

    `PATCH /retailer/v1/account`) is added on top of our price. When items

    complete, your markup is **credited to your business balance** — track it

    under `markupEarnings` on `GET /retailer/v1/account`.


    ---


    # 4. Webhooks (retailer surface — signed & retried)


    ## Setup


    ```

    PUT /retailer/v1/webhook

    { "url": "https://yourshop.com/wizzgift/webhook", "events":
    ["order.completed", "order.failed"] }

    ```


    * URL must be **https** with a public hostname (no IPs / localhost).

    * `events` omitted or `null` = subscribe to all events.

    * The response includes `secret` (`whsec_…`) **only on first create** —
      store it. Get a new one any time with `POST /retailer/v1/webhook/rotate`
      (the old secret keeps verifying for 24 h so you can roll over smoothly).
    * Smoke-test with `POST /retailer/v1/webhook/test` (synchronous signed ping;
      returns the HTTP status your endpoint answered with).
    * Debug past deliveries: `GET /retailer/v1/webhook/deliveries`.


    You can also set `callbackUrl` **per checkout** — it overrides the endpoint

    URL for that checkout's events and is signed with the same business secret.

    A business-level endpoint must exist (it holds the secret); otherwise

    per-checkout URLs are ignored and you are on polling only.


    ## Events


    | Event | Fires when |

    |---|---|

    | `payment.detected` | Customer's transaction seen, awaiting confirmations |

    | `payment.confirmed` | Payment fully confirmed — fulfillment starts |

    | `order.completed` | Every item fulfilled — codes ready to fetch |

    | `order.partial` | Some items fulfilled, some failed |

    | `order.failed` | No item could be fulfilled |

    | `refund.initiated` / `refund.completed` / `refund.failed` | Refund
    lifecycle |

    | `ping` | Manual test via `/webhook/test` |


    ## Delivery contract


    * `POST` to your URL with JSON body `{ id, type, createdAt, data }`
      (see the *Webhooks* section of this spec for exact payloads).
    * Headers: `X-Wizzgift-Signature`, `X-Wizzgift-Event`,
    `X-Wizzgift-Delivery`.

    * **Retries:** 5 attempts with exponential backoff. Respond `2xx` fast
      (< 10 s); do heavy work asynchronously.
    * **Idempotency:** the delivery id (`whd_…`, also in `id` and
      `X-Wizzgift-Delivery`) is your dedup key — retries reuse it.
    * **Ordering is not guaranteed** — treat events as hints and read the
      authoritative state with `GET /retailer/v1/checkouts/{id}`.
    * **Card codes are never in webhook payloads.**


    ## Verifying signatures (do this on every delivery)


    Header format (Stripe-style): `X-Wizzgift-Signature: t=<unix-ms>,v1=<hex>`

    — during the 24 h after a secret rotation there are **two** `v1` entries

    (new secret first, then previous). Verification:


    1. Read the **raw** request body (before any JSON parsing).

    2. Parse the header: `t` = timestamp in **milliseconds**, collect all `v1`
    values.

    3. Compute `HMAC-SHA256(secret, "<t>.<rawBody>")` as lowercase hex.

    4. Constant-time-compare against **each** `v1`; any match = valid.

    5. Reject if `|now - t|` exceeds your tolerance (5 minutes recommended).


    ```js

    // Node.js example

    const crypto = require("node:crypto");


    function verifyWizzgiftSignature(header, rawBody, secret, toleranceMs =
    300_000) {
      const parts = header.split(",").map((p) => p.split("="));
      const t = Number(parts.find(([k]) => k === "t")?.[1]);
      const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
      if (!t || Math.abs(Date.now() - t) > toleranceMs) return false;
      const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
      return sigs.some((s) => {
        try { return crypto.timingSafeEqual(Buffer.from(s, "hex"), Buffer.from(expected, "hex")); }
        catch { return false; }
      });
    }

    ```


    ## B2B order callback (legacy, unsigned)


    On the **B2B** surface, `callbackUrl` on `POST /b2b/orders` triggers a

    single plain JSON POST when the order reaches `completed` / `partial` /

    `failed`. It is **not signed and not retried**, and — unlike retailer

    webhooks — it **does include full fulfillment data (card codes)**. Use an

    unguessable URL over https, or prefer polling. New integrations that need

    reliable notifications should use the retailer surface's signed webhooks.


    ---


    # 5. Idempotency


    Pass your own order id as `externalRef` on `POST /b2b/orders` and

    `POST /retailer/v1/checkouts`:


    * Same `externalRef` again on the **same** surface → `200` with the
      existing order (no duplicate charge).
    * Same `externalRef` on the **other** surface → `409 CONFLICT`
      (`externalRef` is unique per account **across** both surfaces).
    * You can also filter lists by it: `GET /b2b/orders?externalRef=…`.


    Safe retry recipe: on timeout/5xx, retry the same request with the same

    `externalRef` until you get a 2xx.


    ---


    # 6. Statuses & lifecycle


    **Checkout/order status:** `pending` → `processing` → `completed` |

    `partial` | `failed`; `expired` when never paid.

    **Item status:** `pending` → `processing` → `completed` | `failed`.

    **Payment record status:** `pending` → `confirming` → `confirmed` |
    `failed`.

    **Aggregate payment state:** `pending` | `partial` | `confirmed` | `failed`

    (crypto under/partial payments show progress in
    `payments[].partialPayment`).

    **Deposit status:** `pending` → `confirming` → `confirmed` | `failed` |
    `expired`.

    **Refund status:** `requested` → `auto_processing` → `completed`, or

    `awaiting_method` → `processing` → `completed` | `failed` (retryable) |

    `admin_review` | `cancelled_by_owner`.


    All timestamps are **epoch milliseconds** (UTC).


    ---


    # 7. Tiers, limits & rate limiting


    Every account has a tier whose limits you can read live from

    `GET /b2b/account` / `GET /retailer/v1/account` (`limits`, `usage`).

    Enforced limits include: `maxQuantityPerItem`, `maxItemsPerOrder`,

    `maxOrderAmountUSD`, `dailySpendCapUSD`, `monthlySpendCapUSD`,

    `maxDepositUSD`, `maxOpenCheckouts` + `dailyCheckoutCreations` (retailer),

    and `maxMarkupPercent`.


    Exceeding one returns **`422`** with code **`B2B_LIMIT_EXCEEDED`** and

    machine-readable `details` (`{ limit, limitValue, current?, requested?,

    window?, tier }`) so you can react programmatically.


    Requests are also rate-limited per account (tier-dependent). `429` means

    back off and retry with exponential delay.


    ---


    # 8. Errors


    All errors share one envelope:


    ```json

    { "error": { "code": "VALIDATION_ERROR", "message": "Invalid input" },
    "requestId": "…" }

    ```


    `error.details` (structured context) is included in non-production

    environments. Switch on `error.code`, not on messages:


    | HTTP | code | Meaning |

    |---|---|---|

    | 400 | `VALIDATION_ERROR` | Bad input; also "Balance payment failed" (see
    `details.reason`, e.g. insufficient balance) |

    | 401 | `UNAUTHORIZED` | Missing/invalid API key |

    | 403 | `FORBIDDEN` | Missing key scope, capability disabled, or account
    suspended |

    | 404 | `NOT_FOUND` | Unknown id — also returned for checkouts you don't own
    (no existence leak) |

    | 409 | `CONFLICT` | `externalRef` used on the other surface; concurrent
    payment race (retry) |

    | 422 | `B2B_LIMIT_EXCEEDED` | Tier limit hit (see `details`) |

    | 422 | `PAYMENT_ERROR` / `INSUFFICIENT_BALANCE` | Payment-level failures |

    | 429 | — | Rate limited (body: `{ "error": "Too many requests", "message":
    "…" }`) |

    | 502 | `EXTERNAL_SERVICE_ERROR` | Upstream provider failure — safe to retry
    |


    ---


    # 9. Pagination & conventions


    List endpoints take `limit` (1–100, default 20), `offset` (default 0),

    `sortBy` (`createdAt` | `totalAmount` | `status`, default `createdAt`) and

    `sortOrder` (`asc` | `desc`, default `desc`), and return

    `pagination: { total, limit, offset, hasMore }`.


    Monetary caps/limits are in **USD**. A checkout's `totalAmount` is in its

    `paymentCurrency`. B2B item `unitPrice` is expressed in the item's

    `costCurrency` (not necessarily the checkout currency). `amount` on an

    order item is the **denomination** the end-customer receives (e.g. a $50

    card → `amount: 50`), constrained by the SKU's `min`/`max`/`changeStep`.


    `sealCards: true` delivers codes *sealed*: the fulfillment carries a

    `giftCardId` instead of a plain `cardCode` and the recipient reveals it on

    our hosted page. Leave it `false` (default) to receive plain codes.
  x-logo:
    url: https://www.wizzgift.com/logo.png
    altText: Wizzgift
servers:
  - url: https://api.wizzgift.com
    description: Production
security:
  - ApiKeyAuth: []
tags:
  - name: B2B Orders
    description: Prepaid wholesale ordering — create pays instantly from your balance.
  - name: B2B Products
    description: Catalog with margin-applied prices (no vendor/cost internals).
  - name: B2B Balance & Account
    description: Balance, tier, limits, usage, pricing and rebate progress.
  - name: B2B Deposits
    description: >-
      Self-service balance top-ups. Any real payment method; capped by tier
      `maxDepositUSD`.
  - name: B2B Refunds
    description: Refunds for failed/partial B2B orders.
  - name: Retailer Catalog
    description: Products and payment methods to render in your storefront.
  - name: Retailer Checkouts
    description: Checkouts on behalf of your end-customers, with three payment options.
  - name: Retailer Refunds
    description: Refunds for retailer checkouts (ownership by your account).
  - name: Retailer Webhooks
    description: Signed, retried event notifications + endpoint management.
  - name: Retailer Account
    description: Account status, limits, markup earnings; default markup setting.
paths:
  /retailer/v1/catalog:
    get:
      tags:
        - Retailer Catalog
      summary: Full product catalog
      description: Same payload as `GET /b2b/products` — prices before your markup.
      operationId: getRetailerCatalog
      responses:
        '200':
          description: Product catalog.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductCatalog'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
components:
  schemas:
    ProductCatalog:
      type: object
      required:
        - products
        - ratesUpdatedAt
      properties:
        products:
          type: array
          items:
            $ref: '#/components/schemas/Product'
        ratesUpdatedAt:
          $ref: '#/components/schemas/EpochMs'
          description: When FX-based prices in this payload were last rebuilt.
    Product:
      type: object
      required:
        - productId
        - slug
        - name
        - skus
      properties:
        productId:
          type: string
        slug:
          type: string
          examples:
            - amazon-us
        name:
          type: string
          examples:
            - Amazon Gift Card (US)
        description:
          type: string
        instruction:
          type: string
          description: Redemption instructions to show the end-customer.
        disclosure:
          type: string
          description: Legal/terms text to show before purchase.
        imageUrl:
          type: string
        brandColor:
          type: string
        productCurrency:
          type: string
          description: Currency of the denomination (`amount`), e.g. USD for a $50 card.
        costCurrency:
          type: string
          description: Currency the price (`minCost`) is expressed in.
        countries:
          type: array
          items:
            type: string
          description: Countries where the product is redeemable (empty/omitted = global).
        blockedCountries:
          type: array
          items:
            type: string
          description: >-
            Customers from these countries cannot buy (enforced when
            `customerCountry` is sent).
        categories:
          type: array
          items:
            type: string
          examples:
            - - gaming
              - ecommerce
        skus:
          type: array
          items:
            $ref: '#/components/schemas/ProductSku'
        requiredFields:
          description: >
            Extra fields that must be collected from the end-customer for this
            product (e.g. player ID for a game top-up). Pass the collected
            values as the order item's `requiredFields` map.
        playstorePolicy:
          description: Marketplace policy flag (informational).
        isDigitalContent:
          type: boolean
        popularity:
          type: number
        totalSales:
          type: number
        rating:
          type: number
          description: Average review rating.
        reviewCount:
          type: integer
    EpochMs:
      type: integer
      format: int64
      description: Unix timestamp in milliseconds (UTC).
      examples:
        - 1753142400000
    Error:
      type: object
      required:
        - error
        - requestId
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: Machine-readable code — switch on this.
              examples:
                - VALIDATION_ERROR
                - UNAUTHORIZED
                - FORBIDDEN
                - NOT_FOUND
                - CONFLICT
                - B2B_LIMIT_EXCEEDED
            message:
              type: string
            details:
              type: object
              additionalProperties: true
              description: Structured context (non-production environments only).
        requestId:
          type: string
    ProductSku:
      type: object
      required:
        - id
        - min
        - max
        - minCost
      properties:
        id:
          type: string
          description: Pass as `skuId` when ordering.
        min:
          type: number
          description: Minimum denomination (`amount` lower bound).
        max:
          type: number
          description: Maximum denomination (`amount` upper bound).
        minCost:
          type: number
          description: >-
            Price (in `costCurrency`) for the `min` denomination — margin
            applied, before your retailer markup.
        changeStep:
          type: number
          description: Valid `amount` increments between `min` and `max`.
        description:
          type: string
  responses:
    Unauthorized:
      description: Missing, invalid or expired API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: UNAUTHORIZED
              message: Invalid or expired API key
            requestId: req_8f3k2
    Forbidden:
      description: >-
        Missing key permission, capability disabled for this account, or account
        suspended.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: FORBIDDEN
              message: Retailer API access is disabled for this account
            requestId: req_8f3k2
    RateLimited:
      description: Too many requests for your tier — back off and retry.
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                const: Too many requests
              message:
                type: string
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        API key (`wg_live_…`) created in the dashboard (*Account → API Keys*).
        Shown once at creation; only a hash is stored server-side.

````