> ## 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.

# order.completed

> Every item fulfilled — fetch the codes via the authenticated checkout endpoint.



## OpenAPI

````yaml api-reference/b2b.openapi.yaml webhook orderCompleted
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: {}
components:
  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.

````