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}/cancelreturns204and works only whilestatus == "pending".- Once the order is
in_progressororder_placed, the endpoint returnsorder_not_cancellableand the cancellation path is closed. - Post-placement cancellations now happen retailer-side and arrive as an
order.cancelledwebhook 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.
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 /orderswith acodeandmessage. 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.failedwebhooks with anerror_typefield. Examples:product_out_of_stock,max_price_exceeded,invalid_variant,shipping_unavailable,checkout_blocked. Your webhook handler handles these.
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
How to get a v2 API key
Sign in to the v2 dashboard
Mint API keys
zn_live_* key for production and a zn_test_* key for
sandbox testing — either from the dashboard UI or via
POST /api-keys.Pre-flight checklist
Before any code change, complete these manual steps in the v2 dashboard. Code-level edits can’t do them for you:1. Sign up for v2 via Stytch magic-link
1. Sign up for v2 via Stytch magic-link
2. Mint a `zn_live_*` API key and a `zn_test_*` key
2. Mint a `zn_live_*` API key and a `zn_test_*` key
POST /api-keys. Test mode is per-key, not per-host — the same base URL works for both.3. Add a payment method and top up your wallet
3. Add a payment method and top up your wallet
POST /wallet/top-up / POST /wallet/checkout. See the Wallet guide for the full payment model.4. Register a single webhook URL on your user
4. Register a single webhook URL on your user
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.5. Create a managed account for each `(retailer, account_email)` pair
5. Create a managed account for each `(retailer, account_email)` pair
short_id (format zn_acct_XXXXXXXX). Store these in your config — orders reference them by short_id.Endpoint mapping
Orders
| v1 | v2 | Notes |
|---|---|---|
POST /v1/orders | POST /orders | Request body restructured — see Request shape diffs |
POST /v0/order | POST /orders | Same as /v1/orders |
GET /v1/orders/{request_id} | GET /orders/{order_id} | Response shape changes — see Response shape diffs |
GET /v1/orders | GET /orders?limit=&offset= | Pagination params; limit max 500 |
GET /v1/orders/{request_id}/shipments | GET /orders/{order_id} | Read tracking_numbers[] on the order response |
POST /v1/orders/{request_id}/cancel | POST /orders/{order_id}/cancel | Pending orders only in v2. After placement, file a return instead. |
POST /v1/orders/{request_id}/abort | POST /orders/{order_id}/cancel | 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) | 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_orders | GET /orders?status_filter=pending | Status query supplants the dedicated queue endpoint |
Returns
| v1 | v2 | Notes |
|---|---|---|
POST /v1/returns | POST /returns | Request body restructured — see Request shape diffs |
GET /v1/returns/{request_id} | GET /returns/{return_request_id} | |
POST /v1/orders/{request_id}/return | POST /returns 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) | 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
| v1 | v2 | Notes |
|---|---|---|
POST /v1/cancellations | POST /orders/{order_id}/cancel | Resource removed; merged into orders |
GET /v1/cancellations/{request_id} | GET /orders/{order_id}/events | Cancellation progress is part of the order event stream |
POST /v1/cancellations/{request_id}/retry | (no equivalent — see Gap decisions) |
Tracking
| v1 | v2 | Notes |
|---|---|---|
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
| v1 | v2 | Notes |
|---|---|---|
PUT /v1/credentials | POST /managed-accounts / PUT /managed-accounts/{short_id} | Semantic restructure — see Retailer credentials |
GET /v1/account_status/{retailer}/{email} | GET /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) | Lock management not exposed |
Payment
| v1 | v2 | Notes |
|---|---|---|
POST /v1/revise_payments | (no equivalent — see Gap decisions) | Per-order payment revision removed; manage at the wallet level |
POST /v1/vault/payment_info | POST /wallet/checkout + Stripe SetupIntent (see 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:Wallet
API key management
POST /api-keys, separate live/test keys.Webhook secret rotation
Managed accounts
Product search
MPP (HTTP 402)
Request shape diffs
POST /orders
| 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 |
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 |
webhooks | drop | Per-user webhook URL configured once; see 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
| 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. |
products[].product_id + quantity | items[].order_item_id + quantity | Look up v2 order item UUIDs via GET /orders/{order_id} → items[].id |
reason_code (free string) | reason (enum) | See 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 byshort_id.
One-time: create a managed account per credential
(retailer, account_email) pair in your v1 integration:short_id like zn_acct_AB12CDEF. Save it to your config store.Return reason code lookup
v1 returns accept any free-text string inreason_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 |
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 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 awebhooks object on each call. v2 registers one URL per user:
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)
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.placedandorder.tracking_receivedcan 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 theX-Webhook-Event header value (or the event field in the body):
Sandbox testing
- Mint a
zn_test_*key in the dashboard or viaPOST /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.
Test product catalog
Drive your test scenarios with the dedicated test product URLs. Fetch the current list fromGET /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 |
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
Gap decisions
A few v1 endpoints have no v2 equivalent. For each, capture your decision in aMIGRATION_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) |
Validation
After the port, run this end-to-end smoke test against the v2 sandbox using azn_test_* key:
Confirm a managed account exists for the test retailer
GET /managed-accounts or by creating one inline.Poll GET /orders/{id} until status reaches order_placed
Verify your webhook handler received order.started → order.placed

