Skip to main content

Next Commerce (29next) order mirroring

Send a mirror block with any Vora payment and, on a successful charge, Vora creates the matching already-paid order in your Next Commerce (29next) store. Vora charges the card; 29next stays your system-of-record for fulfillment and CRM. Post-purchase upsells consolidate onto the same order (one order, multiple payments).

Before you send a mirror block

  1. Connect each 29next store in your Vora dashboard → Vora → Integrations → Connected Platforms → Connect 29next. You'll paste the store host (your-store.29next.store) and a 29next Admin API token (Settings → API Access → Create App → copy the token). Grant it the full capability set in What the store key must be able to do — a token scoped to orders alone connects cleanly and then fails every order. You can connect more than one store — repeat this per store (see Multiple stores).
  2. Create an External Payment Method in 29next (Settings → Payments → External Payment Methods → Add): name it anything, and set its Code (we recommend vora). You enter this same code when you connect the store, so it matches exactly — the code is per store. (If Code is left blank, 29next auto‑generates one; set it explicitly so you know what to enter.)

When the store key is refused

Vora tests the key with a live read against your store before saving it, so a refused key never leaves a half-connected store behind — fix it and paste it again. Nothing is stored when that read fails.

These five come from the Connect step in the dashboard, not from POST /v1/sessions or POST /v1/payment_intents. If you're searching an error code and landing here, that's why you can't find it in an API response.

CodeWhat it meansFix
scope_missingYour store recognised the key and refused the request — the key exists but isn't permitted to perform it. A permissions problem, not a wrong or expired key.Grant the key the capabilities below, on the key itself in your 29next admin, then paste it again. A brand-new key does not help unless it carries those capabilities.
auth_failedYour store didn't recognise the key at all — wrong value, revoked, or belonging to a different store.Re-copy the key from your store admin, check it's for the host you entered, paste again.
not_foundThe host was reachable but the account behind it couldn't be resolved. Usually a typo in the store host.Check the host matches your store exactly, then retry.
rate_limitedYour store is throttling API calls. Not a verdict on the key.Wait a minute and paste it again.
unavailableYour store didn't answer, or returned a server error. Not a verdict on the key.Retry shortly. If it persists, check your store's status, then contact support.
webhook_provisioning_failedThe key worked, but Vora couldn't set up the notifications your store sends back. On a first-time connect this refuses rather than half-completing. On a reconnect with a secret already on file, the same failure does NOT refuse — see the warning below. The response carries a reason; scope_missing there means the key isn't permitted to manage notifications.Grant the key permission to read and manage notifications (last row below), then paste it again. If your store plan can't grant it at all, contact support before taking payments.
A refund only reaches the card once Vora is told about it

Vora is the processor, so a refund you record in your store only reaches the customer's card once Vora is told about it. A store that can't notify Vora is a store where refunds silently don't happen.

On a first-time connect, that refusal is the whole protection — nothing is saved, so you cannot end up connected-but-deaf.

⚠️ On a reconnect it behaves differently, and this is the case to watch. If a working secret is already on file, a notification failure does not refuse: the existing secret is kept and the connect succeeds with a green "connected" — even though the notification subscription may have been removed at your store. Nothing surfaces that. So after re-pasting a key, confirm a real refund reaches the card before trusting the connection; a green screen is not evidence here.

scope_missing is the one that costs time, because it looks like a bad key and isn't. The key is fine; it just isn't allowed to do the thing.

What the store key must be able to do

Vora talks to your store for the whole order lifecycle, so a read-only key isn't enough. Store platforms group permissions differently, so rather than naming one platform's labels, here's exactly what Vora calls and why — grant a key that can do all of it:

Vora needs toSo that
Read store detailsThe Connect step can confirm the key works before saving it. This is the only call Connect makes.
Create ordersYour paid checkout becomes a real order in your store.
Read orders and their linesVora can find an order again to settle, refund, or reconcile it.
Mark an order paidThe order stops showing as unpaid once the card is charged.
Add a line to an existing orderUpsells attach to the original order instead of creating a second one.
Calculate and create refundsA refund you issue through Vora is recorded on the store order too.
Cancel an orderAn abandoned or unpayable order is released rather than left open.
Create and delete cartsVora can ask the store to price a discount code before charging, then clean up. Only needed if you use store-priced vouchers.
Read and manage notificationsYour store can tell Vora when something happens in it — above all, when you issue a refund. Vora is the processor, so a refund you record in your store only reaches the customer's card once Vora is told about it. Setup registers this automatically; without the permission it cannot — and a first-time connect is refused rather than half-completed.
A key that passes setup can still fail later

Connect performs only the first row — reading store details — plus the notification setup in the last row. Everything in between is untested at that moment. A key that can read the store and manage notifications therefore connects cleanly and then fails every order injection: the connection looks healthy, the wizard shows connected, and nothing arrives in your store.

This is the one failure you cannot deduce from a successful setup screen. Grant the full set up front.

Where the mirror block goes

Both charge surfaces take the block at the top level. The direct-charge call additionally accepts it nested under metadata, and the two positions are equivalent there.

Hosted checkout — POST /v1/sessions. mirror is a top-level field:

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

Direct charge — POST /v1/payment_intents. Top-level mirror works here too, and so does metadata.mirror:

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

A top-level mirror on this call is folded into metadata.mirror before anything else runs, so the two forms are indistinguishable afterwards: same charge, same stored order. metadata.mirror is the established spelling here and is not being retired, so there is no migration to do either way.

Send the block as an object, and send it once

Two rejections to know about on the direct-charge call:

  • A stringified block returns mirror_malformed. In either position the mirror must be a nested JSON object. A block that gets coerced to a string on the way out (String(block), template-literal interpolation, or a JSON.stringify() you meant to skip) lands as "[object Object]" and is rejected.
  • Two different blocks in one request returns mirror_alias_conflict: you sent both a top-level mirror and a metadata.mirror, and they don't match. Identical blocks are fine. Only a genuine disagreement is refused, because picking a winner would attach the wrong order to the charge, or none.

Both fail before the buyer is charged, so a malformed or contradictory block costs you a 400, never an orphaned payment.

On both surfaces the block itself is validated strictly: any unknown key inside it returns validation_unknown_field, and the serialized block must be ≤ 4096 bytes (mirror_too_large). To skip mirroring on a charge, omit the block.

For a Next Commerce mirror these checks — block size, store authorization, variant ids — all run pre-charge on both surfaces. An unconnected store or a missing variant id comes back as a 400 instead of taking the payment and failing the mirror afterwards.

One requirement is not in that set: shipping_address. 29next refuses to create an order without one, but that refusal happens after the charge — see mirror_missing_shipping_address.

The mirror block — field reference

FieldRequiredType / limitNext Commerce notes
contract_versionstring (1–10)"2026-05-15".
destination"nextcommerce"Selects the 29next adapter.
shopstringYour store host, e.g. "your-store.29next.store" — also selects which connected store to mirror to.
line_itemsarray 1–100{ title, quantity, price, external_product_ref }.
customer✅*objectemail (required), first_name, last_name. Not required on an upsell.
shipping_addresseffectively requiredobjectNeutral address. Optional in the schema, but 29next rejects an order create without one — and that rejection lands after the charge. Omit it only on an upsell, which inherits the parent order's.
billing_addressoptionalobjectSame shape; defaults to shipping.
shippingoptionalobject{ code?, price? } — the order's shipping method code + amount. Omit → no shipping line is added (the mirrored order total is just your line items). See Shipping method + amount.
attributionoptionalobjectMarketing / direct-response — maps to order.attribution.
metadataoptionalRecord<string,string>Your own correlation data, carried onto the 29next order.
couponoptionalobject{ code, amount? }display-only coupon/voucher shown on the order; never changes the total. See Coupon (display-only).
voucheroptionalobject{ code } — a real, store-priced discount that your store applies and that reduces the order total. Not the same as coupon. See Voucher (real discount).
parent_order_reffor upsellsstring (1–64)The initial order's number, or the initial charge's payment-intent id (vpi_…). Present → this charge is an upsell appended to that order; absent → create a new order.
upsell_keyoptionalstring (1–64)Your id for one upsell purchase decision. A later repeat returns 409 upsell_duplicate and is not charged — with one narrow exception for simultaneous retries, see upsell_key. Upsells only.

Line items → 29next variants

{ "title": "Rad Cat Tee", "quantity": 1, "price": "29.00", "external_product_ref": "12345" }
  • price is a string ("29.00").
  • external_product_ref is required on every line for 29next and it must be the sellable (variant) id: the id of the specific buyable variant (size / colour / option), a positive integer as a string. It maps to the order line's product_id.
  • Do not send the parent product id. A parent product has no price of its own, so 29next rejects the whole order. That rejection lands after the buyer has been charged: a parent id is a perfectly valid positive integer, so it passes our pre-charge checks, and only 29next can tell the two kinds of id apart. The payment succeeds, no order is created, and the mirror-order poll comes back failed. Get this one right up front. There is no separate variant or package field to send, because 29next resolves the parent from the sellable id automatically.
  • A line with no ref at all is rejected up front as mirror_line_missing_product_ref, before the charge.
  • Finding a variant id: in 29next, call GET /api/admin/products/{product_id}/ and use each entry in variants[].id. (Even a single-variant product has its own variant id — use that, not the product id.)
On the raw block, the item list is separate from the buyer's

mirror.line_items describes the 29next order only. On hosted checkout the buyer's page reads a separate top-level lineItems array, and neither list derives from the other — so describing the order only inside mirror produces a correct store order behind a checkout page showing a bare 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 (2900) on the buyer's list, versus title / price as a decimal string ("29.00") in the block.

On a direct charge there is no buyer-facing page, so this does not apply.

Address

{ "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. Two more fields are conditional, and both depend on something the field itself can't see:

FieldRule
cityRequired on shipping, optional on billing.
stateRequired for countries that have subdivisions, omitted for those that don't.

city depends on which address it is

mirror.shipping_address requires a city — an order with no city can't be delivered. Omitting it is refused with the field path shipping_address.city.

mirror.billing_address does not. A billing address is complete without one, because it exists for address verification, and that needs the street and postal code only.

This widened — nothing you send today breaks

city used to be required on both. It now accepts strictly more and rejects nothing new, so if you already send a city everywhere, nothing changes for you.

Why it changed is worth knowing, because the old failure was silent. The projector that carries an address onto the store order is all-or-nothing, so a billing address missing only its city was discarded whole — and an absent billing address makes this connector send billing_same_as_shipping_address. The order then carried the shipping address as its billing address: not a blank field, but an authoritative-looking wrong one, which nobody goes looking for.

On Next Commerce, send a city on billing anyway

The API accepts a city-less billing address; this connector still needs one, because it packs city into a field the store requires. What happens then depends on when it's caught:

  • Before the charge — refused outright, nothing is billed. The message tells you to send a city on billing_address, or drop the billing address entirely if it should match shipping.
  • After the charge (an upsell appended to an order that's already paid) — it can't refuse without leaving a paid buyer with no order, so it drops the billing address instead, and the order carries the shipping address as billing. The wrong-address outcome above, accepted deliberately as the lesser harm.

So the city-less billing address that's merely inconvenient pre-charge becomes a silently wrong order post-charge. Send the city.

state is required by country, not always

Roughly 150 countries have no state or province at all. state is therefore required only for countries that have one — US, Canada, Australia and the rest — and must be omitted entirely for countries that don't.

Countrystate
Has subdivisions (US, CA, AU, …)Required. A US address without one is still refused.
Has none (SG, MC, …)Omit the field.
Don't send an empty string

"" is not the same as omitting it. An empty string lands on the store order as that order's province, so the order carries a blank province rather than none. Leave the field out.

The same per-country rule governs the address a buyer types on our hosted page and the one you send in the mirror block — one rule, read by the form, the pay button and the store projection, rather than three opinions. That matters: when those disagreed, valid addresses were discarded after buyers had been charged.

Shipping method + amount

"shipping": { "code": "express", "price": "12.50" }

Optional. code is your store-configured shipping method code (e.g. default / express); price is a non-negative decimal string. Omit the whole block and no shipping line is added — the mirrored order total is just your line items (it will not pick up a store-default shipping charge). Set price (and optionally code) so your store's shipping total matches what Vora charged. (Name a code without a price and that store method prices itself.)

Attribution → native 29next order fields

"attribution": {
"affiliate": "acme", "funnel": "spring-promo",
"utm_source": "newsletter", "utm_campaign": "spring",
"passthrough": { "affid2": "xyz", "landing_page": "/lp/spring" }
}

Named fields (affiliate, subaffiliate1-5, funnel, utm_*) map 1:1 to order.attribution.* and are capped at 255 characters each; anything under passthrough lands in order.attribution.metadata and is capped at 2000 characters per value, so a full landing-page URL fits.

Discounts: two options

You can attach a discount code to a mirrored order in one of two ways. Pick based on whether you want the code to change the order total:

coupon (display-only)voucher (real discount)
EffectRecords the code as a labelYour store applies it and lowers the order total
Order totalUnchangedRecomputed by the store's promotions engine
Where it showsThe order's metadataA discount line in the order's Payment Summary
You chargeWhatever you decideThe store's discounted total (from the quote endpoint)

Send one of them for a given discount, not both.

Coupon (display-only)

"coupon": { "code": "SUMMER25", "amount": "10.00" }

Optional. Records the coupon or voucher the buyer applied so it shows on the mirrored 29next order. code (required) is the code itself — use an opaque code, not buyer-identifying text. amount (optional) is a display-only decimal string.

It is informational only — it does not change the mirrored order total. The amount Vora charged stays the source of truth (so refunds reconcile). It's written to the 29next order's metadata as coupon_code / coupon_amount, never a discount line. Realized on the initial order create — not carried onto an upsell append.

Voucher (real discount)

"voucher": { "code": "SUMMER25" }

Use voucher when you want a real discount that appears as a discount line on the order and reduces the order total — as opposed to coupon, which only records a label. Your store's own discount engine applies the code, so the code must be a voucher configured and active in your 29next store.

Direct charge only

Store-priced vouchers work on the direct-charge call (POST /v1/payment_intents) with destination: "nextcommerce". Hosted checkout does not support them — a hosted charge bills the amount fixed at session-create and never re-checks a discounted total, so a voucher there comes back 400 mirror_voucher_disabled. On that path use coupon to record the code without changing the total.

Your store owns the discount math, so you charge the amount it returns. The discount is computed from the prices you send, it excludes shipping, and 29next applies its own rounding — a discount you compute yourself won't match, so don't guess it. The flow is:

  1. Send full-price line items and the voucher code. Do not send pre-discounted prices with a voucher — the store would subtract the discount a second time and the order total would fall below what you charged.
  2. Ask for the amount to charge with POST /v1/mirror/quote. It returns the discounted item total; add your shipping.
  3. Charge exactly that amount. Before the charge, Vora re-checks it against the store and refuses the charge if it doesn't match — so a wrong amount is a clean rejection you retry, never a mis-charge.

Rules and limits:

  • The order total must equal what you charged. This is verified before the card is charged (see the errors below), and again before the order is marked paid; a mismatch leaves the order unpaid for review rather than recorded wrong.
  • Put the discount on the initial order — a voucher with parent_order_ref is rejected.
  • Shipping must have a price. If you name a shipping code with no price alongside a voucher, the request is rejected (the store would price that method itself, so the total can't be known before the charge). Send a shipping.price, or omit the shipping code.
  • code must be an opaque code (e.g. SUMMER25), never buyer-identifying text.

Pre-charge errors (returned by POST /v1/payment_intents before any charge):

CodeHTTPMeaning
mirror_voucher_amount_mismatch400The amount doesn't equal the store's discounted total. Re-quote and charge that.
mirror_voucher_rejected400The store didn't apply the code — check it exists and is active, or drop the voucher.
mirror_voucher_unverifiable503The store couldn't price it right now. Retry. Not a verdict on your code or amount.

Pricing a discounted basket: POST /v1/mirror/quote

When a buyer applies a discount code, ask the store what the basket costs before you charge. Server-to-server; requires a secret key.

Call it on an explicit "apply code" action, not on every page load — it uses the same connected-store request budget as order creation and refunds.

Request
{
"destination": "nextcommerce",
"shop": "your-store.29next.store",
"currency": "USD",
"line_items": [{ "line_ref": "12345", "quantity": 1, "unit_amount": 2900 }],
"voucher_code": "SUMMER25",
"shipping_amount": 500
}
  • line_ref is the sellable (variant) id, same as external_product_ref on the mirror block; unit_amount is your full price for one unit, in minor units (cents). The store discounts off this.
  • voucher_code is optional. Send it to price the basket with that code applied.
  • shipping_amount (optional, minor units) is the shipping you'll send. Supply it and the response includes amount_to_charge — one number to bill. Omit it and you add your own shipping.
Omit voucher_code to get the baseline — this is worth doing on its own

A store can run always-on promotions that apply to any basket without a code. If you only ever quote with a code, those discounts are invisible to you, and you cannot tell how much of the total discount the code is actually responsible for.

Quote twice — once with no voucher_code, once with it — and compare. The difference is the code's real contribution; the baseline is what every buyer gets anyway.

Always inspect status — a 200 does not mean a discount applied:

Response (quoted)
{
"status": "quoted",
"amount_to_charge": 3110,
"item_total": 2610,
"undiscounted_item_total": 2900,
"discount_amount": 290,
"attributed_discount_amount": 290,
"unattributed_discount_amount": 0,
"discount_attribution": "complete",
"shipping_amount": 500,
"currency": "USD",
"offer_name": "10% OFF"
}

How much of the discount the code actually earned

discount_amount is the whole discount. The store also returns the list of offers it applied — and the two don't always agree, because an always-on store promotion lands in the total without ever appearing in the list. The three attribution fields report that gap, so you can tell "your code took 20%" from "your code took 10% and a standing offer took the rest."

FieldWhat it is
attributed_discount_amountThe part the store credited to named offers, in minor units.
unattributed_discount_amountThe remainder — discount applied but not tied to a named offer. Signed (see below).
discount_attributioncomplete, partial, or unknown.

attributed + unattributed == discount_amount whenever both are present, so you can check the arithmetic rather than trust it.

Read the word, not the numbers

complete and unknown both leave you without a positive figure, and they mean opposite things — complete is "every unit is accounted for", unknown is "the store returned an amount we could not read." In the unknown case both amounts are null, never 0, precisely because zero would assert that everything was accounted for.

So branch on discount_attribution. Testing the amounts for zero is the mistake this field group is shaped around.

unattributed_discount_amount is signed. A negative value means the store's own itemised offers add up to more than its stated total — a misconfiguration in that store, surfaced rather than hidden. It's worth looking at rather than ignoring.

These fields don't replace the two-quote comparison above; they answer the same question in one call when the store attributes its offers, and tell you when it can't.

statusWhat to do
quotedCharge amount_to_charge (or item_total + your shipping if you didn't send shipping_amount).
code_rejectedThe store refused the code. Show a friendly message and charge without the discount.
unavailableNo answer (store unreachable or throttled). Not a verdict on the code — don't tell the buyer it's invalid and don't charge a discounted amount. Retry, or charge undiscounted.
item total vs amount

item_total is deliberately not called amount — it's items and the discount only, so charging it directly would undercharge by your shipping. Use amount_to_charge (present when you send shipping_amount), or add your shipping to item_total yourself. If your code discounts shipping, the quote can't reflect that — it prices items only.

Multiple stores

A single Von Payments merchant can connect several 29next stores and route each transaction to the right one.

Connecting. Repeat the Connect step per store, each with its own Admin API token and External Payment Method code. Per-store tokens give hard cross-brand isolation — a Store B token can't touch Store A.

Routing. There's no separate routing rule — the mirror.shop field on each mirror block is the router. Set it to the destination store's host for that transaction:

"mirror": { "destination": "nextcommerce", "shop": "brand-a.29next.store", ... }   // → Store A
"mirror": { "destination": "nextcommerce", "shop": "brand-b.29next.store", ... } // → Store B

The connector loads that store's credentials for the order push, settle, refund, and inbound-webhook verification — so orders land in, and refunds/webhooks resolve against, the store named in shop.

Connect first. A mirror.shop that isn't a connected, active store for your account returns 400 mirror_shop_not_authorized at session-create, before any charge — connect the store first.

What happens on a successful charge

  1. Vora charges the card.
  2. The rail creates the order in 29next as payment_method: "external" (the External Payment Method you configured), then settles it to paid — no card is charged in 29next.
  3. The order appears in your 29next admin with your line items, customer, address, and attribution.
The order is created on capture, not on authorization

The mirror fires when the payment reaches captured, not when the card is merely authorized. A capture_method: "manual" charge creates no 29next order while it sits authorized: the poll below reports pending and stays there indefinitely, because capture is what triggers the order. Capture the charge (POST /v1/payment_intents/{id}/capture), then poll.

The upside of that rule: a mirrored 29next order always represents money already captured.

Duplicate protection is per-payment, not per-cart. The 29next order-create call carries no idempotency key of its own, so what Vora guarantees is at most one 29next order per payment intent, and nothing beyond that. Re-driving a mirror internally never creates a duplicate, and reusing your Idempotency-Key on a retry gives you one charge and one order. But re-sending the same cart with a fresh Idempotency-Key, or as a new payment intent, is a new payment as far as we can tell: you get a second charge and a second 29next order. Always reuse the key on a retry.

The same gap on an upsell has a second guard: an upsell sent without upsell_key gets no deduplication across charges, so a retry can both charge again and append a duplicate line.

Getting the order back (webhook + retrieve)

The 29next order is created asynchronously — a few seconds after the charge succeeds — so the order number isn't in the charge response. Get it back either way:

Webhook (recommended). Subscribe to the mirror.order.created event; Vora pushes it when the order is created:

{
"payment_intent_id": "vpi_...",
"order_number": "10023",
"order_status_url": "https://your-store.29next.store/..."
}

Retrieve (poll). Poll the mirror-order endpoint for the charge until the order lands — one route per flow, both returning the same shape:

// Hosted / session flow — publishable key
GET /v1/public/sessions/{sessionId}/mirror-order

// Discrete payment-intent flow — secret key
GET /v1/payment_intents/{id}/mirror-order

→ { status, order_number, order_status_url, code?, message? }
status ∈ "pending" | "created" | "failed" | "not_mirrored"
  • pending — not created yet, or a retriable hiccup — keep polling.
  • createdorder_number (+ order_status_url when 29next provides one) are present.
  • failedterminal: stop polling. The response adds a self-healing code and a merchant-safe message. Branch on code before you act — most values mean a rejection you need to fix (e.g. an external_product_ref pointing at a parent product instead of a variant: fix the input and resend). One value, mirror_order_cancelled, means the opposite — an order was created before the payment, the payment did not complete, and the order was released, so nothing is wrong and nothing is owed. See What code tells you.
  • not_mirrored - also terminal: stop polling - but it does not prove no order exists. It covers three different situations: the charge carried no mirror block (hosted route only; the discrete route reports pending for that case, see below); the mirror was recorded but never dispatched (store disconnected, authorization revoked, or mirroring switched off for your account); or an order was created in the store and has since been cancelled there. order_number is null in all three, so the response alone cannot tell you which one you are holding. Check the store before you reconcile or refund. In particular, do not issue a second refund on the strength of this status - if the order was created and cancelled in 29next, a refund may already have been issued there. Once you have confirmed no order exists, the charge still stands, so reconcile it: enter the order in 29next yourself, or refund it.
On the discrete route, a never-resolving pending is ambiguous

Handle all four states on both routes — not_mirrored is reachable on the discrete route too (a skipped mirror surfaces there).

The ambiguity is narrower than that: when no record exists at all, the discrete route has nothing to tell "this charge carried no block" apart from "the mirror hasn't run yet," so it reports pending for both.

So on the discrete flow a pending that never resolves means one of three things: still working, or your block never arrived, or the charge is authorized but not yet captured (see above). If it persists, check those rather than continuing to poll.

Use order_number for your thank-you page and as parent_order_ref when sending upsells. order_status_url is 29next's signed customer order page.

Post-purchase upsells (one order, multiple payments)

A post-purchase upsell is just another Vora charge — there's no separate upsell API. To append an accepted upsell to the buyer's existing 29next order (instead of creating a second order), send the upsell charge with mirror.parent_order_ref set to the number of the initial order:

POST /v1/payment_intents        // the upsell charge
{
"amount": 1500,
"currency": "USD",
"metadata": {
"mirror": {
"contract_version": "2026-05-15",
"destination": "nextcommerce",
"shop": "your-store.29next.store",
"parent_order_ref": "10023",
"upsell_key": "offer-7f3a-accepted",
"line_items": [
{ "title": "Warranty add-on", "quantity": 1, "price": "15.00", "external_product_ref": "67890" }
]
}
}
}

Shown nested here; the top-level mirror position works the same on this call, and is the only position on POST /v1/sessions. See Where the mirror block goes.

  • parent_order_ref makes this an append: Vora adds the upsell line to that order and records the payment as a second external transaction — one 29next order with N payments.
  • You don't have to wait for the order_number. parent_order_ref accepts either the parent order's number or the parent charge's Vora payment-intent id (vpi_…, the id in the base charge response). The payment-intent id lets you fire a post-purchase upsell immediately, without first polling for the base order: Vora resolves it to the order, and if the base order hasn't landed yet it retries the append until it does. If the base order never lands, the upsell's mirror-order status fails rather than stalling silently.
  • An upsell can carry one or more line_items (up to 10). Every line is appended to the parent order in one charge, and each line is checkpointed independently, so Vora re-driving that same charge never double-appends a line. (Protecting against your own retry firing a second charge is what upsell_key below is for.) To sell several upsell products, include them all in line_items on one charge, or send them as separate charges, each with its own parent_order_ref append.
  • customer and shipping_address are not required on an upsell (inherited from the parent).
  • Omit parent_order_ref (default) to create a new order.
  • The reference is validated against your account + store — an upsell to an order you don't own, or on a different store, is rejected.

upsell_key — don't let a retry charge twice

An upsell is the one place a network timeout genuinely hurts. Your request times out, you don't know whether the charge landed, you retry — and if the first attempt had landed, the buyer is charged twice and the 29next order gets a duplicate line.

Reusing the Idempotency-Key request header on the retry is the first-line answer, and it's enough whenever your retry is a faithful replay. upsell_key covers the case that header can't: an integration that generates a fresh idempotency key on each attempt, so the two attempts look like two unrelated requests.

Send upsell_key as your identifier for the buyer's purchase decision — an offer-acceptance id, not a per-request id. The same value must go on every attempt at that one decision:

Typestring, 1–64 chars, optional
Whereinside the mirror block, alongside parent_order_ref
Scopeone key per connected store
Valid onupsells only — sending it without parent_order_ref is rejected at parse (use the Idempotency-Key header for order creates)

Vora checks the key before charging. A later charge carrying a key that's already held is refused with 409 upsell_duplicate — the buyer is not charged, and the response carries original_payment_intent_id, the payment that already covered the decision. This is the case a timeout-then-retry actually produces, and it's the one the key is built for.

Two simultaneous retries are caught later, not up front

If two re-fires of the same key are genuinely in flight at the same moment, both can pass the pre-charge check and both can be charged. A database constraint catches the collision immediately afterwards: the second one is refused before any line is appended to the order, and is raised as a high-priority alert for reconciliation rather than being dropped silently.

So upsell_key guarantees the buyer's order never gains a duplicate line — but in that narrow simultaneous case a second charge can land and needs reversing. Space your retries instead of firing them in parallel, and the pre-charge refusal above is what you'll get.

How to handle a 409 upsell_duplicate: treat it as success you already have. Read the original payment's status via GET /v1/payment_intents/{original_payment_intent_id} and show the buyer that result. Do not retry with the same key, and do not strip the key to force the charge through — that's the double-charge you just avoided.

Omitting upsell_key keeps today's behavior exactly: unkeyed upsells get no cross-charge deduplication.

Settle-only: record a payment against an order you built yourself

Sometimes you have already added the line to the 29next order on your own side, and you only need Vora to take the money and record it against that order's outstanding balance. That is settle_only.

{
"amount": 1500,
"currency": "USD",
"mirror": {
"contract_version": "2026-05-15",
"destination": "nextcommerce",
"shop": "acme.29next.store",
"parent_order_ref": "10021",
"settle_only": true,
"customer": { "email": "casey@example.com" }
}
}

Vora charges the card and settles that order's balance. It appends no line, because you already added one.

The rules, all enforced before the charge:

RuleWhy
Requires parent_order_refIt names the existing order the payment is recorded against. Without it there is nothing to settle.
Must not carry line_itemsYou built the line yourself. Send lines and Vora would add a second one. Drop line_items, or drop settle_only and let Vora add the line.
Next Commerce onlyNot supported on other destinations.
Cannot combine with skipsettle_only records a payment by settling an existing order's balance; skip creates no store order at all. Together the buyer is charged with nothing recording it.
Refused if unavailable on the callIf settle_only is not available for the request as sent, it is rejected with mirror_settle_only_disabled before the card is charged.

Use the ordinary upsell append above when you want Vora to add the line; use settle_only when you have already added it.

Correlating the order back to Vora

Every mirrored order carries the Vora payment reference in its 29next order.metadata so you can find, reconcile, or support it from inside 29next. Read it off the order, then resolve the full Vora record (including your complete metadata) via GET /v1/payment_intents/{id}.

The canonical key is vora_payment_intent_id. The older von_payment_intent_id is still written alongside it, carrying the identical value, so anything wired to the original key keeps working. Read the canonical one in new code. Both are stamped last, so a key of your own in mirror.metadata can never shadow them.

Attaching your own metadata

  • payment_intents.metadata (open JSON, ≤ 8KB) is retained on the Vora payment intent and readable via GET /v1/payment_intents/{id}.
  • mirror.metadata (neutral Record<string,string>) is carried onto the 29next order itself, so your correlation data is visible directly in the CRM.

Complete example (initial checkout)

Hosted checkout, so this session carries two item lists: lineItems for the buyer's checkout page (minor units) and mirror.line_items for the 29next order (decimal strings + variant ids). Omit the first and the buyer sees a bare total. On POST /v1/payment_intents there is no buyer-facing page, so mirror alone is enough. See Where the mirror block goes.

POST /v1/sessions
{
"amount": 2900,
"currency": "USD",
"metadata": { "your_order_id": "A-1001" },
"lineItems": [
{ "name": "Rad Cat Tee", "quantity": 1, "unitAmount": 2900 }
],
"mirror": {
"contract_version": "2026-05-15",
"destination": "nextcommerce",
"shop": "example-store.29next.store",
"line_items": [
{ "title": "Rad Cat Tee", "quantity": 1, "price": "29.00", "external_product_ref": "12345" }
],
"customer": { "email": "buyer@example.com", "first_name": "Sam", "last_name": "Rivera" },
"shipping_address": {
"line1": "1 Test St", "city": "Austin", "state": "TX", "postal_code": "78701", "country": "US"
},
"attribution": { "utm_source": "newsletter", "utm_campaign": "spring" },
"metadata": { "your_order_id": "A-1001" }
}
}

End-to-end test checklist

  1. Connect your 29next store and confirm the wizard shows connected.
  2. Grab a real 29next variant id (use as external_product_ref).
  3. Fire the initial charge with a Vora test card; complete the returned checkout_url.
  4. Catch the mirror.order.created webhook (or poll the retrieve) → note the order_number.
  5. In 29next → Orders, confirm a new paid order with the right line items, customer, address, attribution — and the vora_payment_intent_id in the order metadata.
  6. Send an upsell charge with parent_order_ref = that order_number and an upsell_key; confirm the same order now has a second line + a second payment.
  7. Replay that exact upsell charge with the same upsell_key. Confirm you get 409 upsell_duplicate, that no second charge appears, and that original_payment_intent_id points at the charge from step 6.

Errors

All are 400 unless noted.

ResponseMeaningFix
validation_unknown_fieldUnexpected key inside the mirror blockSend only the documented fields. The block itself is accepted at the top level on both charge calls, so this is about a key within it.
mirror_malformedThe mirror on POST /v1/payment_intents isn't a JSON object (typically stringified or coerced to "[object Object]")Send the block as an object, not a string, in either position.
mirror_alias_conflictPOST /v1/payment_intents carried a top-level mirror and a metadata.mirror, and the two differSend one block. Identical blocks in both places are accepted; only a disagreement is refused. See Where the mirror block goes.
mirror_too_largeBlock > 4096 bytesTrim line items / metadata.
mirror_amount_below_line_itemsThe block's line_items + shipping come to more than the amount being charged. Refused before the charge — nothing moved. One-sided: charging more than the lines itemise is allowed. Exempt when mirror.voucher is present, because full-price lines against a store-discounted total is the correct shape thereDon't subtract a discount yourself — send it as mirror.voucher and let the store price it. Otherwise make amount cover the lines and shipping you're mirroring. The response carries charge_amount and mirror_line_items_sum.
mirror_line_missing_product_refA line has no external_product_refAdd the 29next variant id to each line.
mirror_missing_shipping_addressPost-charge, not a 400. An order create carried no shipping_address. The payment succeeded; no 29next order was createdSend shipping_address on every order create. Surfaces via the mirror-order poll / webhook as a terminal failed, not on the charge response.
mirror_shop_not_authorizedshop isn't a connected, active store for your accountReconnect; confirm shop = your *.29next.store host.
mirror_dual_specificationOn POST /v1/sessions: sent both the top-level mirror and a legacy stringified metadata.mirrorKeep the top-level mirror; drop metadata.mirror. On POST /v1/payment_intents, metadata.mirror is a supported object and the equivalent clash is mirror_alias_conflict instead.
mirror_voucher_disabledA voucher was sent on POST /v1/sessions. Hosted checkout doesn't support store-priced vouchers: the charge bills the amount fixed at session-create and never re-checks a discounted total. Refused before the chargeMove the charge to POST /v1/payment_intents, or use mirror.coupon to record the code without changing the total. Don't drop the voucher and charge the discounted amount anyway: the 29next order would be created at full price and permanently disagree with the money taken.
409 upsell_duplicateThis upsell_key is already held — the decision was already paid for. The buyer was not charged.Read original_payment_intent_id off the response and use that payment's status. Don't retry the key; don't strip it. See upsell_key.