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
- Connect your store once, from the Vora dashboard → Vora → Integrations → Connected Platforms.
- Describe the order once, with an
orderobject (what was bought) and amirrorToobject (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. - 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"
}
}
}
addressLine1, the store addresses use line1On 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 units — amount, 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.
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 ShopifySending 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
cityandstateon 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, sendmirrorTo.shippingAddressexplicitly. - 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.customeryourself.
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:
| Field | Required | Type | Notes |
|---|---|---|---|
lineItems | yes | array (1–100) | What was bought. See below. |
shipping | no | object | Next 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. |
coupon | no | object | code (1–64 chars) and an optional display-only amount. |
metadata | no | object | Your own keys (≤64 chars) and values (≤500). |
order.lineItems[]:
| Field | Required | Type | Notes |
|---|---|---|---|
name | yes | string (1–200) | What the buyer sees, and the line description on the store order. |
quantity | yes | integer (1–9999) | |
unitAmount | yes | integer | Price per unit in minor units — 1499 is $14.99. |
variantId | no | string or integer | Catalog id of the exact buyable variant. Required per line on Next Commerce. |
sku | no | string (1–255) |
mirrorTo:
| Field | Required | Type | Notes |
|---|---|---|---|
platform | yes | "shopify" or "nextcommerce" | |
store | yes | string (1–255) | The store host exactly — acme.myshopify.com or acme.29next.store. |
customer | no | object | email (required within), firstName, lastName. Inherited from the buyer when omitted. |
shippingAddress | no | object | Inherited from the buyer when omitted. |
billingAddress | no | object | Inherited from the buyer when omitted. |
parentOrderRef | no | string (1–64) | Append to an existing store order instead of creating one. Next Commerce only — see below. |
upsellKey | no | string (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.
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-levelmirror. On this callmetadata.mirroris a legacy string that is never parsed, so sending both returns400 mirror_dual_specification. - Direct charge (
POST /v1/payment_intents): top-levelmirror, or the identical object nested atmetadata.mirror. The top-level field is folded intometadata.mirrorbefore the block is validated or stored, so both forms produce the same charge and the same stored record;metadata.mirrorstays 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 with400 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
| Platform | Status | Guide |
|---|---|---|
| Shopify | Available | Shopify order mirroring |
| Next Commerce (29next) | Available | Next Commerce order mirroring |
Shared concepts
- The
mirrorblock — 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.lineItemsfeeds 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 apassthroughmap) 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. Rawmirrorblock only — it is not reachable throughorder/mirrorTo. - Your own metadata — attach correlation data with
mirror.metadata, and reference the charge from the order via thevora_payment_intent_idstamped 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.
status | What it means |
|---|---|
pending | Not 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". |
created | The store order exists. |
failed | Terminal — 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_mirrored | Terminal, 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.
code | What happened | What to do |
|---|---|---|
mirror_line_invalid | A line on the order was rejected — typically a product reference the store could not resolve. | Fix the line and resend. |
mirror_connection_error | The store connection needs re-authorizing. | Reconnect the store, then retry. |
mirror_shipping_country_unsupported | The 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_rejected | The 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_cancelled | Nothing 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 existsStop 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
mirrorblock at all (hosted route only; the direct-charge route reportspendingfor 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.
created does not mean the payment succeededMost 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-Keyand 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
- Connect your store in the dashboard (each platform's guide has the exact steps).
- Read your platform's guide for the destination value and any platform‑specific fields:
- Send the
mirrorblock on your create call and confirm the order appears in your store.