Skip to main content

Connected Platforms — mirror Vora orders into your CRM

Take payment through Vora and keep your existing commerce platform as your system‑of‑record. When Vora captures a payment, Vora creates the matching already‑paid order in your connected platform — so orders, fulfillment, and customer records stay exactly where your team already works.

How it works

  1. Connect your store once, from the Vora dashboard → Vora → Integrations → Connected Platforms.
  2. Describe the order once, with an order object (what was bought) and a mirrorTo object (which store records it). Vora uses that one description for both the checkout page the buyer sees and the order it creates in your store. See Describing the order.
  3. When the payment is captured, Vora creates the already‑paid order in your platform. Vora charged the card; the platform records the order without touching a card.

Describing the order

A payment has two audiences: the page the buyer looks at, and the store that ends up holding the order. State the purchase once and Vora serves both from it.

{
"amount": 4498,
"currency": "USD",
"order": {
"lineItems": [
{ "name": "Trail Runner - Size 10", "quantity": 1, "unitAmount": 3499, "sku": "TR-10" },
{ "name": "Merino Socks", "quantity": 2, "unitAmount": 200, "sku": "MS-01" },
{ "name": "Standard shipping", "quantity": 1, "unitAmount": 599 }
]
},
"mirrorTo": { "platform": "shopify", "store": "acme.myshopify.com" },
"buyer": {
"email": "casey@example.com",
"name": "Casey Rivera",
"address": {
"addressLine1": "18 Bridge Street", "city": "Portland", "state": "OR",
"postalCode": "97205", "country": "US"
}
}
}
The buyer address uses addressLine1, the store addresses use line1

On POST /v1/sessions the buyer profile takes addressLine1 / addressLine2. The store-order addresses (mirrorTo.shippingAddress, mirrorTo.billingAddress) take line1 / line2. Both objects reject unknown fields, so mixing them up is a 400 naming the field rather than a silent drop.

The buyer sees those two lines and the shipping row on the checkout page; the store order carries the same lines, the same shipping, and Casey as the customer.

This works identically on hosted checkout (POST /v1/sessions) and on a direct charge (POST /v1/payment_intents) — same fields, same rules, same errors. There is no per-surface variation to code around.

order and mirrorTo travel together. Sending one without the other is a 400.

Every amount is an integer in minor unitsamount, unitAmount, shipping.amount. 3499 is $34.99. There are no decimal strings anywhere in this shape, so there is no formatting step and nothing to round.

The store order is created after the payment succeeds, out of band. A problem at the store is a missing order you can see and fix — never a failed checkout for the buyer.

On a direct charge to Shopify, this also gets you pre-charge validation

A raw mirror block sent to POST /v1/payment_intents with destination: "shopify" skips several checks, so a bad block becomes a successful charge with no store order. Through order / mirrorTo those checks run before the charge instead — an oversized block, an unconnected store or a missing permission comes back as a 400 / 422 with no money moved.

The money rule

What you describe must add up to what you charge, in minor units. If the two figures disagree the request is refused with 400 order_total_mismatch before any charge, and the response carries both derived_amount and charge_amount so you can see which side is wrong.

Shopify — shipping is a line item:

Σ (lineItems[].unitAmount × quantity)  =  amount

Above: 3499 + (200 × 2) + 599 = 4498, with shipping sent as its own line.

Next Commerce — shipping is its own field and counts toward the total:

Σ (lineItems[].unitAmount × quantity)  +  shipping.amount  =  amount
order.shipping is refused on Shopify

Sending order.shipping with platform: "shopify" returns 400 mirror_shopify_shipping_unsupported before any charge. Shopify does not carry that amount onto the order, so the store's total would be short by exactly the shipping and would silently disagree with what the buyer paid. Refusing up front beats a paid order whose recorded total is wrong.

Send shipping as a line item instead — a line whose price is the shipping cost, as in the example above. The store order then totals what you charged.

order.coupon records a discount code for reference. Its amount is display-only and excluded from that total — if a discount should change what the buyer pays, subtract it from amount and the line prices yourself.

A store-priced voucher is Next Commerce only for the same reason: Shopify does not price the code, so the order would be created at full price while the buyer is charged less. On Shopify it returns 400 mirror_shopify_voucher_unsupported.

The buyer is stated once too

You already send the buyer at the top of the request, so mirrorTo inherits from it — there is no second place to type the same person's details. The one buyer address fills both the shipping and billing address on the order.

A single buyer.name is split for the store record: the first word becomes the given name and everything after it the family name, so "Ada King Lovelace" gives "Ada" and "King Lovelace". A one-word name yields a given name only — no surname is invented. Send mirrorTo.customer.firstName / lastName when you want exact control.

Set mirrorTo.customer, mirrorTo.shippingAddress or mirrorTo.billingAddress explicitly and your value wins — for a gift order, or a business buying on someone's behalf.

Two behaviors that are deliberate, because each looks like a bug otherwise:

  • Always send city and state on the buyer address. They are optional on the buyer profile but required on a store-order address, and inheritance is all-or-nothing: a buyer address missing either one is not inherited at all rather than partly copied. The result is a paid order with nowhere to ship it and no error explaining why. If you hold a complete address the buyer record does not, send mirrorTo.shippingAddress explicitly.
  • No buyer email means no store customer. The store customer record is keyed on email, so there is nothing to inherit without one. Send mirrorTo.customer yourself.

On a direct charge, the top-level billing_address is not used as a fallback for the store order's address, even though it is usually the same place. It is collected for one stated purpose — the address-verification check with the payment provider — and is never returned in API responses, webhooks or logs. Only the buyer profile is inherited, which keeps the behavior identical on both surfaces. Send mirrorTo.shippingAddress if the buyer record has no address.

Fields

order:

FieldRequiredTypeNotes
lineItemsyesarray (1–100)What was bought. See below.
shippingnoobjectNext Commerce only. amount in minor units (counts toward the total), optional label up to 120 chars. Refused on Shopify — send shipping as a line item, see the money rule.
couponnoobjectcode (1–64 chars) and an optional display-only amount.
metadatanoobjectYour own keys (≤64 chars) and values (≤500).

order.lineItems[]:

FieldRequiredTypeNotes
nameyesstring (1–200)What the buyer sees, and the line description on the store order.
quantityyesinteger (1–9999)
unitAmountyesintegerPrice per unit in minor units1499 is $14.99.
variantIdnostring or integerCatalog id of the exact buyable variant. Required per line on Next Commerce.
skunostring (1–255)

mirrorTo:

FieldRequiredTypeNotes
platformyes"shopify" or "nextcommerce"
storeyesstring (1–255)The store host exactly — acme.myshopify.com or acme.29next.store.
customernoobjectemail (required within), firstName, lastName. Inherited from the buyer when omitted.
shippingAddressnoobjectInherited from the buyer when omitted.
billingAddressnoobjectInherited from the buyer when omitted.
parentOrderRefnostring (1–64)Append to an existing store order instead of creating one. Next Commerce only — see below.
upsellKeynostring (1–64)Your key for one upsell decision. Valid only with parentOrderRef.

parentOrderRef is Next Commerce only. A Shopify order can be created but never appended to, so platform: "shopify" together with parentOrderRef is refused before the charge with a 400 validation_error whose message begins Invalid order:. Note the code — the raw mirror block returns mirror_upsell_unsupported for the same situation, so a handler keyed on that code alone will not match here. Send the extra item as its own new order.

An address takes line1, city, state, postalCode and a two-letter country, plus optional name, line2 and phone. Both objects reject unknown fields, so a typo is a 400 at the door rather than a silently dropped value.

The 200-character cap on a line name is the tightest limit of every destination the string reaches, so one description stays valid everywhere it lands.

Three things the order model cannot express yet

A store-priced voucher, the per-charge skip opt-out and attribution are not reachable through order / mirrorTo. Both shapes reject unknown fields, so sending one of them inside order or mirrorTo returns a 400.

If you need any of the three, send a raw mirror block — it stays fully supported.

Do not drop a voucher to get past that error

A voucher is a real, store-priced discount. Removing it so the request validates charges the buyer the discounted amount while the store records a full-price order, and the two never reconcile. Switch to a raw mirror block instead.

The raw mirror block

order / mirrorTo is what a new integration should send. The older mirror block is still accepted and is what the pair compiles into internally, so existing integrations keep working unchanged — and it remains the way to reach the three fields above.

Send one or the other. A request carrying both is refused with 400 order_mirror_conflict, because they describe the same store order two ways and picking one silently could attach the wrong order to the charge.

Both create calls take the raw block at the top level, as a JSON object (never a string):

  • Hosted checkout (POST /v1/sessions): top-level mirror. On this call metadata.mirror is a legacy string that is never parsed, so sending both returns 400 mirror_dual_specification.
  • Direct charge (POST /v1/payment_intents): top-level mirror, or the identical object nested at metadata.mirror. The top-level field is folded into metadata.mirror before the block is validated or stored, so both forms produce the same charge and the same stored record; metadata.mirror stays supported, with nothing to migrate. Send the block in one place or, if the two are identical, in both. Two blocks that differ are rejected with 400 mirror_alias_conflict.

The raw block is one shared contract across every destination — the same fields (contract_version, destination, shop, line_items, customer, plus optional skip, attribution, shipping_address, billing_address, metadata). You pick the platform with destination; each platform's page below covers only what's specific to it.

Skipping the store order for one charge

Set mirror.skip to true to record the payment normally and create no store order for that one charge. Useful for an order you already entered in the store by hand, a test charge against a live store, or a correction.

It behaves identically on POST /v1/sessions and POST /v1/payment_intents. It is a field on the raw mirror block — the order model has no equivalent.

skip cannot be combined with a voucher. A store-priced discount is computed by the store, so skipping the order would charge a store-derived total with nothing recording it — that combination returns a 400.

Supported platforms

PlatformStatusGuide
ShopifyAvailableShopify order mirroring
Next Commerce (29next)AvailableNext Commerce order mirroring

Shared concepts

  • The mirror block — strict in every case: unknown keys inside the block are rejected. Omit it to skip mirroring a charge. Where it goes on each call is covered in How it works. A 4096-byte cap applies on the hosted flow and on Next Commerce direct charges; each platform's guide states it in context.
  • One item list: order.lineItems feeds both the buyer's checkout page and the store order, so the two can't disagree. See Describing the order.
  • Attribution — pass marketing / direct‑response data (affiliate, utm_*, funnel, and a passthrough map) and it lands on the created order. Where differs by platform: Next Commerce maps it to the order's own native attribution fields, while Shopify carries it as order custom attributes an affiliate app reads. Raw mirror block only — it is not reachable through order / mirrorTo.
  • Your own metadata — attach correlation data with mirror.metadata, and reference the charge from the order via the vora_payment_intent_id stamped on it.
  • Get the result back — the order is created asynchronously, so its id is never in the charge response. See Confirming the order.

When the order is created

The store order is created when the payment is captured, not when it is authorized. On an ordinary automatic capture those are the same moment.

On a delayed or manual capture (capture_method: "manual") they are not: while the charge is only authorized, no store order exists, and the poll route below reports pending for as long as you keep asking, because capture is what triggers the order. Capture the charge (POST /v1/payment_intents/{id}/capture), then poll. Do not poll forever expecting pending to advance on its own.

A mirrored order therefore always represents money already captured.

Confirming the order

A successful charge is not proof the store order exists. Poll the route for the flow you used, or subscribe to the mirror.order.created webhook (it carries payment_intent_id, order_number and order_status_url).

  • 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 (vp_sk_*), 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
pendingNot created yet. Keep polling, with a cap. On the direct charge this also covers a charge that carried no mirror block at all, which that route cannot tell apart from "not finished yet".
createdThe store order exists.
failedTerminal — stop polling. On the direct charge a code and a plain-language message say why. Read code before you act: most values mean the order was rejected and needs your attention, but one means it was released on purpose and needs nothing. See What code tells you.
not_mirroredTerminal, but read the warning below before you act on it.

What code tells you

On the direct charge, a failed response carries a stable code and a merchant-safe message. Branch on code, not on the message text.

codeWhat happenedWhat to do
mirror_line_invalidA line on the order was rejected — typically a product reference the store could not resolve.Fix the line and resend.
mirror_connection_errorThe store connection needs re-authorizing.Reconnect the store, then retry.
mirror_shipping_country_unsupportedThe store does not deliver to this order's country using the shipping method that was sent. The connection is healthy.Add that country to the shipping method in the store's settings, or send a shipping method that already covers it.
mirror_rejectedThe store rejected the order for a reason we could not classify.Compare the order's items, shipping method and delivery address against the store's settings. Reconnecting does not help here — a broken connection has its own code.
mirror_order_cancelledNothing is wrong. An order was created before the payment, the payment did not complete, and the order was released.Nothing. No order stands and no money moved.

mirror_order_cancelled is the only one of these that is not a fault, and it only appears on accounts using order-before-charge — see the note below.

not_mirrored is not proof that no order exists

Stop polling, but do not treat this as "no order was created". order_number is null in every not_mirrored case, so the response on its own cannot tell these apart:

  • the session carried no mirror block at all (hosted route only; the direct-charge route reports pending for that case);
  • the mirror was recorded but never dispatched, because the store was disconnected, its authorization was revoked, or mirroring is turned off for the account. No order exists and the charge needs reconciling; or
  • an order was created in the store and was later cancelled there. Do not issue a second refund on the strength of this status.

Check the connected store before you reconcile or refund.

On order-before-charge accounts, created does not mean the payment succeeded

Most accounts create the store order after the charge settles, so created implies money moved. Some accounts create the order first, before the card is charged. On those, the direct-charge poll can report created for a payment that then fails.

That is not a contradiction and not an order you owe. It means exactly what it says: an order stands in your store right now and you can open it. The split between created and failed here answers "does an order exist?" — not "has the payment completed?"

If the payment never completes, the order is released automatically and the same endpoint switches to failed with code: mirror_order_cancelled. At that point no order stands and no money moved, so there is nothing to reconcile, refund, or resend.

What this means for your code: on an order-before-charge account, do not treat created from this endpoint as a payment confirmation. Confirm payment from the payment intent's own status, or from the charge.succeeded webhook. Use this endpoint for what it answers — whether an order exists in the store.

A payment that has been captured always reports created with its order_number intact, so a settled order never hides behind a failed.

Retrying safely

Duplicate protection is per payment, not per cart. Vora guarantees at most one store order per payment, and nothing more.

  • Retry with the same Idempotency-Key and you get one charge and one store order.
  • Retry the same cart with a fresh Idempotency-Key, or as a new payment, and you get a second charge and a second store order.

Always reuse the key when you retry.

Related: if an order lands in the store carrying fewer lines than you sent, it is deliberately never re-driven, because re-driving it could create a duplicate order. The poll route still reports created for that paid-but-incomplete order, so compare the mirrored order's lines against what you sent if that matters to you.

Get started

  1. Connect your store in the dashboard (each platform's guide has the exact steps).
  2. Read your platform's guide for the destination value and any platform‑specific fields:
  3. Send the mirror block on your create call and confirm the order appears in your store.