Skip to main content

Shopify order mirroring

A successful payment can create the matching paid order on a connected Shopify store. You opt in per charge by describing the order on the request — with order / mirrorTo, or with the raw mirror block this page documents in full.

Before you start​

The store you name must already be connected to your Von Payments account (dashboard → Connected Platforms → Shopify). You can connect more than one store; mirror.shop selects which one each charge mirrors to. A mirror.shop that is not a connected, active store is refused with mirror_shop_not_authorized.

Required Shopify permissions​

The one-click install asks Shopify for:

write_orders, write_customers, read_customers, read_orders, write_draft_orders

write_orders (create the order) and write_customers (attach the buyer) are load-bearing; without either, no mirror can succeed. A store connected by pasting an Admin API token can be left with no recorded permissions, and the two cases behave differently:

  • The known grant is missing a load-bearing permission. Hosted checkout refuses the charge before any money moves with mirror_shop_missing_scopes (422). A raw mirror block on POST /v1/payment_intents is not checked up front: the charge succeeds and the order fails afterwards.
  • No grant is recorded. Nothing is refused; the charge succeeds, and the order-create discovers the problem, so the order poll reports failed.

If orders are not appearing and the poll reports failed, reconnect through the one-click install before debugging anything else.

Where the block goes​

mirror is an optional top-level field on both charge surfaces — a JSON object, never a string. Attach it and the order is mirrored when the payment completes; omit it and nothing is mirrored.

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

On POST /v1/payment_intents the identical object is also accepted at metadata.mirror; send it in one place or both, and two blocks that differ are refused with mirror_alias_conflict. On POST /v1/sessions, metadata values are strings and a stringified block there is never parsed — the payment succeeds and no order is created; sending both is refused with mirror_dual_specification.

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

On hosted checkout the block is checked before the buyer is charged: a bad block is a 400 and no money moves. On a direct-charge Shopify mirror, these checks run before the charge:

  1. The mirror value is not a JSON object → mirror_malformed.
  2. The block contains the reserved key _gdpr_redacted, at any depth → mirror_malformed. Rename yours if you carry your own privacy state in the block.
  3. The block carries parent_order_ref → mirror_upsell_unsupported. A Shopify order is created, never appended to.
  4. The block carries settle_only → mirror_settle_only_disabled.
  5. The block carries voucher or shipping → mirror_shopify_voucher_unsupported / mirror_shopify_shipping_unsupported.
  6. The block's line_items add up to more than amount → mirror_amount_below_line_items.

Everything else is checked after the charge: an unconnected store, missing permissions, an oversized block or a well-formed block that is 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 mirror.order.created — see Confirming the order. Describing the order with order / mirrorTo runs those checks before the charge on Shopify too.

Charging less than the order is worth​

amount and the mirror's line items may differ: amount is what the card is charged, the mirror describes the store order, and tax, fees, Von-only discounts and partial mirrors legitimately diverge. One direction is refused: if line_items add up to more than amount, the request returns 400 mirror_amount_below_line_items and nothing is charged — otherwise Shopify would create the order at the higher figure, marked paid and fulfillable, against the smaller amount actually captured. Charging more than the lines itemise stays allowed. The error carries charge_amount and mirror_line_items_sum.

The check stands down in three cases: a line price that cannot be read in the session currency, an absent amount (optional on hosted checkout), and mirror.skip: true.

Do not lower amount below the lines to apply a discount: on Shopify, itemise only what you are charging for; on Next Commerce, send mirror.voucher and let the store price it.

The mirror block is strict wherever it is validated: a typo or an extra field returns validation_unknown_field.

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 units and must add up to amount, checked before the card is touched. order.shipping is refused on Shopify before the charge (400 validation_error, message beginning Invalid order:) — send shipping as its own line, as above. Field reference and buyer-inheritance rules: Describing the order.

Field reference — the raw mirror block​

The raw block is the other way to describe the order, and the only way to reach attribution and skip on Shopify. Five fields are required:

FieldRequiredTypeNotes
contract_versionyesstring (1–10)Block-shape version. Use "2026-05-15".
destinationyesstring: shopify | nextcommerceThe connected platform. Send "shopify".
shopyesstring (1–255)Your store's .myshopify.com hostname (e.g. your-store.myshopify.com). Must be a store you've connected; with several connected, it selects which one.
line_itemsyesarray 1–100The 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.
The raw block's item list is separate from the buyer's

mirror.line_items describes the store order; the buyer's checkout page reads the optional top-level lineItems, and neither derives from the other — describing the order only inside mirror yields a correct Shopify order behind a checkout page showing nothing but a total. Send both, or use order.lineItems, which feeds both. The shapes differ: name / unitAmount (minor units) on the buyer's list; title / price (decimal string, "14.99") in the block.

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 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 is required on mirror.shipping_address (an order with no city cannot be delivered; the refusal names shipping_address.city) and optional on mirror.billing_address, which needs only street and postal code for address verification. The Shopify adapter maps shipping and billing independently: an omitted billing address is never substituted with the shipping one.
  • state is required by country, not always. Send it for countries that have states or provinces (US, CA, AU) and omit it entirely for countries that do not — an empty string lands on the store order as the province. Rule: state by country.

Fields the block has that Shopify does not support​

Sending one is refused before the charge:

FieldOn a Shopify mirror
parent_order_refUpsell append — a Shopify order can only be created, never appended to. Refused with mirror_upsell_unsupported.
upsell_keyOnly meaningful for an upsell append, so it has no effect here either.
settle_onlyNext Commerce only — it records a payment against an order you built yourself, which Shopify has no equivalent for. Refused with mirror_settle_only_disabled.

Fields Shopify accepts and does not carry​

FieldTypeOn a Shopify mirror
attributionobjectCarried — lands on the order's custom attributes. See below.
metadataobject of stringCarried — same place. See below.
couponobjectAccepted, then dropped — a display-only label; it changes no total.
shippingobjectRefused before the charge — a raw block on POST /v1/payment_intents returns mirror_shopify_shipping_unsupported; POST /v1/sessions and order / mirrorTo return validation_error. Fold shipping into line_items[] as a line whose price is the shipping cost, so the order total matches what you charge.
voucherobjectRefused before the charge — a raw block on POST /v1/payment_intents returns mirror_shopify_voucher_unsupported; POST /v1/sessions and order / mirrorTo return validation_error. See below.
external_product_refstring (≤255)Accepted, then dropped — Shopify uses its own variant_id, so every mirrored line is a custom line.

coupon and external_product_ref are accepted and never written to the order — the request succeeds; dropping external_product_ref is why stock is never reduced. An unknown field is rejected with validation_unknown_field. The two mirror_shopify_* codes are what the raw block on POST /v1/payment_intents returns; on POST /v1/sessions, and for order / mirrorTo on either route, the same refusal comes back as 400 validation_error before the charge.

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, visible with no setup and included in the standard order CSV export. Fields carried: affiliate, subaffiliate1–5, funnel, utm_source, utm_medium, utm_campaign, utm_content, utm_term, your attribution.passthrough map, and mirror.metadata. This is custom data an affiliate app must be configured to read; Shopify does not let an app write its native attribution, so its built-in marketing reports never see it.

  • A key beginning vora_ is silently ignored — that namespace is ours (vora_payment_intent_id links the order to the payment). Rename yours.
  • Merchant attributes are capped at 50 per order; the overflow is dropped, not refused, because the order is created after the buyer is charged.

Attribution is not available on the order / mirrorTo shape (it is rejected as unknown); send a raw mirror block for it.

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

Shopify does not price store vouchers, so the field is refused before the card is charged: a raw block on POST /v1/payment_intents returns mirror_shopify_voucher_unsupported; POST /v1/sessions returns 400 validation_error. Dropping it instead would charge the discounted amount against a full-price order. Charge Shopify mirrors at the full line-item total; to record a code for display, send it as coupon. Store-priced vouchers are a Next Commerce capability.

line_items[]​

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

The charge amount is what Von Payments captures; mirror.line_items is what appears on the Shopify order. A divergence (tax, fees, discounts, partial mirrors) is not rejected, except the one direction above.

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; a fourth key is rejected. Addresses go on the block-level shipping_address / billing_address. If the buyer types a phone on our checkout and you did not supply one, customer.phone is filled for you; a customer object is never created where none was sent.

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

1. Stock is never reduced​

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

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

Omit shipping_address and Shopify still creates the paid order, with no delivery address, marked so you can find it:

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

Filter your Shopify orders on the vora-no-shipping-address tag. "Missing" means unusable: line1, city and country must all be non-empty; an address carrying only a country counts as none.

On hosted checkout the address the buyer types under shipping: "auto" or "required" is merged into the block before the store order is built, phone included. What you send always wins; the merge fills gaps only:

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 the same way, and their typed phone an absent customer.phone.

The typed address is used only if it carries a street line, a postal code, a two-letter country, a city and — in countries that have them — a state; miss any one and the entire address is dropped, which is how a tagged order still happens. Three ways to get there:

  • shipping: "auto" does not demand a complete address. Use shipping: "required" when the order must ship — Pay stays disabled until the address is complete, and the same rule runs again on our server before the charge.
  • An over-long value counts as missing: a street line over 255 characters, a city over 120, a postal code over 32 or a state over 120 drops the whole address rather than trimming it.
  • A name over 200 characters or a second address line over 255 drops on its own, and nothing tags it — the order ships to the street address without the apartment line.

If you already know the destination, set mirror.shipping_address at create time.

3. No billing address means the order has none​

There is no fallback to the shipping address. When you omit billing_address it is filled by a billing address the buyer typed on our checkout (billingAddress: "auto" or "required") or by mirrorTo.billingAddress; if neither applies the order carries no billing address, which silently breaks store workflows keyed on it — tax and nexus determination, fraud review, invoicing.

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

billingAddress is auto by default and nothing checks that what the buyer typed is complete: miss the street line, postal code, country, or a state in a country that has one, and the entire billing address is discarded. There is no tag for this, unlike shipping. If your tax, invoicing or fraud-review workflow depends on it, send billing_address yourself or set billingAddress: "required". A billing address is accepted without a city.

Worked example​

A create-session request that charges $29.98 and mirrors a two-unit order — note the two item lists in their two 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 response is a normal session object (checkoutUrl, expiresAt, …); the Shopify order is created after the buyer completes payment.

After the checkout completes​

  • On success the order appears in your Shopify orders feed. The Connected Platforms → Shopify dashboard lists only mirrors that are retrying or parked, so a healthy integration shows nothing there; to check one charge, use the confirmation methods below or open the payment under Transactions.
  • On a transient failure the mirror retries automatically — five attempts over roughly two and a half hours — then is parked for manual review. 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 order is created paid, with a sale transaction attached for the full amount captured. On an order with no refund through our API yet, refunding it inside Shopify issues a real card refund through Von Payments, automatically, for the amount you refunded in the store.

Pick one side per order. A full refund through POST /v1/refunds is pushed onto the Shopify order for you; a partial one is not (the store order is left as it was). Either way the order is marked as refunded from our side, and refunds entered in the store after that never reach the card — the store shows them refunded and the buyer receives nothing. So once any amount has gone back through the API, refund the remainder the same way. Refunding in the store first, in full or in part, reaches the card each time; the amount is capped at the charge's remaining balance.

Confirming the order was created​

A successful charge does not guarantee the Shopify order exists. Poll the route for the flow you used, or subscribe to the mirror.order.created webhook:

  • Hosted checkout — GET /v1/public/sessions/{sessionId}/mirror-order with your publishable key (vp_pk_*; a secret key is refused).
  • Direct charge — GET /v1/payment_intents/{id}/mirror-order with your secret key.

Both return { status, order_number, order_status_url }; order_number and order_status_url are non-null only when status is created. order_number is the number the shopper and your store staff see, safe to print on a thank-you page: on Shopify the order name (for example #1001), elsewhere the platform's order number. A Shopify order created before 28 September 2026, or one recovered by an automatic retry, can carry the store's internal numeric id instead. order_status_url is a private link to that shopper's order: show it only to them, and do not log, store or share it.

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 reconciling — mirror_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 these apart:

  • on hosted checkout, the session carried no mirror block (the direct charge reports pending for that case), or the block set skip: true;
  • the mirror was recorded but never dispatched — the store is disconnected, its authorization revoked, or mirroring turned off for the account. No order exists; reconcile the charge;
  • an order was created in the store and later cancelled there.
Check the store before you reconcile or refund

A second refund issued on the strength of not_mirrored is money out twice.

The mirror fires on capture, not authorization: an authorized-only charge creates no store order and the poll reports pending until you capture. Duplicate protection is per payment: 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. The direct-charge path does not enforce this up front, but an oversized block still fails after the charge — stay inside it.

Sandbox — test emails are enforced​

When you test, mirror.customer.email must use a reserved test domain; a realistic address is refused before the charge with mirror_sandbox_email_not_reserved. Accepted: any address at example.com, example.org, example.net or @localhost, and anything ending in .test, .invalid or .localhost. The check runs on POST /v1/sessions, for a test-mode key or any request from a sandbox account. The same rule applies on every store platform.

The legacy metadata.mirror string​

Older integrations sent the block as a JSON string under metadata.mirror on POST /v1/sessions. That string is accepted and never parsed — the payment succeeds and no Shopify order is created — and the 201 carries Deprecation: true, Sunset: Wed, 27 Aug 2026 00:00:00 GMT and a Link header pointing here. A string over 500 characters — a block carrying both addresses, like the worked example below — is refused by metadata validation before it reaches that path. Move the block to top-level mirror, as an object; sending both is refused with mirror_dual_specification.

Errors​

400 unless noted. On the direct-charge path most problems with a Shopify mirror produce no error at all — the charge succeeds and the order never appears.

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.
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_disabledPOST /v1/sessions with a Next Commerce voucher — hosted checkout never re-prices a discounted total. A Shopify voucher on that route fails schema validation first and returns validation_errorCharge the full line-item total; record the code as coupon.
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_reservedWhile testing, mirror.customer.email was 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.