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


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

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

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

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

Field-by-field transformation:

POST /returns

Field-by-field transformation:

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:
Response includes a short_id like zn_acct_AB12CDEF. Save it to your config store.
2

On every order: pass the short_id

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:

Response shape diffs

Order response

Status mapping

v1’s _status field can take many forms across versions. Map to v2’s strict enum: 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:
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

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):

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