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

# V1 → V2 migration reference

> Endpoint-by-endpoint mapping, request and response shape diffs, webhook consolidation, and validation steps for porting an existing Zinc v1 integration to Zinc API v2.

<Info>
  Looking for the high-level "why" and "should I migrate"? Start with the
  [V1 → V2 overview](/docs/v2/migrating-from-v1). This page is the **technical
  detail** — port your integration code with confidence.
</Info>

The migration is mechanical for \~80% of call sites; the remainder need human review because v2 drops or restructures a few v1 features. This page walks through each transformation. Most teams complete the port in a day or two.

If you'd rather have an AI agent do the mechanical edits for you, see the [agent runbook](/docs/v2/migration-agent-runbook) — it's a workflow for Claude Code (or any coding agent) to execute this guide against your integration codebase.

<Note>
  **Scope.** This page covers porting your **integration code**. It does not move historical orders, retailer credentials, or accounts from v1 to v2. Customers start v2 with a fresh account, API key, wallet, and managed accounts.
</Note>

## What changed at a glance

| Area                 | v1                                                                     | v2                                                                                              |
| -------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| Auth                 | HTTP Basic `client_token:password?`                                    | `Authorization: Bearer zn_live_…`                                                               |
| Base URL             | `https://api.zinc.io/v0` / `/v1` / `/v2`                               | New v2 host (published in the dashboard); **no version prefix in path**                         |
| Payment              | Per-request `payment_method` block                                     | Wallet-based — top up via Stripe, orders charge wallet automatically                            |
| Retailer credentials | Embedded in every order request                                        | First-class `managed_accounts` objects; orders reference a `retailer_credentials_id`            |
| Webhooks             | Per-request `webhooks` object, 10 event types, URLs per event          | One `webhook_url` per user, dotted event names, HMAC-SHA256 signature, `X-Webhook-Event` header |
| Idempotency          | `idempotency_key` in request body                                      | Still supported — `idempotency_key` (max 36 chars) on `POST /orders`                            |
| Order ID shape       | Opaque `request_id` string                                             | UUID                                                                                            |
| Tracking endpoint    | `/v1/trackings` (separate async resource)                              | No dedicated endpoint — tracking embedded in `GET /orders/{id}`                                 |
| Cancellations        | `/v1/cancellations` + `/v1/orders/:id/cancel` + `/v1/orders/:id/abort` | Merged into `POST /orders/{id}/cancel` (pending orders only — post-placement, file a return)    |
| Returns              | Free-text `reason_code` string                                         | Strict `reason` enum (10 values)                                                                |

***

## Behavioral changes worth knowing

A few v2 changes aren't pure renames — they narrow or restructure semantics in ways that bite if you treat the migration as a find-and-replace.

### Cancellation is narrower in v2

v1 *attempted* cancellation even after the order had been placed at the retailer. v2 only allows cancellation while the order is still **pending in the queue**:

* `POST /orders/{order_id}/cancel` returns `204` and works only while `status == "pending"`.
* Once the order is `in_progress` or `order_placed`, the endpoint returns `order_not_cancellable` and the cancellation path is closed.
* **Post-placement cancellations now happen retailer-side** and arrive as an `order.cancelled` webhook with an automatic wallet refund. Your app needs to handle that arrival path.
* For everything else (customer changed their mind after the retailer placed the order, item arrived wrong, etc.) the path is a [return](#returns), not a cancel.

Any v1 logic that tried to cancel placed or in-flight orders must be redesigned.

### Returns require persisted `order_item_id` UUIDs

v2 returns reference individual order items by UUID — not by `product_id` like v1. Those UUIDs come back in the `items[]` array of the `POST /orders` response, and there's no way to recover them later other than calling [`GET /orders/{order_id}`](/docs/v2/api-reference/orders/get-order).

If your application doesn't already persist per-item identifiers from the order response, **plan a schema migration**: add a column for the v2 `order_item_id` next to whatever you already store per line item.

### `return.credited` is a new outcome with no v1 equivalent

When Zinc can't get a merchant RMA from the retailer (lost in the mail, retailer refuses, etc.) but the customer is owed a refund, v2 issues a wallet credit and the return resolves with `status = credited` (plus a `return.credited` webhook). v1 had no equivalent code path. Make sure your return-resolution code handles `credited` alongside `approved` / `denied`.

### Sync vs. async failures

Order failures in v2 surface in two distinct places:

* **Synchronous (immediate)** — failures detected at order-creation time return a 4xx from `POST /orders` with a `code` and `message`. Examples: `invalid_shipping_address`, `url_unreachable`, `insufficient_funds`, `unsupported_retailer`. Your create-order code path handles these.
* **Asynchronous (later)** — failures detected during processing arrive as `order.failed` webhooks with an `error_type` field. Examples: `product_out_of_stock`, `max_price_exceeded`, `invalid_variant`, `shipping_unavailable`, `checkout_blocked`. Your webhook handler handles these.

Code that only inspects the create-order response silently drops the async failures. Both code paths are required.

### Product data covers a narrower retailer set

v2's product endpoints (`/products/{id}`, `/products/{id}/offers`, `/products/search`) currently support a narrower retailer set than ordering does — the spec enumerates `amazon` and `walmart`. If your v1 integration fetches product data for other retailers, flag those call sites — they may need to drop to a different data source or be removed.

The new endpoints also gain freshness controls (`max_age` / `newer_than`, mutually exclusive) and an `async` mode that returns immediately with `status=processing`. Decide whether your existing call sites want the fresh-blocking default or the async fast-return.

***

## Authentication

<CodeGroup>
  ```http v1 theme={null}
  POST /v1/orders HTTP/1.1
  Host: api.zinc.io
  Authorization: Basic <base64(client_token:password)>
  Content-Type: application/json
  ```

  ```http v2 theme={null}
  POST /orders HTTP/1.1
  Host: <v2 production host>
  Authorization: Bearer zn_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  Content-Type: application/json
  ```
</CodeGroup>

### How to get a v2 API key

<Steps>
  <Step title="Sign in to the v2 dashboard">
    Use Stytch magic-link via [app.zinc.com](https://app.zinc.com).
  </Step>

  <Step title="Mint API keys">
    Create a `zn_live_*` key for production and a `zn_test_*` key for
    sandbox testing — either from the dashboard UI or via
    `POST /api-keys`.
  </Step>

  <Step title="Store in your config">
    Use your existing config key (e.g. `ZINC_API_KEY`) plus a new
    `ZINC_BASE_URL` if you don't already have one.
  </Step>
</Steps>

***

## Pre-flight checklist

Before any code change, complete these manual steps in the v2 dashboard. Code-level edits can't do them for you:

<AccordionGroup>
  <Accordion title="1. Sign up for v2 via Stytch magic-link">
    Visit [app.zinc.com](https://app.zinc.com) and authenticate with the email you want to associate with your v2 account.
  </Accordion>

  <Accordion title="2. Mint a `zn_live_*` API key and a `zn_test_*` key">
    Use the dashboard or `POST /api-keys`. Test mode is per-key, not per-host — the same base URL works for both.
  </Accordion>

  <Accordion title="3. Add a payment method and top up your wallet">
    v2 charges per order from your wallet balance (not a credit card on file). Top up via the dashboard's Wallet page or `POST /wallet/top-up` / `POST /wallet/checkout`. See the [Wallet guide](/docs/v2/wallet) for the full payment model.
  </Accordion>

  <Accordion title="4. Register a single webhook URL on your user">
    Set `webhook_url` via `POST /users`, then mint a webhook secret via `POST /users/webhook-secret`. Save the secret — it's shown once. See the [Webhooks introduction](/docs/v2/api-reference/introduction/webhooks) for payload + signature details.
  </Accordion>

  <Accordion title="5. Create a managed account for each `(retailer, account_email)` pair">
    For every retailer login your v1 integration uses, call [POST /managed-accounts](/docs/v2/api-reference/managed-accounts/create-managed-account) and record the returned `short_id` (format `zn_acct_XXXXXXXX`). Store these in your config — orders reference them by `short_id`.
  </Accordion>
</AccordionGroup>

***

## Endpoint mapping

### Orders

| v1                                                     | v2                                                                        | Notes                                                                  |
| ------------------------------------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `POST /v1/orders`                                      | [`POST /orders`](/docs/v2/api-reference/orders/create-order)                   | Request body restructured — see [Request shape diffs](#post-orders)    |
| `POST /v0/order`                                       | [`POST /orders`](/docs/v2/api-reference/orders/create-order)                   | Same as `/v1/orders`                                                   |
| `GET /v1/orders/{request_id}`                          | [`GET /orders/{order_id}`](/docs/v2/api-reference/orders/get-order)            | Response shape changes — see [Response shape diffs](#order-response)   |
| `GET /v1/orders`                                       | [`GET /orders?limit=&offset=`](/docs/v2/api-reference/orders/list-orders)      | Pagination params; `limit` max 500                                     |
| `GET /v1/orders/{request_id}/shipments`                | [`GET /orders/{order_id}`](/docs/v2/api-reference/orders/get-order)            | Read `tracking_numbers[]` on the order response                        |
| `POST /v1/orders/{request_id}/cancel`                  | [`POST /orders/{order_id}/cancel`](/docs/v2/api-reference/orders/cancel-order) | **Pending orders only** in v2. After placement, file a return instead. |
| `POST /v1/orders/{request_id}/abort`                   | [`POST /orders/{order_id}/cancel`](/docs/v2/api-reference/orders/cancel-order) | Semantic merge into a single cancel endpoint                           |
| `GET /v1/orders/{request_id}/abort`                    | *(no equivalent)*                                                         | Pre-cancel check; not needed in v2's flow                              |
| `POST /v1/orders/{request_id}/retry`                   | *(no equivalent — see [Gap decisions](#gap-decisions))*                   | v2 handles retries server-side via order attempts                      |
| `POST /v0/order/{request_id}/danger_footgun_repeat`    | *(no equivalent — see [Gap decisions](#gap-decisions))*                   | Place a new order with a new `idempotency_key`                         |
| `POST /v0/order/{request_id}/update`                   | *(no equivalent — see [Gap decisions](#gap-decisions))*                   | Customer-supplied status push not in v2                                |
| `POST /v0/order/{request_id}/insert_merchant_order_id` | *(no equivalent — see [Gap decisions](#gap-decisions))*                   | Merchant order IDs are tracked internally                              |
| `GET /v1/queued_orders`                                | `GET /orders?status_filter=pending`                                       | Status query supplants the dedicated queue endpoint                    |

### Returns

| v1                                            | v2                                                                         | Notes                                                                    |
| --------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `POST /v1/returns`                            | [`POST /returns`](/docs/v2/api-reference/returns/create-return)                 | Request body restructured — see [Request shape diffs](#post-returns)     |
| `GET /v1/returns/{request_id}`                | [`GET /returns/{return_request_id}`](/docs/v2/api-reference/returns/get-return) |                                                                          |
| `POST /v1/orders/{request_id}/return`         | [`POST /returns`](/docs/v2/api-reference/returns/create-return) with `order_id` | Convenience route removed; pass `order_id` explicitly                    |
| `GET /v1/orders/{request_id}/return`          | `GET /returns?order_id=…`                                                  | List and pick latest by `created_at`                                     |
| `POST /v1/returns/{request_id}/status`        | *(no equivalent — see [Gap decisions](#gap-decisions))*                    | Customer-driven status push not exposed; status updates flow via webhook |
| `POST /v1/returns/{request_id}/status/update` | *(no equivalent — see [Gap decisions](#gap-decisions))*                    | Same as above                                                            |

### Cancellations

| v1                                          | v2                                                                               | Notes                                                                         |
| ------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `POST /v1/cancellations`                    | [`POST /orders/{order_id}/cancel`](/docs/v2/api-reference/orders/cancel-order)        | Resource removed; merged into orders                                          |
| `GET /v1/cancellations/{request_id}`        | [`GET /orders/{order_id}/timeline`](/docs/v2/api-reference/orders/get-order-timeline) | Cancellation outcome appears as a `cancelled` milestone on the order timeline |
| `POST /v1/cancellations/{request_id}/retry` | *(no equivalent — see [Gap decisions](#gap-decisions))*                          |                                                                               |

### Tracking

| v1                               | v2                                                             | Notes                                                                                                                          |
| -------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `POST /v1/trackings`             | *(removed)*                                                    | Tracking is sourced server-side; not customer-initiated. Read it via [`GET /orders/{id}`](/docs/v2/api-reference/orders/get-order). |
| `GET /v1/trackings/{request_id}` | [`GET /orders/{order_id}`](/docs/v2/api-reference/orders/get-order) | `tracking_numbers[]` on response                                                                                               |
| `POST /v1/trackings/completed`   | *(no equivalent — see [Gap decisions](#gap-decisions))*        |                                                                                                                                |
| `POST /v1/trackings/update`      | *(no equivalent — see [Gap decisions](#gap-decisions))*        |                                                                                                                                |
| `POST /v1/upload_trackings`      | *(no equivalent — see [Gap decisions](#gap-decisions))*        | Bulk customer-supplied tracking upload not in v2                                                                               |
| `GET /v0/tracking_history`       | *(no equivalent)*                                              | History is part of [`GET /orders/{id}`](/docs/v2/api-reference/orders/get-order)                                                    |

### Retailer credentials

| v1                                                       | v2                                                                                                                                                                                      | Notes                                                                                |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `PUT /v1/credentials`                                    | [`POST /managed-accounts`](/docs/v2/api-reference/managed-accounts/create-managed-account) / [`PUT /managed-accounts/{short_id}`](/docs/v2/api-reference/managed-accounts/update-managed-account) | Semantic restructure — see [Retailer credentials](#retailer-credentials-restructure) |
| `GET /v1/account_status/{retailer}/{email}`              | [`GET /managed-accounts`](/docs/v2/api-reference/managed-accounts/list-managed-accounts) + client-side filter                                                                                | No per-account live-status endpoint; account state surfaces in order events          |
| `GET /v1/account_locks`, `POST /v1/account_locks/update` | *(no equivalent — see [Gap decisions](#gap-decisions))*                                                                                                                                 | Lock management not exposed                                                          |

### Payment

| v1                            | v2                                                                      | Notes                                                          |
| ----------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------- |
| `POST /v1/revise_payments`    | *(no equivalent — see [Gap decisions](#gap-decisions))*                 | Per-order payment revision removed; manage at the wallet level |
| `POST /v1/vault/payment_info` | `POST /wallet/checkout` + Stripe SetupIntent (see [Wallet](/docs/v2/wallet)) | Vault replaced by Stripe-managed payment methods               |

### Removed entirely

| v1                                                             | Notes                                  |
| -------------------------------------------------------------- | -------------------------------------- |
| `POST /v1/gift_balances`, `GET /v1/gift_balances/{request_id}` | Gift-card balance checks removed       |
| `POST /v1/messages`, `GET /v1/messages/{request_id}`           | Account messaging removed              |
| `PUT/GET/DELETE /v1/proxies/byop`                              | BYOP proxies not exposed               |
| `GET /v1/flags`, `POST /v1/flags`                              | Customer-toggled feature flags removed |
| `GET /v0/health_check`, `GET /v0/status`                       | Use `GET /health`                      |

### Concepts new in v2 (no v1 equivalent)

These don't appear in v1, but your integration may want to adopt them after the port:

<CardGroup cols={2}>
  <Card title="Wallet" icon="wallet" href="/docs/v2/wallet">
    Balance, top-up, payment methods, transactions, receipts.
  </Card>

  <Card title="API key management" icon="key">
    Programmatic key rotation via `POST /api-keys`, separate live/test keys.
  </Card>

  <Card title="Webhook secret rotation" icon="rotate" href="/docs/v2/api-reference/introduction/webhooks">
    Single per-user webhook URL with HMAC-signed payloads.
  </Card>

  <Card title="Managed accounts" icon="user-lock" href="/docs/v2/api-reference/managed-accounts/create-managed-account">
    First-class retailer credentials with TOTP, email forwarding.
  </Card>

  <Card title="Product search" icon="magnifying-glass" href="/docs/v2/api-reference/products/search">
    Cross-retailer product search and details.
  </Card>

  <Card title="MPP (HTTP 402)" icon="robot" href="/docs/v2/mpp">
    Place orders via the Machine Payments Protocol — no account required.
  </Card>
</CardGroup>

***

## Request shape diffs

### `POST /orders`

<CodeGroup>
  ```json v1 theme={null}
  {
    "client_token": "XXXX",
    "retailer": "amazon",
    "products": [
      {
        "product_id": "B07JGBW826",
        "quantity": 1,
        "seller_selection_criteria": { "prime": true }
      }
    ],
    "shipping_address": {
      "first_name": "Jane",
      "last_name": "Doe",
      "address_line1": "123 Main St",
      "city": "San Francisco",
      "state": "CA",
      "zip_code": "94103",
      "country": "US",
      "phone_number": "555-555-5555"
    },
    "billing_address": { "...same shape..." },
    "payment_method": {
      "name_on_card": "Jane Doe",
      "number": "4242...",
      "expiration_month": 12,
      "expiration_year": 2030,
      "security_code": "123"
    },
    "retailer_credentials": {
      "email": "jane@example.com",
      "password": "...",
      "totp_2fa_key": "..."
    },
    "webhooks": {
      "order_placed":   { "url": "https://yours.example.com/wh/placed" },
      "tracking_obtained": { "url": "https://yours.example.com/wh/tracking" }
    },
    "idempotency_key": "your-key-123",
    "max_price": 5000
  }
  ```

  ```json v2 theme={null}
  {
    "products": [
      {
        "url": "https://www.amazon.com/dp/B07JGBW826",
        "quantity": 1,
        "variant": [{ "label": "Color", "value": "Black" }]
      }
    ],
    "shipping_address": {
      "first_name": "Jane",
      "last_name": "Doe",
      "address_line1": "123 Main St",
      "city": "San Francisco",
      "state": "CA",
      "postal_code": "94103",
      "country": "US",
      "phone_number": "+14155551234"
    },
    "max_price": 5000,
    "idempotency_key": "your-key-123",
    "retailer_credentials_id": "zn_acct_AB12CDEF",
    "po_number": "PO-9001",
    "metadata": { "your": "metadata" }
  }
  ```
</CodeGroup>

**Field-by-field transformation:**

| v1 field                                 | v2 destination                                | Notes                                                                                                                                    |
| ---------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `client_token`                           | *drop*                                        | Carried in `Authorization: Bearer …`                                                                                                     |
| `retailer`                               | *drop*                                        | Inferred from product URLs                                                                                                               |
| `products[].product_id`                  | `products[].url`                              | Convert ID → full retailer URL (e.g. `https://www.amazon.com/dp/{id}`).                                                                  |
| `products[].quantity`                    | `products[].quantity`                         | 1 ≤ qty ≤ 100                                                                                                                            |
| `products[].seller_selection_criteria`   | *drop*                                        | No v2 equivalent — see [Gap decisions](#gap-decisions)                                                                                   |
| `products[].variants`                    | `products[].variant`                          | List of `{label, value}` pairs                                                                                                           |
| `shipping_address.zip_code`              | `shipping_address.postal_code`                | **Field renamed.**                                                                                                                       |
| `shipping_address.phone_number`          | `shipping_address.phone_number`               | v2 normalises to E.164. Pass through unchanged; v2 will reformat.                                                                        |
| `billing_address`                        | *drop*                                        | Wallet-based payment                                                                                                                     |
| `payment_method`                         | *drop*                                        | Wallet-based payment; ensure wallet is funded                                                                                            |
| `retailer_credentials` (embedded object) | `retailer_credentials_id` (`short_id` string) | **Two-step migration** — see [Retailer credentials](#retailer-credentials-restructure)                                                   |
| `webhooks`                               | *drop*                                        | Per-user webhook URL configured once; see [Webhook handler consolidation](#webhook-handler-consolidation)                                |
| `idempotency_key`                        | `idempotency_key`                             | Pass through (max 36 chars). v2 generates one if omitted. Duplicate keys return `409 already_exists` rather than placing a second order. |
| `max_price`                              | `max_price`                                   | **Confirm units.** v2 requires integer cents. v1 examples and docs commonly use cents; double-check if your code passes dollars.         |
| `po_number`                              | `po_number`                                   | Pass through                                                                                                                             |
| *(custom v1 metadata fields)*            | `metadata: {...}`                             | Stash arbitrary v1 fields not otherwise mapped under `metadata` so they're queryable on the order response                               |

### `POST /returns`

<CodeGroup>
  ```json v1 theme={null}
  {
    "retailer": "amazon",
    "merchant_order_id": "111-2222222-3333333",
    "products": [{ "product_id": "B07JGBW826", "quantity": 1 }],
    "reason_code": "defective_item",
    "method_code": "ups_dropoff",
    "retailer_credentials": { "...embedded..." },
    "webhooks": { "return_placed": { "url": "..." } },
    "idempotency_key": "ret-key-123"
  }
  ```

  ```json v2 theme={null}
  {
    "order_id": "01931f3c-cd6f-7f00-8000-1234567890ab",
    "items": [{ "order_item_id": "01931f3c-...", "quantity": 1 }],
    "reason": "defective",
    "notes": "Arrived with cracked screen."
  }
  ```
</CodeGroup>

**Field-by-field transformation:**

| v1                                   | v2                                   | Notes                                                                                                                                         |
| ------------------------------------ | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `retailer` + `merchant_order_id`     | `order_id` (v2 UUID)                 | **Returns must reference a v2-tracked order.** Orders placed on v1 cannot be returned through v2 — handle in [Gap decisions](#gap-decisions). |
| `products[].product_id` + `quantity` | `items[].order_item_id` + `quantity` | Look up v2 order item UUIDs via [`GET /orders/{order_id}`](/docs/v2/api-reference/orders/get-order) → `items[].id`                                 |
| `reason_code` (free string)          | `reason` (enum)                      | See [reason code lookup](#return-reason-code-lookup)                                                                                          |
| `method_code`                        | *drop*                               | v2 picks return method automatically                                                                                                          |
| `retailer_credentials`               | *drop*                               | Inherited from the original order                                                                                                             |
| `webhooks`                           | *drop*                               | Per-user webhook URL                                                                                                                          |
| `idempotency_key`                    | *drop*                               | Not exposed on the v2 returns endpoint                                                                                                        |

### Retailer credentials restructure

v1 ships credentials **inside every order request**. v2 stores them once as a first-class resource and references by `short_id`.

<Steps>
  <Step title="One-time: create a managed account per credential">
    For each `(retailer, account_email)` pair in your v1 integration:

    ```http theme={null}
    POST /managed-accounts
    Authorization: Bearer zn_live_…
    Content-Type: application/json

    {
      "retailer": "amazon",
      "email": "buyer@example.com",
      "password": "…",
      "totp_secret": "…",
      "retailer_config": { ... }
    }
    ```

    Response includes a `short_id` like `zn_acct_AB12CDEF`. Save it to your config store.
  </Step>

  <Step title="On every order: pass the short_id">
    ```json theme={null}
    {
      "retailer_credentials_id": "zn_acct_AB12CDEF",
      "..."
    }
    ```

    Omit `retailer_credentials_id` to let v2 auto-select an available managed account for the retailer (subject to availability and account type).
  </Step>
</Steps>

### Return reason code lookup

v1 returns accept any free-text string in `reason_code`. v2 enforces a 10-value enum. Use this mapping:

| v1 `reason_code` (representative)                                | v2 `reason`                                       |
| ---------------------------------------------------------------- | ------------------------------------------------- |
| `defective_item`, `defective`, `broken`, `not_working`           | `defective`                                       |
| `damaged_item`, `damaged`, `arrived_damaged`                     | `damaged`                                         |
| `wrong_item`, `not_what_i_ordered`, `incorrect_item`             | `wrong_item`                                      |
| `wrong_size`, `does_not_fit`, `size_too_small`, `size_too_large` | `wrong_size`                                      |
| `not_as_described`, `misleading_description`                     | `not_as_described`                                |
| `empty_box`, `missing_contents`                                  | `empty_box`                                       |
| `not_delivered`, `lost_in_transit`, `package_never_arrived`      | `not_delivered`                                   |
| `no_longer_needed`, `bought_by_mistake`, `changed_mind`          | `no_longer_needed`                                |
| `forced_cancellation`, `seller_cancelled`, `unable_to_ship`      | `forced_cancellation`                             |
| *(anything else)*                                                | `other` (preserve the original string in `notes`) |

***

## Response shape diffs

### Order response

| v1                                                         | v2                                                  | Notes                                                                                                           |
| ---------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `request_id`                                               | `id` (UUID)                                         | Type changes from opaque string to UUID — your storage and any code path matching `^[0-9a-f-]+$` must accept it |
| `_request_status` / `_status` / various v1 statuses        | `status` (string enum)                              | See [status mapping](#status-mapping)                                                                           |
| `merchant_order_ids[].merchant_order_id`                   | `metadata.merchant_order_id`                        | Embedded under `metadata` rather than as a sibling field                                                        |
| `price_components.{subtotal,tax,shipping,total}`           | `metadata.price_components.{...}`                   | Shape preserved, nested under `metadata`                                                                        |
| `tracking[].{merchant_order_id, carrier, tracking_number}` | `tracking_numbers[].{carrier, tracking_number, id}` | Field renamed                                                                                                   |
| `password_changed`                                         | *not exposed*                                       | Surfaces via order events / webhooks                                                                            |

### Status mapping

v1's `_status` field can take many forms across versions. Map to v2's strict enum:

| v1 status (representative)              | v2 `status`             |
| --------------------------------------- | ----------------------- |
| `pending`, `queued`, `submitted`        | `pending`               |
| `in_progress`, `processing`, `running`  | `in_progress`           |
| `placed`, `ordered`, `succeeded`        | `order_placed`          |
| `failed`, `error`                       | `order_failed`          |
| `aborted`, `cancelled_by_user`          | `cancelled`             |
| `cancelled_by_retailer`, `out_of_stock` | `cancelled_by_retailer` |

If your code uses `if status == "placed"` style branching, rewrite to the v2 enum value. Centralise the comparison in a single helper if more than three call sites match.

***

## Webhook handler consolidation

### Configuration

v1 registered URLs **per order request** in a `webhooks` object on each call. v2 registers **one URL per user**:

```bash theme={null}
# Set the webhook URL once
curl -X POST https://<v2-host>/users \
  -H "Authorization: Bearer zn_live_…" \
  -H "Content-Type: application/json" \
  -d '{"webhook_url": "https://yours.example.com/zinc-webhooks"}'

# Mint a webhook secret (shown once)
curl -X POST https://<v2-host>/users/webhook-secret \
  -H "Authorization: Bearer zn_live_…"
```

Every event for orders and returns belonging to that user is POSTed to that single URL with an HMAC-SHA256 signature. The webhook secret has the prefix `zn_whsec_*` so it's easy to spot in logs/config and distinguish from API keys.

### Event name mapping

| v1 event (per-request key)              | v2 event name                                                                                            |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `order_placed`                          | `order.placed`                                                                                           |
| `order_failed`                          | `order.failed`                                                                                           |
| `tracking_obtained`, `tracking_updated` | `order.tracking_received`                                                                                |
| *(none — new in v2)*                    | `order.started`                                                                                          |
| *(none — new in v2)*                    | `order.delivered`                                                                                        |
| *(none — new in v2)*                    | `order.cancelled`                                                                                        |
| `return_placed`                         | `return.created`                                                                                         |
| `return_failed`                         | *(no direct equivalent)* — return lifecycle uses `return.approved` / `return.denied` / `return.credited` |
| `status_updated`, `case_updated`        | *(no equivalent)* — surfaced via order event stream, not as a webhook                                    |
| `request_succeeded`, `request_failed`   | *(dropped)* — these were envelope-level events; v2 fires resource-level events directly                  |

### Payload + signature verification

Every v2 webhook POST carries:

* **Header** `X-Webhook-Event: <event name>` — e.g. `order.placed`
* **Header** `X-Webhook-Signature: <hex HMAC-SHA256 of raw body using your webhook secret>`
* **Body** JSON: `{ "event": "<name>", "order_id": "…", "data": {…} }` (and `"return_id"` for return events)

<Warning>
  **Hash the raw body, not the parsed-and-re-serialised body.** JSON re-serialisation may reorder keys or change whitespace, which breaks the signature. Capture the raw request body before parsing.
</Warning>

The HMAC is computed over the **raw request body** with `hmac.new(secret, body, sha256).hexdigest()`. Reject any request whose signature doesn't match, using a constant-time comparison (`hmac.compare_digest` or your language's equivalent — `===` leaks timing).

### Handler hardening

A few operational rules to keep webhook delivery healthy under load:

* **Respond 2xx fast.** Zinc retries on non-2xx and on timeouts. Do the minimum work synchronously (signature check, dedupe, enqueue) and return; defer heavy processing to a background job. A handler that does its full processing inline will pile up retries during transient backend slowness.
* **Dedupe by `(order_id, event)` or `(return_id, event)`.** Network retries can deliver the same event twice. Keep a short-lived seen-set (Redis with TTL, or a unique constraint on a webhook-events table) and short-circuit duplicates.
* **Tolerate out-of-order arrival.** `order.placed` and `order.tracking_received` can arrive in either order if a retry is involved. Make handlers idempotent and don't assume sequence.

### Refactor pattern

The v1 handler was typically a switch on URL path (one route per event). Collapse to a single route + dispatch on the `X-Webhook-Event` header value (or the `event` field in the body):

<CodeGroup>
  ```python Python theme={null}
  import hmac, hashlib

  def zinc_webhook(request):
      sig = request.headers["X-Webhook-Signature"]
      expected = hmac.new(
          WEBHOOK_SECRET.encode(),
          request.body,
          hashlib.sha256,
      ).hexdigest()
      if not hmac.compare_digest(sig, expected):
          return 401

      event = request.headers["X-Webhook-Event"]
      payload = request.json()
      dispatch[event](payload)
      return 200
  ```

  ```javascript Node theme={null}
  import crypto from "crypto";

  function zincWebhook(req, res) {
    const sig = req.headers["x-webhook-signature"];
    const expected = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(req.rawBody)
      .digest("hex");

    if (
      sig.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    ) {
      return res.status(401).end();
    }

    const event = req.headers["x-webhook-event"];
    dispatch[event](JSON.parse(req.rawBody));
    res.status(200).end();
  }
  ```
</CodeGroup>

***

## Sandbox testing

* Mint a `zn_test_*` key in the dashboard or via `POST /api-keys { "is_test": true }`. The [Sandbox guide](/docs/v2/api-reference/introduction/sandbox) walks through how test mode behaves.
* Use the **same v2 base URL** with the test key — test mode is per-key, not per-host.
* Orders placed with `zn_test_*` keys don't actually purchase products; webhooks still fire with realistic timing.

<Warning>
  Never mix `zn_test_*` and `zn_live_*` keys in the same environment. A `zn_live_*` key in your CI config places real orders. Add a config-time assertion that rejects the wrong prefix per environment.
</Warning>

### Test product catalog

Drive your test scenarios with the dedicated test product URLs. Fetch the current list from `GET /orders/test-products`. At minimum exercise:

| URL                                                        | Mode             | What it exercises                                                                                         |
| ---------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------- |
| `https://zinc.com/shop/products/test-success`              | —                | Happy path through `order_placed`, tracking numbers, and price components                                 |
| `https://zinc.com/shop/products/test-invalid-address`      | **Synchronous**  | Rejected at order-creation time (4xx)                                                                     |
| `https://zinc.com/shop/products/test-url-unreachable`      | Synchronous      | 4xx at creation                                                                                           |
| `https://zinc.com/shop/products/test-insufficient-funds`   | Synchronous      | 4xx at creation                                                                                           |
| `https://zinc.com/shop/products/test-out-of-stock`         | **Asynchronous** | Order creates successfully; `order.failed` webhook arrives later with `error_type="product_out_of_stock"` |
| `https://zinc.com/shop/products/test-price-exceeded`       | Asynchronous     | `order.failed` with `error_type="max_price_exceeded"`                                                     |
| `https://zinc.com/shop/products/test-invalid-variant`      | Asynchronous     | `order.failed`                                                                                            |
| `https://zinc.com/shop/products/test-shipping-unavailable` | Asynchronous     | `order.failed`                                                                                            |

The synchronous-vs-asynchronous split matters: see [Behavioral changes worth knowing](#sync-vs-async-failures) above. Make sure your test suite covers both arrival paths.

### Sandbox limitations

Test mode intentionally skips a few checks so you can develop without a funded wallet or real product URLs:

* **No wallet balance check** — orders go through even if the wallet would be insufficient in production
* **No URL reachability check** — fake product URLs are accepted
* **No address validation** — addresses aren't validated against Google's address-validation API

Plan a small set of **real production orders** as the final cutover check — sandbox can't catch problems with any of those three.

***

## Gap decisions

A few v1 endpoints have no v2 equivalent. For each, capture your decision in a `MIGRATION_GAPS.md` (or wherever you track porting decisions) so they don't get lost during code review:

| File                      | Line | v1 endpoint                  | What we use it for            | Decision                      |
| ------------------------- | ---- | ---------------------------- | ----------------------------- | ----------------------------- |
| `src/handlers/balance.js` | 42   | `GET /v1/gift_balances`      | Display gift balance in admin | drop                          |
| `src/api/client.ts`       | 117  | `POST /v1/messages`          | Send account-recovery message | workaround                    |
| `src/jobs/retry.py`       | 23   | `POST /v1/orders/{id}/retry` | Auto-retry stuck orders       | drop (v2 retries server-side) |

Email [support@zinc.com](mailto:support@zinc.com) for any gap where you can't find a workaround — most have one, and a few may justify keeping a small v1 dependency until v1 sunset.

***

## Validation

After the port, run this end-to-end smoke test against the v2 sandbox using a `zn_test_*` key:

<Steps>
  <Step title="Confirm a managed account exists for the test retailer">
    Either via `GET /managed-accounts` or by creating one inline.
  </Step>

  <Step title="POST /orders for a test product">
    Assert 201 response; capture the returned `id`.
  </Step>

  <Step title="Poll GET /orders/{id} until status reaches order_placed">
    Typically completes in under a minute in sandbox mode.
  </Step>

  <Step title="Verify your webhook handler received order.started → order.placed">
    Both events should arrive with valid HMAC signatures.
  </Step>

  <Step title="POST /returns { order_id, items, reason } for the same order">
    Assert 201 response.
  </Step>

  <Step title="Verify your webhook handler received return.created">
    The return lifecycle flows from here.
  </Step>
</Steps>

If your existing v1 test suite covered these flows, port the tests first and use them as the validation harness.

***

<Card title="Want to automate the port?" icon="robot" href="/docs/v2/migration-agent-runbook">
  The agent runbook turns this guide into a workflow Claude Code can execute directly against your integration repo — discovery → auth swap → endpoint port → webhook consolidation → validation.
</Card>
