Skip to main content

Shopify order mirroring

If you've connected a Shopify store to Von Payments, you can have a successful payment mirror the order onto that store automatically — a Shopify order is created for you as soon as the payment completes. You opt in per charge by attaching a mirror block to the request.

This page is the reference for that block: where it goes, every field it accepts, what Shopify quietly does not carry, how refunds behave once an order is mirrored, a full worked example, and the errors you can get back.

Before you start

The store you name in mirror.shop must already be connected to your Von Payments account. Connect it in the dashboard under Connected Platforms → Shopify. If you send a mirror.shop that isn't a connected, active store, the session-create call is rejected with mirror_shop_not_authorized — this is a deliberate guard so no merchant can drive orders into a store they don't own.

Connect exactly ONE Shopify store

A second connected store silently stops mirroring for both

Shopify mirroring does not route per store. Nothing stops you connecting a second Shopify store, but while more than one connection is active, mirror.shop is not consulted and every Shopify mirror stops: no order is created on either store.

Charges still succeed and buyers are still billed. The only signal is the order poll reporting not_mirrored, which is the same value it reports for a disconnected or revoked store, so this is easy to misdiagnose as a broken connection. Disconnect the extra store to resume mirroring.

Required Shopify permissions

The one-click install asks Shopify for order, customer and draft-order permissions:

write_orders, write_customers, read_customers, read_orders, write_draft_orders

Two of them are load-bearing: write_orders (create the order) and write_customers (attach the buyer). Without either, no mirror can ever succeed. read_orders only feeds a background drift check, and read_customers is not read by anything today.

The one-click install grants what's needed. If your store was connected by pasting an Admin API token instead, it can be left with no recorded permissions at all. Two outcomes follow, and they look nothing alike:

  • We know your grant and it's missing a load-bearing permission. On hosted checkout the charge is refused before any money moves with mirror_shop_missing_scopes (422). Re-grant and retry. Note this check does not run for a raw mirror block sent to POST /v1/payment_intents: there a missing permission surfaces the same way as the case below, as a successful charge with no store order.
  • We have no record of your grant. Nothing is refused, because "we weren't told" is not the same fact as "you were granted nothing". The charge succeeds, mirroring runs after the payment, and the order-create is what discovers the problem, so the order poll reports failed.
Connected by pasting a token? Reconnect before you debug anything else

If the order poll returns failed and orders aren't appearing, missing permissions are the most common cause — reconnect the store through the one-click install first, then retry.

Where the block goes

mirror is an optional top-level field on both charge surfaces, in the same position. It is not a separate endpoint. Attach it and the order is mirrored when the payment completes; omit it and nothing is mirrored.

Hosted checkout — POST /v1/sessions

POST /v1/sessions
{ "amount": 2998, "currency": "USD", "mirror": { "...": "..." } }

Direct charge — POST /v1/payment_intents

POST /v1/payment_intents
{ "amount": 2998, "currency": "USD", "mirror": { "...": "..." } }

On this endpoint the block is also accepted nested at metadata.mirror. That form is fully supported and is not being retired: a top-level mirror is folded into metadata.mirror before anything validates or stores it, so the two positions produce the same charge and the same stored record. Sending it in both places is fine when the two blocks are identical. If they differ, the request is refused with mirror_alias_conflict rather than one being picked silently.

In every position, on both surfaces, the mirror value is a JSON object. A JSON string is never a valid mirror block anywhere.

On the direct charge, most of a bad Shopify mirror is not rejected, and the charge still succeeds

This is the one behaviour on this page most likely to cost you an order.

On the hosted flow the block is checked before the buyer is charged: a bad block returns a 400 and no money moves.

On a direct-charge Shopify mirror, four checks run first and refuse the charge before any money moves:

  1. The mirror value isn't a JSON object at all → mirror_malformed.
  2. The block contains the reserved key _gdpr_redacted, at any depth → mirror_malformed. That key is written only by our privacy erasure process and may never be supplied by a caller. If you carry your own privacy state in the block, rename that key.
  3. The block carries parent_order_refmirror_upsell_unsupported. A Shopify order can only be created, never appended to.
  4. The block's own line_items (plus shipping) add up to more than the amount you're charging → mirror_amount_below_line_items.

Everything else is checked after the charge has already succeeded. So an unconnected store, missing permissions, an oversized block, or a block that's well-formed JSON but wrong in content produces a completed payment and an order that never appears, with no error on the charge response.

Never treat a 201 on this path as proof the order exists. Confirm it: poll GET /v1/payment_intents/{id}/mirror-order, or subscribe to the mirror.order.created webhook. See Confirming the order.

Describing the order with order / mirrorTo avoids this: those checks run before the charge on Shopify too, so an oversized block, an unconnected store or a missing permission comes back as an error with no money moved.

Charging less than the order is worth

amount and the mirror's line items are allowed to differ — that's normal. amount is what the card is charged; the mirror describes the store order, and tax, fees, Von-only discounts and partial mirrors all make them legitimately diverge. A plain mismatch is only logged for our operations team; it never blocks you.

One direction is refused. If the mirror's line_items plus shipping add up to more than amount, the request returns 400 mirror_amount_below_line_items and nothing is charged.

The reason is what happens otherwise: the store creates the order at its own higher figure, then refuses to mark it paid — so the buyer is charged against an order that permanently shows as unpaid, and no amount of retrying fixes it. Catching it before the charge is the only place it can be caught cleanly.

It's one-sided on purpose. Charging more than the lines itemise stays allowed, because that's usually correct — tax, shipping folded into the total, or fees the mirror doesn't enumerate.

The error response carries charge_amount and mirror_line_items_sum, so you can see exactly which two figures disagreed rather than recomputing them.

The check stands down rather than guessing in three cases: when a line price can't be read in the session currency (an untrusted sum refuses nothing), when amount is absent (it's optional on hosted checkout — an absent amount is a different flow, not a zero), and when mirror.skip is true (no store order is created, so there's nothing for the money to strand against).

Don't subtract a discount yourself

If a discount applies, the fix is not to lower amount below the lines. On Shopify, itemise only what you're actually charging for. On Next Commerce send mirror.voucher and let the store price it — Von then verifies the total against the store before charging, and the shortfall check steps aside because full-price lines against a discounted total is the correct shape there.

The mirror block is strict wherever it is validated: a typo or an extra field inside it returns validation_unknown_field rather than being silently dropped.

Describing the items

State the items once, in order.lineItems, and Vora uses them for both the buyer's checkout page and the Shopify order:

"order": {
"lineItems": [
{ "name": "Trail Runner - Size 10", "quantity": 1, "unitAmount": 3499, "sku": "TR-10" },
{ "name": "Standard shipping", "quantity": 1, "unitAmount": 599 }
]
},
"mirrorTo": { "platform": "shopify", "store": "acme.myshopify.com" }

Prices are integers in minor units3499 is $34.99. The line items must add up to the charge amount, checked before the card is touched.

Shipping is a line item on Shopify

There is an order.shipping field, but sending it with platform: "shopify" returns 400 mirror_shopify_shipping_unsupported. Shopify does not carry that amount onto the order, so the store's total would be short by exactly the shipping and would permanently disagree with what the buyer paid. Send shipping as its own line, as above.

Full field reference and the buyer-inheritance rules: Describing the order.

Field reference — the raw mirror block

The raw block is the older way to describe a mirrored order. It stays fully supported, and it is what order / mirrorTo compiles into, so existing integrations need no migration. New integrations should send order / mirrorTo instead.

These five fields are required when you send the raw block.

On the raw block, the item list is separate from the buyer's

mirror.line_items describes the store order. The buyer's checkout page reads a different, optional top-level lineItems array, and neither derives from the other — so describing the order only inside mirror produces a correct Shopify order behind a checkout page showing nothing but a total. Send both, or switch to order.lineItems, which feeds both from one list.

The two also differ in shape: name / unitAmount as an integer in minor units (1499) on the buyer's list, versus title / price as a decimal string ("14.99") in the block.

FieldRequiredTypeNotes
contract_versionyesstringBlock-shape version. Use "2026-05-15".
destinationyesstringThe connected platform. Send "shopify".
shopyesstringYour store's .myshopify.com hostname (e.g. your-store.myshopify.com). Must be a store you've connected, and the only one you have connected.
line_itemsyesarray (1–100)The order lines mirrored to Shopify. Each item: see below. This is not the buyer-facing list — see the note above.
customeryesobjectThe customer recorded on the Shopify order. See below.

Optional fields Shopify honours

FieldTypeNotes
shipping_addressobjectWritten to the Shopify order's shipping address. Omit it and the order is still created and paid, but flagged as unshippable.
billing_addressobjectWritten to the order's billing address. Send it explicitly: omit it and the slot is filled only by a billing address the buyer typed on our checkout — otherwise the order has no billing address at all.
skipbooleanPer-transaction opt-out. true records the payment normally but creates no Shopify order for that one charge. Useful for an order you already entered in the store yourself, or a correction. Omit it (or send false) for normal behaviour.

Both addresses take the same neutral shape:

{ "name": "Sam Rivera", "line1": "1 Test St", "line2": "Apt 4",
"city": "Austin", "state": "TX", "postal_code": "78701",
"country": "US", "phone": "5125550123" }

line1, postal_code and a 2-letter country are required when the address is present; name, line2 and phone are optional. Two fields are conditional:

city depends on which address it is. Required on mirror.shipping_address — an order with no city can't be delivered, and omitting it is refused with the field path shipping_address.city. Optional on mirror.billing_address, which is complete without one because it exists for address verification, and that needs the street and postal code only. This widened recently: it accepts strictly more than before and rejects nothing new, so if you already send a city everywhere, nothing changes for you. Full rule and the silent wrong-address bug that prompted it: city by slot.

Shopify carries a city-less billing address fine

Unlike the Next Commerce connector, the Shopify adapter maps shipping and billing independently — an omitted billing address is never substituted with the shipping one. It is filled from a billing address the buyer typed on our checkout, if there is one, and otherwise left empty.

state is required by country, not always. Roughly 150 countries have no state or province — send state for countries that have one (US, CA, AU), and omit it entirely for countries that don't. An empty string is not the same as omitting it: "" lands on the store order as that order's province. Full rule and the reason it's shared across the form, the pay button and the store projection: state by country.

Fields Shopify accepts and does not carry

The block also accepts attribution, metadata, coupon and the per-line external_product_ref. They do not all behave the same way on Shopify — and two further fields, shipping and voucher, are refused on this destination rather than accepted:

FieldOn a Shopify mirror
attributionCarried — lands on the order's custom attributes. See below.
metadataCarried — same place. See below.
couponAccepted, then dropped — a display-only label; it changes no total.
external_product_refAccepted, then dropped — Shopify uses its own variant_id, so every mirrored line is a custom line.
shippingRefused before the chargemirror_shopify_shipping_unsupported. Fold shipping into line_items[] as a line whose price is the shipping cost, so the order total matches what you charge.
voucherRefused before the chargemirror_shopify_voucher_unsupported. See the warning below.

The two refusals fire pre-charge on both charge surfaces, so a mismatch of this kind can no longer reach a buyer: POST /v1/payment_intents returns the mirror_shopify_* codes above, and POST /v1/sessions refuses a voucher outright with mirror_voucher_disabled on every destination and rejects a Shopify shipping in validation.

Worth being precise about the dropped ones, because that failure is quiet: an unknown field is rejected with validation_unknown_field, so you find out immediately. coupon and external_product_ref are not rejected — the request succeeds and the data is simply never written to the Shopify order.

Two of the dropped fields change what the order is worth. Read Four things a Shopify mirror quietly does not do before you rely on any of them.

Where attribution and metadata land on the order

mirror.attribution and mirror.metadata are written to the Shopify order's custom attributes — the Additional details block on the order page.

Fields carried: the typed set (affiliate, subaffiliate15, funnel, utm_source, utm_medium, utm_campaign, utm_content, utm_term), your free-form attribution.passthrough map, and mirror.metadata.

Custom attributes were chosen over metafields because they're the only home that's visible with no setup and appears in the standard order CSV export — which is how an affiliate payout run actually gets done. A metafield queries better but needs a merchant-created definition and is absent from that export.

This is not Shopify's own referral tracking, and never can be

Shopify does not let an app write into its native attribution. That's reserved for visits converting through Shopify's own checkout, and a payment taken on our page never is.

So what we pass is custom data an affiliate app must be configured to read — never a signal Shopify's built-in marketing reports pick up. This is permanent and it is not a gap on our side: an integrator whose tool reads Shopify's native attribution cannot be served by any payment provider. One whose tool reads order data — which is most of them — is fine.

Two limits worth knowing, both of which bite quietly:

  • A key beginning vora_ is silently ignored. That namespace is ours, and one of the names in it (vora_payment_intent_id) is what links the order back to the payment. Not an error — just dropped. Rename yours.
  • Merchant attributes are capped at 50 per order, and the overflow is dropped, not refused. Deliberate: the Shopify order is created after the buyer is charged, so refusing would cost the order for money already taken. Losing attribution on an order that exists is the better failure.
Not available on the order / mirrorTo shape

On the newer order / mirrorTo request shape, attribution is rejected outright — not silently dropped. Both shapes reject unknown fields, so sending it returns a 400.

If you need attribution today, describe the order with the raw mirror block instead.

voucher is refused on Shopify — charge the full line-item total

mirror.voucher is a real, store-priced discount on platforms that support it. Shopify does not price it at all, so the API refuses the field rather than accepting it: POST /v1/payment_intents returns mirror_shopify_voucher_unsupported and POST /v1/sessions returns mirror_voucher_disabled, both before the card is charged.

The refusal exists to close a money mismatch. Were the field simply dropped, the charge would go through at the discounted amount you sent while the Shopify order was created at full line-item price — the buyer pays less than the order says, permanently, and refunds stop reconciling.

Charge Shopify mirrors at the full line-item total. To record the code for display without changing any total, send it as coupon instead. Store-priced vouchers are a Next Commerce capability.

line_items[]

Each entry describes one line on the Shopify order. Between 1 and 100 items.

FieldRequiredTypeNotes
titleyesstring (1–255)Product/line name.
quantityyesinteger (1–9999)Units ordered.
priceyesstring (≤50)Unit price as a decimal string in your store currency, e.g. "14.99". Not minor units.
external_product_refnostring (≤255)A catalog reference for platforms that use one. Shopify does not read it (see degradations).

You don't need to reconcile the line-item total against the charge amount. A divergence (tax, fees, discounts, partial mirrors) is logged but not rejected. The charge amount on the request is what Von Payments captures; mirror.line_items is what appears on the Shopify order.

customer

FieldRequiredTypeNotes
emailyesstring (email, ≤254)The buyer's email; recorded on the Shopify order.
first_namenostring (≤100)
last_namenostring (≤100)

Only these three fields are accepted. There is no phone or address field you can send on mirror.customer — the object is strict and a fourth key is rejected. To put an address on the mirrored order, use the block-level shipping_address / billing_address fields instead.

The resulting Shopify customer record can still end up with a phone on it: if the buyer types one on our checkout and you did not supply one, we fill customer.phone for you. We never create a customer object that was not already there — on an upsell append, which deliberately carries no customer, nothing is added.

Four things a Shopify mirror quietly does not do

None of these return an error. Each produces a paid charge and a Shopify order that is wrong in a way nothing on our side reports. Read all four before you go live.

1. Stock is never reduced

external_product_ref (and any other catalog id you send) is not carried to Shopify. Every mirrored line is a custom line: title, quantity and price only. That means no product link, no inventory decrement, and no product-level analytics on the mirrored order.

If you are counting on Shopify to draw stock down as mirrored orders arrive, it will not, and stock you believe is being reduced is not. For a merchant selling limited inventory this is the highest-impact item on the page.

2. No delivery address still creates a paid, unshippable order

Omit shipping_address and Shopify still creates the paid order. It is not refused and not held. The order simply has no delivery address, and we mark it so you can find it:

MarkerValue
Order tagvora-no-shipping-address
Order note attributevora_no_shipping_address = true

Nothing in the charge response, the order poll, or any error code reports this. Filter your Shopify orders on the vora-no-shipping-address tag to catch paid-but-unshippable orders.

"Missing" here means unusable, not merely absent: line1, city and country must all be non-empty. An empty object, or an address carrying only a country, counts as no address and gets tagged the same way.

On hosted checkout the address the buyer types does reach the mirrored order. Leave mirror.shipping_address unset — the normal case, since at session-create the buyer has not typed one yet — and the address they enter under shipping: "auto" or "required" is merged into the mirror block before the store order is built. The phone they type is merged the same way, onto both the order's shipping address and its customer record.

What you send always wins. The merge only fills genuine gaps:

Your mirror.shipping_addressWhat the buyer typed
omittedused, phone included
sent, without a phoneonly the phone is filled in
sent, with a phoneignored — your block is unchanged

The buyer's typed billing address fills an absent billing_address independently of all three rows, and their typed phone fills an absent customer.phone the same way.

The typed address is all-or-nothing, and that is where a paid, unshippable order can still come from. It is used only if it carries a street line, a postal code, a two-letter country and a city — and, in countries that have them, a state or province. Miss any one and the entire address is dropped rather than half-written onto a real order, which is how you land on the tagged order described above. Three ways to get there:

  • shipping: "auto" does not demand a complete address. The field is shown but optional, so a buyer can leave it blank or half-filled and still pay. Use shipping: "required" when the order must ship — Pay stays disabled until the address is complete, and the same rule is applied again on our server before the charge.
  • An over-long value counts as missing, not as something to shorten. A street line over 255 characters, a city over 120, a postal code over 32 or a state over 120 drops the whole address. A shortened street line is not a shorter address, it is a wrong one, so it is refused rather than trimmed.
  • Two fields drop on their own instead, and neither tags the order: a name over 200 characters, or a second address line (apartment, suite) over 255. A dropped apartment line yields an order that looks complete and ships to the street address without it.

If you already know the destination, keep setting mirror.shipping_address at create time — it is the one version none of the above can affect.

3. No billing address means the order has none

There is no fallback to the shipping address. On Shopify the two addresses are mapped independently — unlike Next Commerce, we never copy one into the other, so a missing billing address is never quietly filled with the shipping one.

What fills it instead, when you omit it:

  • A billing address the buyer typed on our checkout, under billingAddress: "auto" or "required". It fills the slot only if you left it empty — anything you send wins.
  • mirrorTo.billingAddress, if you are describing the order with the order model rather than a raw mirror block.

If neither applies — you omitted it and the buyer was never asked for one — then the order really does carry no billing address, and that silently breaks store workflows keyed on it: tax and nexus determination, fraud review, invoicing. The omission is legal, so there is no error.

A half-filled billing address is dropped whole, and nothing tags it

billingAddress is auto by default — shown to the buyer but not required. Under auto nothing checks that what they typed is complete, and the same all-or-nothing rule applies: miss the street line, postal code, country, or a state in a country that has one, and the entire billing address is discarded. The order is created and paid with no billing address at all.

There is no tag for this. The shipping equivalent is filterable — a vora-no-shipping-address tag lands on the order. Billing has no counterpart, so an order that lost a half-typed billing address is indistinguishable from one where billing was never collected. If your tax, invoicing or fraud-review workflow depends on it, either send billing_address yourself or set billingAddress: "required", which holds the buyer to a complete one.

Send billing_address explicitly for Shopify unless you are deliberately relying on the buyer to supply it. A billing address is accepted here without a city — that field exists for address verification, which needs street and postal code — so a city-less one still fills the slot rather than being discarded.

4. Shipping is not carried, so the order total understates the charge

mirror.shipping is dropped on Shopify: the mirrored order gets no shipping line at all. A charge of $59.99 of items plus $8.99 shipping produces a Shopify order whose line total reads $59.99, while the sale transaction attached to it is the full $68.98 actually captured.

Store reporting and accounting exports will show the smaller figure. If your bookkeeping reconciles Shopify order totals against settlements, expect the gap and account for it outside the mirror.

Worked example

A create-session request that charges $29.98 and mirrors a two-unit order to the connected store. Note the two item lists, in their two different shapes, and the two addresses:

curl https://checkout.vonpay.com/v1/sessions \
-H "Authorization: Bearer vp_sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"amount": 2998,
"currency": "USD",
"successUrl": "https://your-store.example.com/thanks",
"cancelUrl": "https://your-store.example.com/cart",
"lineItems": [
{ "name": "Premium Widget", "quantity": 2, "unitAmount": 1499 }
],
"mirror": {
"contract_version": "2026-05-15",
"destination": "shopify",
"shop": "your-store.myshopify.com",
"line_items": [
{ "title": "Premium Widget", "quantity": 2, "price": "14.99" }
],
"customer": {
"email": "buyer@example.com",
"first_name": "Ada",
"last_name": "Lovelace"
},
"shipping_address": {
"name": "Ada Lovelace",
"line1": "1 Market St",
"city": "San Francisco",
"state": "CA",
"postal_code": "94105",
"country": "US"
},
"billing_address": {
"name": "Ada Lovelace",
"line1": "1 Market St",
"city": "San Francisco",
"state": "CA",
"postal_code": "94105",
"country": "US"
}
}
}'

The two lists say the same thing in two shapes. Top level: name plus unitAmount: 1499 (minor units, per unit). Inside mirror: title plus price: "14.99" (decimal string, per unit). Copy one into the other and it will be rejected as an unknown field.

successUrl takes no placeholder of any kind. We append the session id, status, amount, currency, transaction id where there is one, and a signature to it ourselves when we redirect the buyer back.

The response is a normal session object (checkoutUrl, expiresAt, …). The mirror block doesn't change the session response shape. The Shopify order is created after the buyer completes payment, not at session-create time.

After the checkout completes

  • On success the order is created on the connected Shopify store and appears in your Shopify orders feed — that is where mirrored orders live. The Connected Platforms → Shopify dashboard does not list them: it surfaces mirrors that are retrying or parked, so a healthy integration shows nothing there. To check one specific charge, use the confirmation methods below, or open that payment under Transactions and read its mirror status.
  • On a transient failure (a brief Shopify-side error) the mirror retries automatically — five attempts over roughly two and a half hours — before being parked for manual review. The original payment is unaffected: a failed mirror never reverses a completed charge.
  • Re-run a parked order from the Connected Platforms → Shopify dashboard.

Refunding a mirrored order

Shopify's own Refund button moves real money

A mirrored Shopify order is created paid, with a sale transaction attached for the full amount Von Payments captured. That makes the store's native Refund button work against it, and that button is not a bookkeeping entry.

Refunding a mirrored order inside Shopify issues a real card refund through Von Payments, automatically, for the amount you refunded in the store. Money leaves your account and reaches the buyer.

So the rule is: refund in Shopify OR call POST /v1/refunds, never both for the same amount.

  • Do not build your own "store refund → Von refund" sync. One already runs. A second one double-refunds.
  • Refunds you start on our side are marked so the Shopify-side listener never fans them back out. The loop only runs in one direction.
  • The refund amount is independently capped at the charge's remaining balance, so an over-refund is refused rather than executed. Treat that as a backstop, not a licence to refund on both sides and let the cap sort it out: a partial refund on each side is under the cap and both will go through.

If you refund in Shopify, the payment on our side reflects it. You do not need to mirror the refund back manually.

Confirming the order was created

A successful charge does not guarantee the Shopify order exists — the order is created after the payment. Confirm it with the poll route for the flow you used, or subscribe to the mirror.order.created webhook.

  • Hosted checkout (POST /v1/sessions) — poll GET /v1/public/sessions/{sessionId}/mirror-order with your publishable key (vp_pk_*; a secret key is refused). sessionId is the session you created.
  • Direct charge (POST /v1/payment_intents) — poll GET /v1/payment_intents/{id}/mirror-order with your secret key, keyed on the payment-intent id.

Both return { status, order_number, order_status_url }. order_number and order_status_url are non-null only when status is created.

statusWhat it means
createdThe Shopify order exists.
pendingStill working — keep polling, with a cap. On the direct charge this also covers a charge that carried no mirror block, which that route can't tell apart from "not finished yet."
failedThe mirror ran and will not succeed. Stop polling, then read code before reconcilingmirror_order_cancelled means the order was released on purpose and no money moved, so there is nothing to reconcile. See What code tells you.
not_mirroredTerminal. Stop polling, but read the warning below before you act on it.
not_mirrored does not prove there is no order

order_number is null for every not_mirrored case, so the response alone cannot tell them apart. Check the store before you reconcile or refund. It covers all of:

  • on hosted checkout, the session carried no mirror block at all (the direct charge reports pending for that case, not not_mirrored), or the block set skip: true;
  • the mirror was recorded but never dispatched: the store is disconnected, the authorization was revoked, mirroring is turned off for the account, or more than one Shopify store is connected. No order exists; reconcile the charge;
  • an order was created in the store and was later cancelled there. Do not issue a second refund on the strength of this status.

Two more timing notes:

  • The mirror fires on capture, not on authorization. A charge that is only authorized creates no store order; the poll reports pending and stays there until you capture. A mirrored order therefore always represents money already captured.
  • One store order per payment, and nothing more. Retrying the same cart with a fresh Idempotency-Key, or as a new payment, yields a second charge and a second Shopify order. Reuse the key on retries.

Size limit

The serialized mirror block is capped at 4096 bytes on the hosted flow. Over the cap returns mirror_too_large. Keep line-item titles short and put long free-text elsewhere; the block only needs what Shopify records on the order. (A raw Shopify block on the direct-charge path doesn't enforce this up front — but stay inside the cap anyway, since an oversized block still fails after the charge.)

Sandbox — test emails are enforced, not suggested

Whenever the charge is claimed to be test dataeither a test-mode key, or a sandbox accountmirror.customer.email must use a reserved test domain. A realistic-looking address is refused before the charge with mirror_sandbox_email_not_reserved — on both charge surfaces. This is a rule, not hygiene advice.

Note the "either": a live account testing with a test-mode key is covered too. If your account isn't a sandbox account, this still applies to every test-key charge you send.

Accepted: any address at example.com, example.org, example.net, or @localhost; and anything ending in .test, .invalid, or .localhost.

"customer": { "email": "buyer@example.com" }
Switching to a live-mode key does not lift this

Only a live account charging on a live provider is exempt — that's where real buyer emails are expected. A sandbox or playground account is caught in both key modes, because the gate keys off the account, not just the key.

So if you're on a sandbox account and hit this, swapping to a live key fails identically. Use a reserved address instead of hunting for a different cause.

The legacy metadata.mirror string

A stringified metadata.mirror never mirrors anything

On POST /v1/sessions, metadata values are strings — so a mirror block put there can only be a stringified blob, and a stringified block is never parsed. The session is created with no mirror attached: the payment succeeds and no store order is ever created.

This is a silent no-mirror, not a degraded legacy path. Requests using it do receive standards-compliant Deprecation and Sunset headers (window opens 2026-08-27) on an otherwise-successful response — read those as "this request did not mirror," not as "still supported for now."

Send the structured top-level mirror field instead.

If a request carries both the top-level mirror and a legacy metadata.mirror string, it's rejected with mirror_dual_specification — keep the top-level mirror.

This does not affect the direct-charge path

On direct charges, metadata.mirror is a real nested object, not a string. That position is fully supported, carries no deprecation header and no sunset date, and is not going away. Only the stringified form on the hosted flow is dead.

Errors

400 unless noted. Remember that on the direct-charge path most problems with a Shopify mirror produce no error at all: the charge succeeds and the order silently never appears, so these are not the only failure mode to plan for.

CodeWhenFix
mirror_shop_not_authorizedmirror.shop isn't a connected, active store for your accountConnect the shop under Connected Platforms → Shopify, then retry with the exact hostname. Keep exactly one Shopify store connected.
422 mirror_shop_missing_scopesThe connected store's known grant is missing write_orders or write_customers, so it cannot create the order. Refused before the charge on hosted checkout; the missing permissions are listed in missing_scopes. Not checked up front for a raw block on POST /v1/payment_intents, where it instead becomes a charge with no store orderRe-connect the store and grant the required permissions. This is a store-side re-grant, not a request-field change.
mirror_dual_specificationOn POST /v1/sessions: the request sends both top-level mirror and a legacy metadata.mirror stringRemove metadata.mirror; keep the top-level mirror. See the legacy string path.
mirror_alias_conflictOn POST /v1/payment_intents: the request sends a mirror block in both mirror and metadata.mirror, and the two differ. Identical blocks are acceptedSend one block. Both positions stay supported, with no sunset and no migration; they just must not disagree.
mirror_too_largeThe serialized mirror block exceeds 4096 bytesTrim line-item titles / move long free-text out of the block.
mirror_amount_below_line_itemsThe mirror's line_items + shipping add up to more than the amount being charged. Refused before the charge on both surfaces — nothing moved. One-sided: charging more than the lines itemise is allowedItemise only what you're charging for, or raise amount to cover the lines and shipping you're mirroring. The response carries charge_amount and mirror_line_items_sum. See Charging less than the order is worth.
mirror_malformedOn POST /v1/payment_intents, two causes: (1) the mirror isn't a JSON object (typically stringified, or coerced to "[object Object]"); (2) the block contains the reserved key _gdpr_redacted at any depth(1) Send it as a nested object, not a string. (2) Rename that key at the path named in the message. It is written only by our privacy erasure process and may never be supplied by a caller.
mirror_upsell_unsupportedA Shopify mirror carried parent_order_ref. Shopify orders can only be created, never appended to. Refused before the chargeRemove parent_order_ref (and upsell_key) and mirror the item as its own new order. Sent on the hosted flow the same combination is refused with validation_error and the same message, so don't branch on this code alone.
mirror_voucher_disabledA voucher was sent on POST /v1/sessions. Store-priced discounts are not supported on hosted checkout, on any destinationDrop the voucher and charge the full total. Shopify does not price vouchers at all (see the voucher warning).
mirror_shopify_voucher_unsupportedA voucher was sent on POST /v1/payment_intents with destination: shopify. Shopify does not price the code, so the order would be created at full price while the buyer is charged the discounted amount. Refused before the chargeRecord the code with coupon as a display-only label, or charge the full line-item amount. Store-priced vouchers are Next Commerce only.
mirror_shopify_shipping_unsupportedA shipping amount was sent on POST /v1/payment_intents with destination: shopify. The amount is not carried to the Shopify order, so its line total would be short by that amount versus the charge. Refused before the chargeFold shipping into line_items[] as a line whose price is the shipping cost, so the order total matches what you charge.
mirror_sandbox_email_not_reservedA test-mode key or a sandbox account sent a mirror.customer.email outside the reserved test domainsUse an @example.com (or .test / .invalid / .localhost) address — see Sandbox.
validation_unknown_fieldThe block contains a field that isn't in this referenceRemove the unrecognized field. The error response lists every unknown key it found and suggests the canonical spelling where it can.

What this block is not

To keep integrations accurate, note what the deployed contract does not accept today. Sending any of these returns validation_unknown_field wherever the block is validated, because the block is strict:

  • No send_receipt, financial_status, or tax fields on the block. A mirrored order is always created paid, because the mirror only fires once money is captured; you cannot ask for a different status.
  • No variant_id or sku on a line item, and no phone on the customer. (Addresses are supported — see shipping_address / billing_address above. external_product_ref is accepted, but Shopify does not read it.)

parent_order_ref and upsell_key are a separate case. They are real fields on the block, so they are not rejected as unknown - they are rejected because Shopify mirrors create orders and cannot append a line to an existing one. The code differs by surface: a direct charge returns mirror_upsell_unsupported, while hosted checkout returns validation_error. Branch accordingly if you handle these.

If you need one of these, check back — the block is versioned (contract_version) precisely so it can grow without breaking existing integrations.