Skip to main content
Looking for the high-level “why” and “should I migrate”? Start with the V1 → V2 overview. This page is the technical detail — port your integration code with confidence.
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 — it’s a workflow for Claude Code (or any coding agent) to execute this guide against your integration codebase.
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.

What changed at a glance

Areav1v2
AuthHTTP Basic client_token:password?Authorization: Bearer zn_live_…
Base URLhttps://api.zinc.io/v0 / /v1 / /v2New v2 host (published in the dashboard); no version prefix in path
PaymentPer-request payment_method blockWallet-based — top up via Stripe, orders charge wallet automatically
Retailer credentialsEmbedded in every order requestFirst-class managed_accounts objects; orders reference a retailer_credentials_id
WebhooksPer-request webhooks object, 10 event types, URLs per eventOne webhook_url per user, dotted event names, HMAC-SHA256 signature, X-Webhook-Event header
Idempotencyidempotency_key in request bodyStill supported — idempotency_key (max 36 chars) on POST /orders
Order ID shapeOpaque request_id stringUUID
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/abortMerged into POST /orders/{id}/cancel (pending orders only — post-placement, file a return)
ReturnsFree-text reason_code stringStrict 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, 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}. 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

POST /v1/orders HTTP/1.1
Host: api.zinc.io
Authorization: Basic <base64(client_token:password)>
Content-Type: application/json

How to get a v2 API key

1

Sign in to the v2 dashboard

Use Stytch magic-link via app.zinc.com.
2

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

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.

Pre-flight checklist

Before any code change, complete these manual steps in the v2 dashboard. Code-level edits can’t do them for you:
Use the dashboard or POST /api-keys. Test mode is per-key, not per-host — the same base URL works for both.
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 for the full payment model.
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 for payload + signature details.
For every retailer login your v1 integration uses, call POST /managed-accounts and record the returned short_id (format zn_acct_XXXXXXXX). Store these in your config — orders reference them by short_id.

Endpoint mapping

Orders

v1v2Notes
POST /v1/ordersPOST /ordersRequest body restructured — see Request shape diffs
POST /v0/orderPOST /ordersSame as /v1/orders
GET /v1/orders/{request_id}GET /orders/{order_id}Response shape changes — see Response shape diffs
GET /v1/ordersGET /orders?limit=&offset=Pagination params; limit max 500
GET /v1/orders/{request_id}/shipmentsGET /orders/{order_id}Read tracking_numbers[] on the order response
POST /v1/orders/{request_id}/cancelPOST /orders/{order_id}/cancelPending orders only in v2. After placement, file a return instead.
POST /v1/orders/{request_id}/abortPOST /orders/{order_id}/cancelSemantic 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)v2 handles retries server-side via order attempts
POST /v0/order/{request_id}/danger_footgun_repeat(no equivalent — see Gap decisions)Place a new order with a new idempotency_key
POST /v0/order/{request_id}/update(no equivalent — see Gap decisions)Customer-supplied status push not in v2
POST /v0/order/{request_id}/insert_merchant_order_id(no equivalent — see Gap decisions)Merchant order IDs are tracked internally
GET /v1/queued_ordersGET /orders?status_filter=pendingStatus query supplants the dedicated queue endpoint

Returns

v1v2Notes
POST /v1/returnsPOST /returnsRequest body restructured — see Request shape diffs
GET /v1/returns/{request_id}GET /returns/{return_request_id}
POST /v1/orders/{request_id}/returnPOST /returns with order_idConvenience route removed; pass order_id explicitly
GET /v1/orders/{request_id}/returnGET /returns?order_id=…List and pick latest by created_at
POST /v1/returns/{request_id}/status(no equivalent — see 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)Same as above

Cancellations

v1v2Notes
POST /v1/cancellationsPOST /orders/{order_id}/cancelResource removed; merged into orders
GET /v1/cancellations/{request_id}GET /orders/{order_id}/eventsCancellation progress is part of the order event stream
POST /v1/cancellations/{request_id}/retry(no equivalent — see Gap decisions)

Tracking

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

Retailer credentials

v1v2Notes
PUT /v1/credentialsPOST /managed-accounts / PUT /managed-accounts/{short_id}Semantic restructure — see Retailer credentials
GET /v1/account_status/{retailer}/{email}GET /managed-accounts + client-side filterNo 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)Lock management not exposed

Payment

v1v2Notes
POST /v1/revise_payments(no equivalent — see Gap decisions)Per-order payment revision removed; manage at the wallet level
POST /v1/vault/payment_infoPOST /wallet/checkout + Stripe SetupIntent (see Wallet)Vault replaced by Stripe-managed payment methods

Removed entirely

v1Notes
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/byopBYOP proxies not exposed
GET /v1/flags, POST /v1/flagsCustomer-toggled feature flags removed
GET /v0/health_check, GET /v0/statusUse 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:

Wallet

Balance, top-up, payment methods, transactions, receipts.

API key management

Programmatic key rotation via POST /api-keys, separate live/test keys.

Webhook secret rotation

Single per-user webhook URL with HMAC-signed payloads.

Managed accounts

First-class retailer credentials with TOTP, email forwarding.

Product search

Cross-retailer product search and details.

MPP (HTTP 402)

Place orders via the Machine Payments Protocol — no account required.

Request shape diffs

POST /orders

{
  "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
}
Field-by-field transformation:
v1 fieldv2 destinationNotes
client_tokendropCarried in Authorization: Bearer …
retailerdropInferred from product URLs
products[].product_idproducts[].urlConvert ID → full retailer URL (e.g. https://www.amazon.com/dp/{id}).
products[].quantityproducts[].quantity1 ≤ qty ≤ 100
products[].seller_selection_criteriadropNo v2 equivalent — see Gap decisions
products[].variantsproducts[].variantList of {label, value} pairs
shipping_address.zip_codeshipping_address.postal_codeField renamed.
shipping_address.phone_numbershipping_address.phone_numberv2 normalises to E.164. Pass through unchanged; v2 will reformat.
billing_addressdropWallet-based payment
payment_methoddropWallet-based payment; ensure wallet is funded
retailer_credentials (embedded object)retailer_credentials_id (short_id string)Two-step migration — see Retailer credentials
webhooksdropPer-user webhook URL configured once; see Webhook handler consolidation
idempotency_keyidempotency_keyPass through (max 36 chars). v2 generates one if omitted. Duplicate keys return 409 already_exists rather than placing a second order.
max_pricemax_priceConfirm units. v2 requires integer cents. v1 examples and docs commonly use cents; double-check if your code passes dollars.
po_numberpo_numberPass 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

{
  "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"
}
Field-by-field transformation:
v1v2Notes
retailer + merchant_order_idorder_id (v2 UUID)Returns must reference a v2-tracked order. Orders placed on v1 cannot be returned through v2 — handle in Gap decisions.
products[].product_id + quantityitems[].order_item_id + quantityLook up v2 order item UUIDs via GET /orders/{order_id}items[].id
reason_code (free string)reason (enum)See reason code lookup
method_codedropv2 picks return method automatically
retailer_credentialsdropInherited from the original order
webhooksdropPer-user webhook URL
idempotency_keydropNot 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.
1

One-time: create a managed account per credential

For each (retailer, account_email) pair in your v1 integration:
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.
2

On every order: pass the short_id

{
  "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).

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_workingdefective
damaged_item, damaged, arrived_damageddamaged
wrong_item, not_what_i_ordered, incorrect_itemwrong_item
wrong_size, does_not_fit, size_too_small, size_too_largewrong_size
not_as_described, misleading_descriptionnot_as_described
empty_box, missing_contentsempty_box
not_delivered, lost_in_transit, package_never_arrivednot_delivered
no_longer_needed, bought_by_mistake, changed_mindno_longer_needed
forced_cancellation, seller_cancelled, unable_to_shipforced_cancellation
(anything else)other (preserve the original string in notes)

Response shape diffs

Order response

v1v2Notes
request_idid (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 statusesstatus (string enum)See status mapping
merchant_order_ids[].merchant_order_idmetadata.merchant_order_idEmbedded 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_changednot exposedSurfaces 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, submittedpending
in_progress, processing, runningin_progress
placed, ordered, succeededorder_placed
failed, errororder_failed
aborted, cancelled_by_usercancelled
cancelled_by_retailer, out_of_stockcancelled_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:
# 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_placedorder.placed
order_failedorder.failed
tracking_obtained, tracking_updatedorder.tracking_received
(none — new in v2)order.started
(none — new in v2)order.delivered
(none — new in v2)order.cancelled
return_placedreturn.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)
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.
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):
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

Sandbox testing

  • Mint a zn_test_* key in the dashboard or via POST /api-keys { "is_test": true }. The Sandbox guide 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.
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.

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:
URLModeWhat it exercises
https://zinc.com/shop/products/test-successHappy path through order_placed, tracking numbers, and price components
https://zinc.com/shop/products/test-invalid-addressSynchronousRejected at order-creation time (4xx)
https://zinc.com/shop/products/test-url-unreachableSynchronous4xx at creation
https://zinc.com/shop/products/test-insufficient-fundsSynchronous4xx at creation
https://zinc.com/shop/products/test-out-of-stockAsynchronousOrder creates successfully; order.failed webhook arrives later with error_type="product_out_of_stock"
https://zinc.com/shop/products/test-price-exceededAsynchronousorder.failed with error_type="max_price_exceeded"
https://zinc.com/shop/products/test-invalid-variantAsynchronousorder.failed
https://zinc.com/shop/products/test-shipping-unavailableAsynchronousorder.failed
The synchronous-vs-asynchronous split matters: see Behavioral changes worth knowing 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:
FileLinev1 endpointWhat we use it forDecision
src/handlers/balance.js42GET /v1/gift_balancesDisplay gift balance in admindrop
src/api/client.ts117POST /v1/messagesSend account-recovery messageworkaround
src/jobs/retry.py23POST /v1/orders/{id}/retryAuto-retry stuck ordersdrop (v2 retries server-side)
Email 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:
1

Confirm a managed account exists for the test retailer

Either via GET /managed-accounts or by creating one inline.
2

POST /orders for a test product

Assert 201 response; capture the returned id.
3

Poll GET /orders/{id} until status reaches order_placed

Typically completes in under a minute in sandbox mode.
4

Verify your webhook handler received order.started → order.placed

Both events should arrive with valid HMAC signatures.
5

POST /returns { order_id, items, reason } for the same order

Assert 201 response.
6

Verify your webhook handler received return.created

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

Want to automate the port?

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.