# Get account overview (status, tier, limits, usage, pricing, rebates)
Source: https://docs.wizzgift.com/api-reference/b2b-balance-&-account/get-account-overview-status-tier-limits-usage-pricing-rebates
/api-reference/b2b.openapi.yaml get /b2b/account
One-call integrator overview: profile status, capabilities, tier and
effective limits, spend usage vs caps, balance, your pricing
(`marginDiscountPercent` off catalog prices), markup earnings and
rebate-offer progress.
# Get current balance
Source: https://docs.wizzgift.com/api-reference/b2b-balance-&-account/get-current-balance
/api-reference/b2b.openapi.yaml get /b2b/balance
# Create a balance top-up (optionally with payment invoice in one call)
Source: https://docs.wizzgift.com/api-reference/b2b-deposits/create-a-balance-top-up-optionally-with-payment-invoice-in-one-call
/api-reference/b2b.openapi.yaml post /b2b/deposits
`amount` is the **USD value to credit** to your balance. You pay in
`paymentCurrency` — the response's `paymentAmount` is the converted
amount due. Include `paymentMethodId` to also create the provider
invoice in the same round trip; otherwise pick from the returned
`availablePaymentMethods` and pay via a follow-up (the deposit expires
after ~60 minutes unpaid). Each deposit is capped by your tier's
`maxDepositUSD`. The balance payment method cannot be used here.
# Get deposit + payment state (poll until confirmed)
Source: https://docs.wizzgift.com/api-reference/b2b-deposits/get-deposit-+-payment-state-poll-until-confirmed
/api-reference/b2b.openapi.yaml get /b2b/deposits/{depositId}
# List deposits
Source: https://docs.wizzgift.com/api-reference/b2b-deposits/list-deposits
/api-reference/b2b.openapi.yaml get /b2b/deposits
# Get order status, fulfillment details and card codes
Source: https://docs.wizzgift.com/api-reference/b2b-orders/get-order-status-fulfillment-details-and-card-codes
/api-reference/b2b.openapi.yaml get /b2b/orders/{checkoutId}
Poll this after creating an order. When `status` is `completed` (or
`partial`), read codes from `items[].fulfillments[]`. Vendor errors are
mapped to customer-safe messages. Orders belonging to another account
(or to the retailer surface) return `404`.
# List orders
Source: https://docs.wizzgift.com/api-reference/b2b-orders/list-orders
/api-reference/b2b.openapi.yaml get /b2b/orders
Paginated order history, scoped to the B2B surface (retailer checkouts
never appear here). Rows are summaries — fetch the detail endpoint for
items and codes.
# Place an order (create + pay from balance in one call)
Source: https://docs.wizzgift.com/api-reference/b2b-orders/place-an-order-create-+-pay-from-balance-in-one-call
/api-reference/b2b.openapi.yaml post /b2b/orders
Creates a checkout and immediately pays it from your prepaid USD
balance. On success (`201`) fulfillment starts asynchronously — poll
`GET /b2b/orders/{checkoutId}` for codes.
* **Idempotent** via `externalRef`: a repeated ref returns `200` with
the existing order. A ref already used on the retailer surface
returns `409`.
* Tier checks: item count, per-line quantity, order USD amount, daily
and monthly spend caps → `422 B2B_LIMIT_EXCEEDED` on violation.
* Insufficient balance → `400 VALIDATION_ERROR` with
`details.reason` — the order is not left open.
# Get one product by slug or productId
Source: https://docs.wizzgift.com/api-reference/b2b-products/get-one-product-by-slug-or-productid
/api-reference/b2b.openapi.yaml get /b2b/products/{slug}
# List all products (public catalog cache)
Source: https://docs.wizzgift.com/api-reference/b2b-products/list-all-products-public-catalog-cache
/api-reference/b2b.openapi.yaml get /b2b/products
Full catalog with margin-applied prices. Identical payload to
`GET /retailer/v1/catalog`. Cached server-side; `ratesUpdatedAt` tells
you when FX-based prices were last rebuilt.
# Get refund status
Source: https://docs.wizzgift.com/api-reference/b2b-refunds/get-refund-status
/api-reference/b2b.openapi.yaml get /b2b/refunds/{refundId}
# Request a refund for an order
Source: https://docs.wizzgift.com/api-reference/b2b-refunds/request-a-refund-for-an-order
/api-reference/b2b.openapi.yaml post /b2b/refunds
Requests a refund for the failed portion of an order. Auto-refund to
the original payment (your balance) is attempted first; anything
remaining goes through the refund method flow. A checkout can have at
most one active refund.
# API reference
Source: https://docs.wizzgift.com/api-reference/introduction
Base URLs, authentication, and the interactive playground.
The reference documents every endpoint on both surfaces, generated from the OpenAPI 3.1 specification. Each page shows request and response schemas, per-field documentation, generated code examples, and an interactive playground.
## Base URLs
| Environment | URL |
| ----------- | -------------------------- |
| Production | `https://api.wizzgift.com` |
## Authentication
Send your API key on every request:
```bash theme={null}
curl https://api.wizzgift.com/b2b/account \
-H "X-API-Key: wg_live_..."
```
Each endpoint requires a [key scope](/authentication#key-scopes) — the required scope is noted on the endpoint page. In the playground, paste your key into the `X-API-Key` field.
## How the reference is organized
* **B2B groups** (`/b2b/*`) — prepaid ordering: orders, products, balance and account, deposits, refunds.
* **Retailer groups** (`/retailer/v1/*`) — reseller checkouts: catalog, checkouts and payments, refunds, webhook management, account.
* **Webhook payloads** — the messages Wizzgift sends *to your server*, documented from the spec's `webhooks` section.
## Conventions in the schemas
* Timestamps are epoch milliseconds (UTC).
* Nullable fields are typed as `string | null` (and similar) in the schemas.
* `404` is returned both for unknown ids and for resources owned by another account.
* The `429` rate-limit response uses a flat body, not the standard error envelope — see [errors](/guides/errors).
# Get account overview (adds open-checkout usage and markup ceiling)
Source: https://docs.wizzgift.com/api-reference/retailer-account/get-account-overview-adds-open-checkout-usage-and-markup-ceiling
/api-reference/b2b.openapi.yaml get /retailer/v1/account
Mirror of `GET /b2b/account`, plus `usage.openCheckouts` (current vs
cap) and `pricing.maxMarkupPercent`.
# Set your default markup percent
Source: https://docs.wizzgift.com/api-reference/retailer-account/set-your-default-markup-percent
/api-reference/b2b.openapi.yaml patch /retailer/v1/account
`defaultMarkupPercent` is applied to every checkout that does not send
an explicit `markupPercent`; capped by the tier's `maxMarkupPercent`.
# Full product catalog
Source: https://docs.wizzgift.com/api-reference/retailer-catalog/full-product-catalog
/api-reference/b2b.openapi.yaml get /retailer/v1/catalog
Same payload as `GET /b2b/products` — prices before your markup.
# Get one product by slug or productId
Source: https://docs.wizzgift.com/api-reference/retailer-catalog/get-one-product-by-slug-or-productid
/api-reference/b2b.openapi.yaml get /retailer/v1/catalog/{idOrSlug}
# List all public payment methods
Source: https://docs.wizzgift.com/api-reference/retailer-catalog/list-all-public-payment-methods
/api-reference/b2b.openapi.yaml get /retailer/v1/payment-methods
Render a method picker before any checkout exists. Per-checkout
eligibility (minimum amounts) comes back with each checkout response
as `availablePaymentMethods[].eligible`.
# Create a checkout for an end-customer
Source: https://docs.wizzgift.com/api-reference/retailer-checkouts/create-a-checkout-for-an-end-customer
/api-reference/b2b.openapi.yaml post /retailer/v1/checkouts
Creates an order on behalf of your end-customer. Idempotent via
`externalRef` (same-surface replay → `200`; used on B2B → `409`).
* `customerCountry` is the **end-customer's** country (never inferred
from your server's IP) — blocked-country rules apply when present.
* `markupPercent` overrides your account default; both are capped by
your tier's `maxMarkupPercent`.
* Include `payment.paymentMethodId` for a one-call invoice; the
*balance* method prepays from your own balance instead (must
confirm instantly or the call fails with `400`).
* Tier caps on open checkouts and daily creations → `422`.
# Create (or refresh) the payment invoice for a checkout
Source: https://docs.wizzgift.com/api-reference/retailer-checkouts/create-or-refresh-the-payment-invoice-for-a-checkout
/api-reference/b2b.openapi.yaml post /retailer/v1/checkouts/{checkoutId}/payment
Two-step flow: create the invoice after the customer picks a method.
The amount is always the full remaining balance (split payments are
not exposed on this surface). Re-calling with the same method
refreshes an expired crypto invoice (provider-side dedup returns the
same invoice with fresh data). The *balance* method deducts from your
business balance and must confirm instantly, otherwise `400` with
`details.reason`.
# Get checkout — order status, payment state and codes (one poll endpoint)
Source: https://docs.wizzgift.com/api-reference/retailer-checkouts/get-checkout-—-order-status-payment-state-and-codes-one-poll-endpoint
/api-reference/b2b.openapi.yaml get /retailer/v1/checkouts/{checkoutId}
Everything in one response: order status, aggregate payment state
(including crypto partial-payment progress), items, and card codes in
`items[].fulfillments[]` once fulfilled (unless sealed). This is also
the endpoint to call when a webhook arrives. Foreign or B2B checkout
ids return `404`.
# List checkouts
Source: https://docs.wizzgift.com/api-reference/retailer-checkouts/list-checkouts
/api-reference/b2b.openapi.yaml get /retailer/v1/checkouts
Paginated, scoped to the retailer surface (B2B orders never bleed in).
# Get refund status
Source: https://docs.wizzgift.com/api-reference/retailer-refunds/get-refund-status
/api-reference/b2b.openapi.yaml get /retailer/v1/refunds/{refundId}
# List refunds for a checkout
Source: https://docs.wizzgift.com/api-reference/retailer-refunds/list-refunds-for-a-checkout
/api-reference/b2b.openapi.yaml get /retailer/v1/refunds
# Request a refund for a checkout
Source: https://docs.wizzgift.com/api-reference/retailer-refunds/request-a-refund-for-a-checkout
/api-reference/b2b.openapi.yaml post /retailer/v1/refunds
# Create or update your webhook endpoint
Source: https://docs.wizzgift.com/api-reference/retailer-webhooks/create-or-update-your-webhook-endpoint
/api-reference/b2b.openapi.yaml put /retailer/v1/webhook
One endpoint per account. The signing `secret` (`whsec_…`) is returned
**only when the endpoint is first created** — store it; later updates
return the config without the secret (rotate to get a new one).
`events: null`/omitted subscribes to all events. URL must be https
with a public hostname.
# Delete the webhook endpoint
Source: https://docs.wizzgift.com/api-reference/retailer-webhooks/delete-the-webhook-endpoint
/api-reference/b2b.openapi.yaml delete /retailer/v1/webhook
# Get webhook config (secret masked)
Source: https://docs.wizzgift.com/api-reference/retailer-webhooks/get-webhook-config-secret-masked
/api-reference/b2b.openapi.yaml get /retailer/v1/webhook
# List recent webhook deliveries (debug log)
Source: https://docs.wizzgift.com/api-reference/retailer-webhooks/list-recent-webhook-deliveries-debug-log
/api-reference/b2b.openapi.yaml get /retailer/v1/webhook/deliveries
# Rotate the signing secret
Source: https://docs.wizzgift.com/api-reference/retailer-webhooks/rotate-the-signing-secret
/api-reference/b2b.openapi.yaml post /retailer/v1/webhook/rotate
Returns a new secret immediately. The previous secret keeps producing
a second valid `v1` signature entry for 24 h so you can deploy the new
secret without dropping verifications.
# Send a synchronous signed ping
Source: https://docs.wizzgift.com/api-reference/retailer-webhooks/send-a-synchronous-signed-ping
/api-reference/b2b.openapi.yaml post /retailer/v1/webhook/test
Sends a `ping` event directly (bypassing the retry queue) so you see
your endpoint's actual response immediately. Use it to verify your
signature check end-to-end.
# B2B order callback
Source: https://docs.wizzgift.com/api-reference/webhook-events/b2b-order-callback
api-reference/b2b.openapi.yaml webhook b2bOrderCallback
Legacy unsigned callback for B2B orders — includes card codes; see the guide before using it.
# order.completed
Source: https://docs.wizzgift.com/api-reference/webhook-events/order-completed
api-reference/b2b.openapi.yaml webhook orderCompleted
Every item fulfilled — fetch the codes via the authenticated checkout endpoint.
# order.failed
Source: https://docs.wizzgift.com/api-reference/webhook-events/order-failed
api-reference/b2b.openapi.yaml webhook orderFailed
No item could be fulfilled.
# order.partial
Source: https://docs.wizzgift.com/api-reference/webhook-events/order-partial
api-reference/b2b.openapi.yaml webhook orderPartial
Some items fulfilled, some failed.
# payment.confirmed
Source: https://docs.wizzgift.com/api-reference/webhook-events/payment-confirmed
api-reference/b2b.openapi.yaml webhook paymentConfirmed
Payment fully confirmed — fulfillment starts.
# payment.detected
Source: https://docs.wizzgift.com/api-reference/webhook-events/payment-detected
api-reference/b2b.openapi.yaml webhook paymentDetected
Customer transaction seen, awaiting network confirmations.
# ping
Source: https://docs.wizzgift.com/api-reference/webhook-events/ping
api-reference/b2b.openapi.yaml webhook ping
Manual test event sent by POST /retailer/v1/webhook/test.
# refund.* events
Source: https://docs.wizzgift.com/api-reference/webhook-events/refund-lifecycle
api-reference/b2b.openapi.yaml webhook refundLifecycle
refund.initiated, refund.completed, and refund.failed share one payload shape.
# Authentication
Source: https://docs.wizzgift.com/authentication
Create an API key and authenticate your requests.
Every request to `/b2b/*` and `/retailer/v1/*` is authenticated with an API key in the `X-API-Key` header.
```bash theme={null}
curl https://api.wizzgift.com/b2b/account \
-H "X-API-Key: wg_live_..."
```
A missing or invalid key returns `401 UNAUTHORIZED`. A suspended account returns `403` on every call.
## Create an API key
Create a Wizzgift account at [wizzgift.com](https://www.wizzgift.com) and sign in.
Go to **Account → API Keys** and create a key. Creating your first key
automatically enrolls you as a **starter-tier** business with the **b2b**
capability enabled — no separate signup step.
The key looks like `wg_live_...` and is shown **exactly once**. Only a
hash is stored server-side; a lost key must be replaced.
Treat API keys like passwords. Keep them in your server-side environment, never in client-side code, and rotate them from the dashboard if they leak.
## Key scopes
Each key carries a list of scopes. Operations in the [API reference](/api-reference/introduction) state the scope they require. A key without the needed scope gets `403 FORBIDDEN`.
Pick the scopes when you create the key — the dialog lists all of them, with every scope selected by default. Scopes are fixed once the key exists; to change them, create a replacement key and delete the old one.
| Scope | Grants |
| ----------------- | --------------------------------------------------- |
| `orders:create` | Create orders, checkouts, and payment invoices |
| `orders:read` | Read orders and checkouts; manage webhook endpoints |
| `balance:read` | Read balance and account overview |
| `products:read` | Read the catalog and payment methods |
| `refunds:create` | Request refunds |
| `refunds:read` | Read refund status |
| `deposits:create` | Create balance top-ups |
| `deposits:read` | Read deposit status and history |
Grant only what the integration needs — a reporting job wants just `products:read` and `orders:read`, not the create scopes.
A key with the `*` scope has full access; keys created before scopes existed also carry full access and keep working. Any scope outside the table above is rejected at creation with `400 VALIDATION_ERROR`.
## Account capabilities
Scopes control what a *key* may do; capabilities control which *surfaces* your account can use:
* **b2b** — enabled automatically when you create your first API key.
* **retailer** — enable it in the dashboard business settings, or contact support.
Calling a surface whose capability is off returns `403 FORBIDDEN` with a hint in the error message.
## Account status
Your account status is visible on [`GET /b2b/account`](/api-reference/introduction):
| Status | Effect |
| ---------------- | ------------------------------------------------------- |
| `active` | Normal operation |
| `under_review` | Advisory — the API keeps working |
| `info_requested` | Advisory — check `statusNote` for what we need from you |
| `suspended` | All API access blocked (`403`) |
# Conventions
Source: https://docs.wizzgift.com/guides/conventions
Pagination, timestamps, currencies, and identifiers.
## Pagination
List endpoints share the same query parameters and response wrapper:
| Parameter | Default | Notes |
| ----------- | ----------- | ------------------------------------------ |
| `limit` | 20 | 1–100 |
| `offset` | 0 | |
| `sortBy` | `createdAt` | Also `totalAmount`, `status` (order lists) |
| `sortOrder` | `desc` | `asc` or `desc` |
```json theme={null}
{
"orders": ["..."],
"pagination": { "total": 142, "limit": 20, "offset": 0, "hasMore": true }
}
```
Iterate by advancing `offset` until `hasMore` is `false`.
## Timestamps
All timestamps are **epoch milliseconds** (UTC) — `createdAt`, `completedAt`, `expiresAt`, webhook `createdAt`, and the `t=` value in webhook signatures.
## Currencies and amounts
* Currency codes are uppercase: fiat `USD`, `EUR`, `GBP`, `AUD`, `CAD`, `INR`; crypto `BTC`, `LTC`, `USDC`, `USDT`, `XRP`, `XLM`.
* Tier limits, spend caps, deposits, and `markupTotal` 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's `paymentCurrency`.
* Crypto invoice amounts are **strings** (for example `"0.00016589"`) to avoid float precision loss.
## Order item semantics
* `amount` is the **denomination** the recipient receives (a 50 USD card → `amount: 50`), constrained by the SKU's `min`, `max`, and `changeStep`.
* `quantity` is how many units of that denomination — each unit gets its own fulfillment entry.
* `requiredFields` supplies per-item data some products need (for example a player id for a game top-up). The product's catalog entry defines which fields to collect.
## Identifiers
| Prefix | Resource |
| ---------- | -------------------------------------- |
| `wg_live_` | API keys |
| `chkb_` | Checkouts created via the B2B API |
| `chkr_` | Checkouts created via the Retailer API |
| `depb_` | Deposits created via the B2B API |
| `whe_` | Webhook endpoints |
| `whd_` | Webhook deliveries |
| `whsec_` | Webhook signing secrets |
Checkouts and deposits created on the consumer website use the untagged `chk_` / `dep_` prefixes, and records created before surface tagging was introduced keep them too — endpoints accept every variant, so treat all ids as opaque strings and do not parse structure out of them.
## Country codes
`customerCountry` uses ISO 3166-1 alpha-2 (`DE`, `US`, ...). It describes the **end-customer**, is never inferred from your server's IP, and activates per-product blocked-country rules when present.
# Errors
Source: https://docs.wizzgift.com/guides/errors
The error format and every error code.
## The envelope
Every error (except `429`, noted below) uses one JSON shape:
```json theme={null}
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input"
},
"requestId": "req_8f3k2"
}
```
* **Switch on `error.code`**, never on `message` text — messages can change.
* `error.details` carries structured context and is included in non-production environments.
* Log `requestId` — include it when contacting support and we can trace the exact request.
## Error codes
| HTTP | Code | Meaning | Handling |
| ---- | ------------------------ | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| 400 | `VALIDATION_ERROR` | Bad input; also synchronous payment failures (see `details.reason`, for example insufficient balance) | Fix the request; for balance failures, top up and retry |
| 401 | `UNAUTHORIZED` | Missing, invalid, or expired API key | Check the `X-API-Key` header and key status |
| 403 | `FORBIDDEN` | Missing key scope, capability disabled, or account suspended | Read the message hint; adjust key scopes or enable the capability |
| 404 | `NOT_FOUND` | Unknown id — **also returned for resources owned by another account** | Verify the id; do not retry |
| 409 | `CONFLICT` | `externalRef` used on the other surface, or a concurrent payment race | Cross-surface ref: fix the integration. Payment race: retry after a short delay |
| 422 | `B2B_LIMIT_EXCEEDED` | Tier limit hit — `details` identifies which | See [limits and tiers](/guides/limits-and-tiers) |
| 422 | `PAYMENT_ERROR` | Payment-level failure | Inspect `details`; retry with a different method if appropriate |
| 422 | `INSUFFICIENT_BALANCE` | Balance too low for the operation | Top up via deposits |
| 429 | — | Rate limited | Exponential backoff |
| 502 | `EXTERNAL_SERVICE_ERROR` | Upstream provider failure | Safe to retry with backoff |
The `429` response uses a flat body — `{ "error": "Too many requests", "message": "..." }` — rather than the envelope. Match on the status code.
## Ownership returns 404, not 403
Fetching a checkout, order, deposit, or refund that belongs to another account returns `404 NOT_FOUND` — identical to a nonexistent id. This is deliberate: foreign ids do not leak existence. If you see unexpected 404s, verify you are querying the right surface (`/b2b` orders are invisible to `/retailer/v1` endpoints and vice versa).
## Retry classification
| Class | Retry? |
| --------------------------------- | --------------------------------------------------------------------- |
| `429`, `502`, network timeouts | Yes, with exponential backoff — and the same `externalRef` on creates |
| `409` concurrent payment race | Yes, after a short delay |
| `400`, `401`, `403`, `404`, `422` | No — fix the underlying cause first |
# Idempotency
Source: https://docs.wizzgift.com/guides/idempotency
Retry order creation safely with externalRef.
Network timeouts happen. `externalRef` — your own order id — makes `POST /b2b/orders` and `POST /retailer/v1/checkouts` safe to retry.
## How it works
Send your internal order id (up to 128 characters) with the create call:
```json theme={null}
{
"externalRef": "shop-order-889",
"items": [{ "productId": "prod_x", "skuId": "sku_y", "amount": 25, "quantity": 1 }]
}
```
* **First call** creates the order and returns `201`.
* **Any repeat** with the same `externalRef` on the same surface returns `200` with the *existing* order — no duplicate charge, no duplicate fulfillment.
* **Cross-surface reuse** returns `409 CONFLICT`: `externalRef` is unique per account across both surfaces, so a ref already used on `/b2b` cannot be reused on `/retailer/v1` (and vice versa). Treat this as an integration bug, not a replay.
## The retry recipe
```text theme={null}
1. Generate the externalRef from your own order id (stable, not random per attempt).
2. POST the create call.
3. On timeout, connection error, or 5xx: retry the same request unchanged.
4. Stop on any 2xx (201 = created now, 200 = already existed) or a non-retryable 4xx.
```
Never generate a fresh `externalRef` per retry attempt — that defeats the mechanism and can double-charge your balance.
## Reconciliation
`externalRef` is also a filter on the list endpoints, which makes reconciling against your own database a single call per order:
```bash theme={null}
curl "https://api.wizzgift.com/b2b/orders?externalRef=shop-order-889" \
-H "X-API-Key: wg_live_..."
```
Webhook payloads echo `externalRef` too, so you can match events to your records without a lookup.
## Related idempotency behavior
* **Payment invoices** — re-creating a payment with the same method refreshes the same provider invoice instead of creating a second one.
* **Webhook deliveries** — each delivery has a stable id (`whd_...`) that retries reuse; deduplicate on it. See [verifying signatures](/webhooks/verify-signatures).
# Limits and tiers
Source: https://docs.wizzgift.com/guides/limits-and-tiers
Account limits, spend caps, and rate limits.
Every account has a tier. Tiers define the limits below; higher tiers (bigger caps, better pricing) are assigned by the Wizzgift team — contact support with your expected volumes. New accounts start on the **starter** tier.
## Reading your limits
Never hardcode limits — read them live from `GET /b2b/account` or `GET /retailer/v1/account`:
```json theme={null}
{
"tier": "starter",
"limits": {
"maxQuantityPerItem": 250,
"maxItemsPerOrder": 20,
"maxOrderAmountUSD": 1000,
"dailySpendCapUSD": 5000,
"monthlySpendCapUSD": 50000,
"maxDepositUSD": 2000,
"rateLimitTier": "moderate",
"maxOpenCheckouts": 25,
"dailyCheckoutCreations": 200,
"maxMarkupPercent": 20
},
"usage": {
"daily": { "spentUsd": 1200, "capUsd": 5000 },
"monthly": { "spentUsd": 14300, "capUsd": 50000 }
}
}
```
The values above are illustrative — your actual limits come from your tier plus any per-account overrides, and are always current in the account response.
## What each limit controls
| Limit | Applies to |
| ----------------------------------------- | ------------------------------------------------- |
| `maxQuantityPerItem` | Quantity on a single order line |
| `maxItemsPerOrder` | Number of lines in one order |
| `maxOrderAmountUSD` | USD value of a single order |
| `dailySpendCapUSD` / `monthlySpendCapUSD` | Rolling UTC-day / UTC-month B2B spend |
| `maxDepositUSD` | Single deposit size |
| `maxOpenCheckouts` | Simultaneously pending retailer checkouts |
| `dailyCheckoutCreations` | Retailer checkout creations per UTC day |
| `maxMarkupPercent` | Retailer markup, per checkout and account default |
## Handling limit errors
Exceeding a limit returns `422` with code `B2B_LIMIT_EXCEEDED` and machine-readable details:
```json theme={null}
{
"error": {
"code": "B2B_LIMIT_EXCEEDED",
"message": "Daily spend cap exceeded",
"details": {
"limit": "dailySpendCapUSD",
"limitValue": 5000,
"current": 4800,
"requested": 400,
"window": "day",
"tier": "starter"
}
},
"requestId": "req_8f3k2"
}
```
Switch on `details.limit` to react: queue the order for the next window (`dailySpendCapUSD`), split it (`maxOrderAmountUSD`, `maxQuantityPerItem`), or surface an upgrade prompt.
## Rate limiting
API traffic is rate-limited per account, keyed to your tier's `rateLimitTier` (`strict`, `moderate`, or `general`). Exceeding it returns `429`:
```json theme={null}
{ "error": "Too many requests", "message": "Rate limit exceeded. Please try again later." }
```
The `429` body is a flat shape, not the standard error envelope — match on the status code, not the body structure.
Back off exponentially on `429`. Spread bulk operations (catalog syncs, reconciliation sweeps) instead of bursting them.
## Rebates
Tiers can carry volume rebate offers: spend `targetAmountUsd` within the window and `rewardAmountUsd` is credited to your balance. Progress is returned under `rebates` in the account response — no separate tracking needed.
# Order lifecycle
Source: https://docs.wizzgift.com/guides/order-lifecycle
How orders move from pending to completed, and where the codes live.
## Status model
Three levels track progress independently:
**Checkout / order** — the overall record:
| Status | Meaning |
| ------------ | --------------------------------- |
| `pending` | Created, awaiting payment |
| `processing` | Paid, fulfillment running |
| `completed` | Every item fulfilled |
| `partial` | Some items fulfilled, some failed |
| `failed` | Nothing could be fulfilled |
| `expired` | Never paid within the window |
**Item** — one product line: `pending → processing → completed` or `failed`.
**Fulfillment** — one unit within a line (a line with `quantity: 3` gets three fulfillment entries): each carries its own `status`, code fields, and `fulfilledAt`.
## Reading codes
Codes live on the fulfillment entries once a unit completes:
```json theme={null}
{
"index": 0,
"status": "completed",
"cardCode": "AQ4X-...-9PLM",
"cardPin": "1234",
"cardUrl": "https://redeem.example/...",
"fulfilledAt": 1753142400000
}
```
* `cardCode` is the redeemable code; `cardPin` and `cardUrl` are present when the product uses them.
* Serial-less deliveries (direct top-ups to a player account) complete without any code fields — the item status is the receipt.
## Partial fulfillment
Large orders can partially succeed: the order lands on `partial`, completed units carry codes, and failed units carry a customer-safe `errorMessage`. Request a refund for the failed portion — the refund amount is computed from the failed quantities automatically.
`errorMessage` on fulfillments is always a mapped, customer-safe message. Raw vendor errors are never exposed through the API.
## Sealed cards
Pass `sealCards: true` at creation to deliver codes *sealed*:
* The fulfillment carries `sealed: true` and a `giftCardId` instead of plain code fields.
* The recipient reveals the card on the Wizzgift hosted page — useful when you do not want plain codes passing through your systems.
* Default is `false`: codes come back in plain form.
## Polling guidance
* Poll the detail endpoint (`GET /b2b/orders/{id}` or `GET /retailer/v1/checkouts/{id}`) every few seconds after payment confirms; most orders finish within a minute.
* On the retailer surface, prefer [webhooks](/webhooks/overview) and use polling as the fallback. Webhooks tell you *when* to fetch; the GET is always the source of truth.
* Statuses only move forward. Once you observe `completed`, `partial`, or `failed`, the order is terminal (refunds are a separate record).
# Payments
Source: https://docs.wizzgift.com/guides/payments
Payment methods, invoices, and payment states.
## Payment methods
`GET /retailer/v1/payment-methods` lists every active method — crypto coins, Lightning, and the internal *balance* method. Each record carries display data (`name`, `imageUrl`, `feeInfo`), constraints (`minAmountUSD`, `memoRequired`), and refund policy fields.
When a method is returned *with a checkout* (`availablePaymentMethods`), it gains an `eligible` boolean: `false` means the checkout total is below the method's `minAmountUSD`. Show ineligible methods greyed out with a tooltip rather than hiding them.
## The payment invoice
Creating a payment (one-call checkout, `POST /retailer/v1/checkouts/{id}/payment`, or a deposit with `paymentMethodId`) returns an invoice object. The fields you render depend on the provider type:
| Field | Meaning |
| ------------------------------------ | ------------------------------------------------------------------------- |
| `paymentAddress` | Crypto address to pay to |
| `amount`, `currency` | Exact crypto amount to send, as a string (for example `"0.00016589"` BTC) |
| `memo` / `destinationTag` | Required for XLM / XRP — payments without them can be lost |
| `paymentUrl` | Redirect URL for hosted providers |
| `qr.withAmount` / `qr.withoutAmount` | QR payload strings for wallet scanning |
| `expiresAt` | Invoice expiry (epoch ms) — re-quote after this |
| `appliedAmount`, `appliedCurrency` | What gets credited toward the checkout |
| `providerAmount`, `providerCurrency` | What is invoiced at the provider |
When `memo` or `destinationTag` is present, your customer **must** include it in the transfer. Funds sent without it may not be credited.
### Expired invoices
Crypto invoices expire. Call `POST /retailer/v1/checkouts/{id}/payment` again with the same method — the provider deduplicates and returns the same invoice refreshed (new expiry, same address where possible).
## Aggregate payment state
`GET /retailer/v1/checkouts/{id}` (and the deposit detail endpoint) return a `payment` object summarizing all payments on the record:
```json theme={null}
{
"state": "partial",
"confirmedAmount": 30,
"totalAmount": 50,
"currency": "USDT",
"payments": [
{
"paymentId": "pay_x1",
"method": "USDT (TRC-20)",
"amount": 50,
"status": "confirming",
"partialPayment": { "sentAmount": 30, "expectedAmount": 50, "currency": "USDT" }
}
]
}
```
| `state` | Meaning |
| ----------- | ------------------------------- |
| `pending` | No payment detected yet |
| `partial` | Some value received, not enough |
| `confirmed` | Fully paid — fulfillment starts |
| `failed` | Payment failed |
`partialPayment` shows crypto under-payment progress ("30 of 50 USDT received") so you can prompt the customer to send the remainder.
## Prepaid balance payments
Both surfaces can pay with the internal *balance* method:
* **B2B orders** always pay with balance — that is the surface's model.
* **Retailer checkouts** can pass the balance method id to prepay from your business balance instead of invoicing the customer.
Balance payments settle synchronously. If the deduction cannot confirm (typically insufficient balance), the call fails with `400 VALIDATION_ERROR` and the reason in `error.details.reason` — no order is left half-paid.
## Payment record statuses
Individual payment records move `pending → confirming → confirmed` or `failed`. `confirming` means the transaction is detected and awaiting network confirmations. The retailer webhook events [`payment.detected` and `payment.confirmed`](/webhooks/events) map to these transitions.
# Introduction
Source: https://docs.wizzgift.com/index
Buy gift cards, game top-ups, and vouchers through the Wizzgift API — in bulk or one checkout at a time.
Wizzgift sources gift cards, game top-ups, and vouchers directly from brands and suppliers. The API gives your business two ways to buy them — both use the same account and the same API key.
## Choose how you integrate
Buy codes in bulk from your prepaid USD balance. One call creates and pays
an order; poll for the codes. Base path: `/b2b`.
Embed our catalog in your storefront. Your end-customer pays per checkout
(crypto invoice or hosted page) — or you prepay from balance. We fulfill,
you relay the codes. Base path: `/retailer/v1`.
## How it works
| Surface | Who pays | Typical use |
| ------------ | ----------------------------------- | ----------------------------------------- |
| **B2B** | You, from your prepaid balance | Bulk purchasing for your own distribution |
| **Retailer** | Your end-customer — or you, prepaid | Reselling with your own markup on top |
Fulfillment is asynchronous: you create an order, payment confirms, and card codes land on the order's fulfillment records. Read them back with an authenticated `GET`, or react to [signed webhooks](/webhooks/overview) on the retailer surface.
## Base URLs
| Environment | URL |
| ----------- | -------------------------- |
| Production | `https://api.wizzgift.com` |
## Explore the docs
Create an API key, understand scopes and capabilities.
Event notifications with HMAC signatures and automatic retries.
Payments, order lifecycle, idempotency, limits, and errors.
Every endpoint with schemas, examples, and an interactive playground.
# B2B quickstart
Source: https://docs.wizzgift.com/quickstart/b2b
Fund your balance, place an order, and get the card codes.
This walkthrough takes you from an empty account to delivered card codes on the B2B surface. You need an [API key](/authentication) with the default scopes.
Create a deposit. `amount` is the **USD value to credit**; `paymentCurrency` is what you pay in. Pass `paymentMethodId` to get the payment invoice in the same call (list method ids first with `GET /retailer/v1/payment-methods`, or omit the field and pick from the returned `availablePaymentMethods`).
```bash curl theme={null}
curl -X POST https://api.wizzgift.com/b2b/deposits \
-H "X-API-Key: wg_live_..." \
-H "Content-Type: application/json" \
-d '{
"amount": 500,
"paymentCurrency": "USDT",
"paymentMethodId": "pm_usdt_trc20"
}'
```
```javascript Node.js theme={null}
const res = await fetch("https://api.wizzgift.com/b2b/deposits", {
method: "POST",
headers: {
"X-API-Key": process.env.WIZZGIFT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 500,
paymentCurrency: "USDT",
paymentMethodId: "pm_usdt_trc20",
}),
});
const { deposit, paymentAmount, payment } = await res.json();
```
```python Python theme={null}
import requests
res = requests.post(
"https://api.wizzgift.com/b2b/deposits",
headers={"X-API-Key": WIZZGIFT_API_KEY},
json={
"amount": 500,
"paymentCurrency": "USDT",
"paymentMethodId": "pm_usdt_trc20",
},
)
data = res.json()
```
The response contains the invoice — send exactly `payment.amount` to `payment.paymentAddress` (or redirect to `payment.paymentUrl` for hosted providers):
```json Response (201) theme={null}
{
"deposit": {
"depositId": "depb_a8Xk2mQ9pL1r",
"amount": 500,
"currency": "USD",
"paymentCurrency": "USDT",
"status": "pending",
"expiresAt": 1753149600000
},
"paymentAmount": 500.75,
"payment": {
"paymentId": "pay_31xk...",
"status": "pending",
"paymentAddress": "TWd4...Ab12",
"amount": "500.75",
"currency": "USDT",
"network": "Tron",
"expiresAt": 1753149600000
}
}
```
Deposits expire after roughly 60 minutes unpaid. Each deposit is capped by your tier's `maxDepositUSD`.
Poll the deposit until `deposit.status` is `confirmed`:
```bash theme={null}
curl https://api.wizzgift.com/b2b/deposits/depb_a8Xk2mQ9pL1r \
-H "X-API-Key: wg_live_..."
```
Crypto payments pass through `confirming` (transaction seen, awaiting network confirmations) before `confirmed`. Once confirmed, `GET /b2b/balance` reflects the credit.
```bash theme={null}
curl https://api.wizzgift.com/b2b/products \
-H "X-API-Key: wg_live_..."
```
Each product carries `skus` with a denomination range (`min`, `max`, `changeStep`) and `minCost` — the price for the minimum denomination, before your account discount. Note the `productId` and the SKU `id` you want to order.
One call creates the order **and pays it from your balance**. Always send an `externalRef` so retries are [idempotent](/guides/idempotency):
```bash theme={null}
curl -X POST https://api.wizzgift.com/b2b/orders \
-H "X-API-Key: wg_live_..." \
-H "Content-Type: application/json" \
-d '{
"externalRef": "acme-order-1042",
"items": [
{ "productId": "prod_amazon_us", "skuId": "sku_50_100", "amount": 50, "quantity": 3 }
]
}'
```
`amount` is the denomination the recipient gets (a 50 USD card), `quantity` is how many. A `201` means the balance payment confirmed and fulfillment started. Insufficient balance returns `400` with the reason in `error.details.reason`; tier caps return `422 B2B_LIMIT_EXCEEDED`.
```bash theme={null}
curl https://api.wizzgift.com/b2b/orders/chkb_9f2k1m \
-H "X-API-Key: wg_live_..."
```
When `status` reaches `completed` (or `partial`), read the codes from each item's `fulfillments` array:
```json Response (200) theme={null}
{
"checkoutId": "chkb_9f2k1m",
"externalRef": "acme-order-1042",
"status": "completed",
"items": [
{
"productName": "Amazon Gift Card (US)",
"quantity": 3,
"status": "completed",
"fulfillments": [
{ "index": 0, "status": "completed", "cardCode": "AQ4X-...-9PLM", "cardPin": null, "fulfilledAt": 1753142400000 },
{ "index": 1, "status": "completed", "cardCode": "BX2N-...-4KJk", "cardPin": null, "fulfilledAt": 1753142401000 },
{ "index": 2, "status": "completed", "cardCode": "CM8V-...-2RTQ", "cardPin": null, "fulfilledAt": 1753142403000 }
]
}
]
}
```
Poll every few seconds; most orders complete within a minute. If some units fail (`status: partial`), request a refund for the failed portion with `POST /b2b/refunds`.
## Next steps
Statuses, partial fulfillment, and sealed cards.
Spend caps, order shape limits, and how to read your usage.
# Retailer quickstart
Source: https://docs.wizzgift.com/quickstart/retailer
Create a checkout, let your customer pay, and deliver the codes.
This walkthrough integrates the Wizzgift catalog into your storefront. You need an [API key](/authentication) and the **retailer** capability enabled on your account.
```bash theme={null}
curl https://api.wizzgift.com/retailer/v1/catalog \
-H "X-API-Key: wg_live_..."
```
Prices are what *you* pay before your markup. Render them in your store with your margin applied — the API adds your `markupPercent` on top when you create the checkout.
```bash theme={null}
curl -X POST https://api.wizzgift.com/retailer/v1/checkouts \
-H "X-API-Key: wg_live_..." \
-H "Content-Type: application/json" \
-d '{
"customerEmail": "buyer@example.com",
"externalRef": "shop-order-889",
"customerCountry": "DE",
"markupPercent": 5,
"items": [
{ "productId": "prod_psn_de", "skuId": "sku_25", "amount": 25, "quantity": 1 }
]
}'
```
Key fields:
* `customerEmail` — your **end-customer's** email, used for delivery identity and refunds.
* `customerCountry` — the end-customer's country, never inferred from your server's IP. Blocked-country rules apply when present.
* `markupPercent` — your profit on this checkout. Overrides your account default; capped by your tier's `maxMarkupPercent`.
* `externalRef` — your own order id, for [idempotent retries](/guides/idempotency).
The `201` response includes the priced checkout (`totalAmount` includes your markup), `availablePaymentMethods` with per-method `eligible` flags, and `links.hostedPaymentUrl`.
Redirect your customer to `links.hostedPaymentUrl`:
```text theme={null}
https://www.wizzgift.com/checkout/chkr_ab12cd34
```
Our hosted checkout handles payment method selection, invoices, and payment UI. Nothing else to build.
Include a `payment` object in the create call to get the invoice in the same response:
```json theme={null}
{
"customerEmail": "buyer@example.com",
"items": [{ "productId": "prod_psn_de", "skuId": "sku_25", "amount": 25, "quantity": 1 }],
"payment": { "paymentMethodId": "pm_btc" }
}
```
Render `payment.paymentAddress`, `payment.amount`, `payment.currency`, and the `payment.qr` strings in your own payment UI.
Create the invoice after your customer picks a method:
```bash theme={null}
curl -X POST https://api.wizzgift.com/retailer/v1/checkouts/chkr_ab12cd34/payment \
-H "X-API-Key: wg_live_..." \
-H "Content-Type: application/json" \
-d '{ "paymentMethodId": "pm_btc" }'
```
Call it again with the same method to refresh an expired invoice — the provider deduplicates and returns the same invoice with fresh data.
**Prepaid mode:** pass the *balance* payment method id and the total is deducted from your business balance instantly — no customer invoice at all. Useful when you collect payment on your own side.
Either poll, or configure [signed webhooks](/webhooks/overview) and fetch when `order.completed` arrives. One endpoint returns everything — order status, payment state, and codes:
```bash theme={null}
curl https://api.wizzgift.com/retailer/v1/checkouts/chkr_ab12cd34 \
-H "X-API-Key: wg_live_..."
```
```json Response (200) theme={null}
{
"checkoutId": "chkr_ab12cd34",
"customerEmail": "buyer@example.com",
"status": "completed",
"totalAmount": 27.3,
"paymentCurrency": "USD",
"markupTotal": 1.3,
"payment": { "state": "confirmed", "confirmedAmount": 27.3, "totalAmount": 27.3, "currency": "USD" },
"items": [
{
"productName": "PlayStation Store 25 EUR (DE)",
"unitPrice": 27.3,
"unitMarkup": 1.3,
"quantity": 1,
"status": "completed",
"fulfillments": [
{ "index": 0, "status": "completed", "sealed": false, "cardCode": "9XKD-...-PL2M", "fulfilledAt": 1753142400000 }
]
}
]
}
```
Card codes are never included in webhook payloads — the webhook tells you *when* to fetch, this endpoint tells you *what* to deliver.
## Your margin
Markup is your profit: `unitMarkup` is baked into each item's `unitPrice`, and the checkout's `markupTotal` (USD) is **credited to your business balance** as items complete. Track lifetime earnings under `markupEarnings` on `GET /retailer/v1/account`, and set an account-wide default with `PATCH /retailer/v1/account`.
## Next steps
Get notified on payment and fulfillment instead of polling.
Invoice fields, payment states, and partial crypto payments.
# Choosing a surface
Source: https://docs.wizzgift.com/surfaces
B2B or Retailer — who pays, and which mode fits your business.
One account, one API key, two surfaces. Pick the one that matches who pays and who receives the codes — or use both side by side.
## Comparison
| | B2B (`/b2b`) | Retailer (`/retailer/v1`) |
| -------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |
| Who pays | You, from prepaid balance | Your end-customer (crypto invoice or hosted page), or you via prepaid balance |
| Payment timing | Instant — order create pays and starts fulfillment | After the customer pays the invoice |
| Codes go to | You (fetch via API) | You (fetch via API and relay to your customer) |
| Your pricing | Catalog price minus your negotiated discount | Catalog price plus your `markupPercent` — markup is credited back to your balance |
| Notifications | Optional plain callback (unsigned, no retries) | Signed webhooks with retries |
| Best for | Bulk purchasing, internal distribution | Storefronts, reseller sites, checkout embedding |
## When to use B2B
Use `/b2b` when you are the buyer: you fund a USD balance with [deposits](/quickstart/b2b), then place orders that are paid instantly from that balance. There is no per-order payment flow to manage — the only asynchronous part is fulfillment itself.
## When to use Retailer
Use `/retailer/v1` when someone else is the buyer: you create a checkout for your end-customer, they pay it (or you redirect them to our hosted payment page), and you deliver the codes once fulfillment completes. You control your margin per checkout with `markupPercent`, and completed items credit that markup to your balance.
The retailer surface also supports a prepaid mode: pay a checkout with the *balance* payment method and skip the customer invoice entirely. This is useful when you collect payment on your own side and just need fulfillment.
## How the surfaces stay separated
Orders created on one surface never appear in the other's list endpoints — `GET /b2b/orders` only returns B2B orders, and `GET /retailer/v1/checkouts` only returns retailer checkouts, even though both belong to the same account.
`externalRef` (your own order id) is unique per account **across both surfaces**:
* Reusing a ref on the *same* surface returns the existing order (`200`) — that is the [idempotency mechanism](/guides/idempotency).
* Reusing a ref on the *other* surface returns `409 CONFLICT` — that is a bug in your integration, not a replay.
## Shared account state
Balance, tier, limits, and rebate progress are account-wide. `GET /b2b/account` and `GET /retailer/v1/account` return the same core data; the retailer variant adds open-checkout usage and your markup ceiling.
# B2B order callback
Source: https://docs.wizzgift.com/webhooks/b2b-callback
The optional order callback on the B2B surface.
B2B orders support a simpler notification mechanism than retailer webhooks: pass `callbackUrl` on `POST /b2b/orders` and Wizzgift sends a single plain JSON `POST` when the order reaches a terminal status (`completed`, `partial`, or `failed`).
This callback is **not signed** and **not retried** — one attempt, fire-and-forget. Unlike retailer webhooks, the payload **contains full fulfillment data including card codes**. If you use it, protect the URL: https only, an unguessable path, and treat inbound payloads as untrusted until you re-fetch the order via the API.
## Payload
```json theme={null}
{
"checkoutId": "chkb_9f2k1m",
"status": "completed",
"items": [
{
"id": "itm_1",
"productId": "prod_amazon_us",
"productName": "Amazon Gift Card (US)",
"status": "completed",
"fulfillments": [
{
"index": 0,
"status": "completed",
"cardCode": "AQ4X-...-9PLM",
"cardPin": null,
"cardUrl": null,
"fulfilledAt": 1753142400000
}
]
}
],
"timestamp": 1753142405000
}
```
`errorMessage` fields on failed fulfillments are customer-safe mapped messages, the same as the API returns.
## Recommendations
* **Prefer polling** `GET /b2b/orders/{checkoutId}` for reliability — the callback has no retries, so a transient failure on your side means you miss it entirely. Poll as the source of truth; treat the callback as an optimization that ends polling early.
* **Never rely on the callback's codes alone.** Since it is unsigned, re-fetch the order via the authenticated API before delivering anything.
* **Need reliable, signed notifications?** The retailer surface's [signed webhooks](/webhooks/overview) provide HMAC signatures, retries, and a delivery log.
# Event catalog
Source: https://docs.wizzgift.com/webhooks/events
Every event and its payload.
## The envelope
Every delivery shares one envelope; `data` varies by event:
```json theme={null}
{
"id": "whd_Xy12Ab34Cd56",
"type": "order.completed",
"createdAt": 1753142400000,
"data": { "...": "..." }
}
```
`id` is the delivery id (stable across retries — deduplicate on it), `type` is the event name, `createdAt` is epoch milliseconds.
## 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` | A refund was created |
| `refund.completed` | The refund finished |
| `refund.failed` | The refund attempt failed |
| `ping` | Manual test via `POST /retailer/v1/webhook/test` |
## Payloads
All order-related events carry the same base: checkout identity, status, totals, and an item summary. Payment and refund events add one extra object.
`order.completed`, `order.partial`, `order.failed`:
```json theme={null}
{
"id": "whd_Xy12Ab34Cd56",
"type": "order.completed",
"createdAt": 1753142400000,
"data": {
"checkoutId": "chkr_ab12cd34",
"externalRef": "shop-order-889",
"status": "completed",
"totalAmount": 27.3,
"currency": "USD",
"items": [
{
"id": "itm_1",
"productId": "prod_psn_de",
"productName": "PlayStation Store 25 EUR (DE)",
"quantity": 1,
"status": "completed"
}
]
}
}
```
No card codes — fetch them with `GET /retailer/v1/checkouts/{checkoutId}` after verifying the signature.
`payment.detected`, `payment.confirmed` add a `payment` object:
```json theme={null}
{
"id": "whd_Qr78St90Uv12",
"type": "payment.confirmed",
"createdAt": 1753142300000,
"data": {
"checkoutId": "chkr_ab12cd34",
"externalRef": "shop-order-889",
"status": "processing",
"totalAmount": 27.3,
"currency": "USD",
"items": ["..."],
"payment": {
"paymentId": "pay_x1",
"method": "Bitcoin",
"amount": 27.3,
"currency": "USD"
}
}
}
```
`refund.initiated`, `refund.completed`, `refund.failed` add a `refund` object:
```json theme={null}
{
"id": "whd_Gh34Ij56Kl78",
"type": "refund.completed",
"createdAt": 1753150000000,
"data": {
"checkoutId": "chkr_ab12cd34",
"externalRef": "shop-order-889",
"status": "partial",
"totalAmount": 27.3,
"currency": "USD",
"items": ["..."],
"refund": {
"amount": 27.3,
"method": "balance",
"reason": "fulfillment_failed"
}
}
}
```
```json theme={null}
{
"id": "whd_Mn90Op12Qr34",
"type": "ping",
"createdAt": 1753142000000,
"data": { "message": "Wizzgift webhook test" }
}
```
## Choosing events to subscribe
Most integrations only need the order terminals:
* **Minimal:** `order.completed`, `order.partial`, `order.failed` — fetch codes on completed/partial, alert on failed.
* **With payment UX:** add `payment.detected` and `payment.confirmed` to update your customer's payment screen in real time.
* **With refund tracking:** add the three `refund.*` events.
The exact payload schemas are also documented under **Webhook payloads** in the [API reference](/api-reference/introduction), with full field documentation generated from the OpenAPI spec.
# Webhooks overview
Source: https://docs.wizzgift.com/webhooks/overview
Get notified when payments confirm and orders complete.
The retailer surface delivers signed webhooks for payment, order, and refund events. Configure one endpoint per account, verify every delivery's signature, and fetch the authoritative state with the API when an event arrives.
Webhooks fire for **retailer** checkouts. B2B orders have a separate, simpler [legacy callback](/webhooks/b2b-callback).
## Set up your endpoint
```bash theme={null}
curl -X PUT https://api.wizzgift.com/retailer/v1/webhook \
-H "X-API-Key: wg_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourshop.com/wizzgift/webhook",
"events": ["order.completed", "order.partial", "order.failed"]
}'
```
* The URL must be **https** with a public hostname — IPs, localhost, and internal hosts are rejected.
* Omit `events` (or send `null`) to subscribe to everything.
The response includes `secret` (`whsec_...`) **only on first create**. Store it now — later updates return the config without it, and the only way to get a new one is rotation.
Send a synchronous signed ping and check your endpoint's actual response:
```bash theme={null}
curl -X POST https://api.wizzgift.com/retailer/v1/webhook/test \
-H "X-API-Key: wg_live_..."
```
```json Response theme={null}
{ "ok": true, "status": 200 }
```
Use this to test your [signature verification](/webhooks/verify-signatures) end to end before going live.
Each delivery is a `POST` with a JSON envelope and three headers:
| Header | Contents |
| ---------------------- | ------------------------------------------------------ |
| `X-Wizzgift-Signature` | `t=,v1=` — verify on every delivery |
| `X-Wizzgift-Event` | Event type, for routing before parsing |
| `X-Wizzgift-Delivery` | Delivery id (`whd_...`) — your idempotency key |
Respond with any `2xx` within 10 seconds. Do heavy work asynchronously — acknowledge first, process after.
## Delivery guarantees
* **Retries:** failed deliveries are retried up to 5 times with exponential backoff. A delivery that exhausts retries is marked `failed` in the delivery log.
* **Idempotency:** retries reuse the same delivery id — deduplicate on `X-Wizzgift-Delivery` (also in the body as `id`).
* **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.** The webhook tells you when to fetch; the authenticated GET returns the codes.
## Per-checkout override
Pass `callbackUrl` on `POST /retailer/v1/checkouts` to route that checkout's events to a different URL — useful for multi-tenant platforms. The override is signed with the same business secret, so a business-level endpoint must exist (it holds the secret). Without one, per-checkout URLs are ignored and you are on polling only. The endpoint's event filter applies to overrides too.
## Debugging deliveries
```bash theme={null}
curl "https://api.wizzgift.com/retailer/v1/webhook/deliveries?checkoutId=chkr_ab12cd34" \
-H "X-API-Key: wg_live_..."
```
Returns recent delivery attempts with status (`sent` / `failed`), attempt counts, and a SHA-256 hash of the delivered body — enough to confirm what was sent and when without storing payloads.
## Endpoint management
| Operation | Endpoint |
| --------------------------- | ------------------------------------- |
| Create / update | `PUT /retailer/v1/webhook` |
| Read config (secret masked) | `GET /retailer/v1/webhook` |
| Rotate secret | `POST /retailer/v1/webhook/rotate` |
| Test ping | `POST /retailer/v1/webhook/test` |
| Delivery log | `GET /retailer/v1/webhook/deliveries` |
| Delete | `DELETE /retailer/v1/webhook` |
# Verify signatures
Source: https://docs.wizzgift.com/webhooks/verify-signatures
Check the signature on every delivery — code included for Node.js, Python, and PHP.
Every webhook delivery is signed with your endpoint secret (`whsec_...`) using HMAC-SHA256. Verify the signature before trusting any payload — an unverified webhook could be forged by anyone who knows your URL.
## Header format
```text theme={null}
X-Wizzgift-Signature: t=1753142400000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```
* `t` — delivery timestamp in **milliseconds**.
* `v1` — hex HMAC-SHA256 of the string `"."` keyed with your secret.
* During the 24 hours after a [secret rotation](#rotating-secrets), the header carries **two** `v1` entries — one per secret. A match on either is valid.
## Verification steps
1. Read the **raw** request body, before any JSON parsing or re-serialization.
2. Parse the header: extract `t` and every `v1` value.
3. Compute `HMAC-SHA256(secret, ".")` as lowercase hex.
4. Compare against each `v1` using a constant-time comparison. Any match passes.
5. Reject if the timestamp is stale — 5 minutes tolerance is a good default.
```javascript Node.js theme={null}
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((sig) => {
try {
return crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"));
} catch {
return false;
}
});
}
// Express example — note express.raw, NOT express.json
app.post("/wizzgift/webhook", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyWizzgiftSignature(
req.header("X-Wizzgift-Signature"),
req.body.toString("utf8"),
process.env.WIZZGIFT_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send("invalid signature");
res.sendStatus(200); // acknowledge fast, process async
const event = JSON.parse(req.body);
queue.push(event);
});
```
```python Python theme={null}
import hashlib
import hmac
import time
def verify_wizzgift_signature(header: str, raw_body: bytes, secret: str,
tolerance_ms: int = 300_000) -> bool:
parts = [p.split("=", 1) for p in header.split(",")]
t = next((v for k, v in parts if k == "t"), None)
sigs = [v for k, v in parts if k == "v1"]
if t is None or abs(time.time() * 1000 - int(t)) > tolerance_ms:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(sig, expected) for sig in sigs)
# Flask example — use request.get_data(), not request.json
@app.post("/wizzgift/webhook")
def wizzgift_webhook():
if not verify_wizzgift_signature(
request.headers.get("X-Wizzgift-Signature", ""),
request.get_data(),
WIZZGIFT_WEBHOOK_SECRET,
):
return "invalid signature", 400
process_async(request.get_json())
return "", 200
```
```php PHP theme={null}
function verifyWizzgiftSignature(
string $header,
string $rawBody,
string $secret,
int $toleranceMs = 300000
): bool {
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = explode('=', $part, 2);
if ($k === 't') $t = (int) $v;
if ($k === 'v1') $sigs[] = $v;
}
if ($t === null || abs((int) (microtime(true) * 1000) - $t) > $toleranceMs) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) return true;
}
return false;
}
// Usage
$rawBody = file_get_contents('php://input');
$ok = verifyWizzgiftSignature(
$_SERVER['HTTP_X_WIZZGIFT_SIGNATURE'] ?? '',
$rawBody,
getenv('WIZZGIFT_WEBHOOK_SECRET')
);
if (!$ok) {
http_response_code(400);
exit;
}
```
The HMAC is computed over the **raw bytes** we sent. If your framework parses JSON before you can read the body, the re-serialized string will not match — configure a raw-body route for the webhook path (see the framework notes in each example).
## Rotating secrets
```bash theme={null}
curl -X POST https://api.wizzgift.com/retailer/v1/webhook/rotate \
-H "X-API-Key: wg_live_..."
```
```json Response theme={null}
{ "secret": "whsec_kJ8s...", "previousSecretValidForMs": 86400000 }
```
Rotation is zero-downtime: for 24 hours, deliveries are signed with **both** secrets (two `v1` entries), so you can deploy the new secret at your own pace. After the window, only the new secret signs.
Test the full path any time with `POST /retailer/v1/webhook/test` — it sends a real signed `ping` synchronously and reports your endpoint's response code.
## Checklist
* [ ] Verify on the raw body, before JSON parsing
* [ ] Constant-time comparison (`timingSafeEqual`, `hmac.compare_digest`, `hash_equals`)
* [ ] Accept any matching `v1` entry (rotation window has two)
* [ ] Enforce a timestamp tolerance (about 5 minutes)
* [ ] Deduplicate on the delivery id (`X-Wizzgift-Delivery`)
* [ ] Respond `2xx` fast; process asynchronously