What changed at a glance
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.Store in your config
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: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
Returns
Cancellations
Tracking
Retailer credentials
Payment
Removed entirely
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
POST /returns
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.On every order: pass the short_id
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 inreason_code. v2 enforces a 10-value enum. Use this mapping:
Response shape diffs
Order response
Status mapping
v1’s_status field can take many forms across versions. Map to v2’s strict enum:
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
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:
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:
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.POST /orders for a test product
id.Poll GET /orders/{id} until status reaches order_placed
Verify your webhook handler received order.started → order.placed
POST /returns { order_id, items, reason } for the same order
Verify your webhook handler received return.created

