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
- 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). - 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.
| Code | What it means | Fix |
|---|---|---|
scope_missing | Your 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_failed | Your 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_found | The 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_limited | Your store is throttling API calls. Not a verdict on the key. | Wait a minute and paste it again. |
unavailable | Your 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_failed | The 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. |
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 to | So that |
|---|---|
| Read store details | The Connect step can confirm the key works before saving it. This is the only call Connect makes. |
| Create orders | Your paid checkout becomes a real order in your store. |
| Read orders and their lines | Vora can find an order again to settle, refund, or reconcile it. |
| Mark an order paid | The order stops showing as unpaid once the card is charged. |
| Add a line to an existing order | Upsells attach to the original order instead of creating a second one. |
| Calculate and create refunds | A refund you issue through Vora is recorded on the store order too. |
| Cancel an order | An abandoned or unpayable order is released rather than left open. |
| Create and delete carts | Vora 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 notifications | Your 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. |
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.
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 aJSON.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-levelmirrorand ametadata.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
| Field | Required | Type / limit | Next Commerce notes |
|---|---|---|---|
contract_version | ✅ | string (1–10) | "2026-05-15". |
destination | ✅ | "nextcommerce" | Selects the 29next adapter. |
shop | ✅ | string | Your store host, e.g. "your-store.29next.store" — also selects which connected store to mirror to. |
line_items | ✅ | array 1–100 | { title, quantity, price, external_product_ref }. |
customer | ✅* | object | email (required), first_name, last_name. Not required on an upsell. |
shipping_address | effectively required | object | Neutral 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_address | optional | object | Same shape; defaults to shipping. |
shipping | optional | object | { 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. |
attribution | optional | object | Marketing / direct-response — maps to order.attribution. |
metadata | optional | Record<string,string> | Your own correlation data, carried onto the 29next order. |
coupon | optional | object | { code, amount? } — display-only coupon/voucher shown on the order; never changes the total. See Coupon (display-only). |
voucher | optional | object | { 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_ref | for upsells | string (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_key | optional | string (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" }
priceis a string ("29.00").external_product_refis 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'sproduct_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 invariants[].id. (Even a single-variant product has its own variant id — use that, not the product id.)
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:
| Field | Rule |
|---|---|
city | Required on shipping, optional on billing. |
state | Required 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.
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.
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.
| Country | state |
|---|---|
| Has subdivisions (US, CA, AU, …) | Required. A US address without one is still refused. |
| Has none (SG, MC, …) | Omit the field. |
"" 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) | |
|---|---|---|
| Effect | Records the code as a label | Your store applies it and lowers the order total |
| Order total | Unchanged | Recomputed by the store's promotions engine |
| Where it shows | The order's metadata | A discount line in the order's Payment Summary |
| You charge | Whatever you decide | The 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.
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:
- Send full-price line items and the
vouchercode. 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. - Ask for the amount to charge with
POST /v1/mirror/quote. It returns the discounted item total; add your shipping. - 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
voucherwithparent_order_refis rejected. - Shipping must have a price. If you name a shipping
codewith nopricealongside a voucher, the request is rejected (the store would price that method itself, so the total can't be known before the charge). Send ashipping.price, or omit the shippingcode. codemust be an opaque code (e.g.SUMMER25), never buyer-identifying text.
Pre-charge errors (returned by POST /v1/payment_intents before any charge):
| Code | HTTP | Meaning |
|---|---|---|
mirror_voucher_amount_mismatch | 400 | The amount doesn't equal the store's discounted total. Re-quote and charge that. |
mirror_voucher_rejected | 400 | The store didn't apply the code — check it exists and is active, or drop the voucher. |
mirror_voucher_unverifiable | 503 | The 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.
{
"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_refis the sellable (variant) id, same asexternal_product_refon the mirror block;unit_amountis your full price for one unit, in minor units (cents). The store discounts off this.voucher_codeis 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 includesamount_to_charge— one number to bill. Omit it and you add your own shipping.
voucher_code to get the baseline — this is worth doing on its ownA 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:
{
"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."
| Field | What it is |
|---|---|
attributed_discount_amount | The part the store credited to named offers, in minor units. |
unattributed_discount_amount | The remainder — discount applied but not tied to a named offer. Signed (see below). |
discount_attribution | complete, partial, or unknown. |
attributed + unattributed == discount_amount whenever both are present, so you
can check the arithmetic rather than trust it.
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.
status | What to do |
|---|---|
quoted | Charge amount_to_charge (or item_total + your shipping if you didn't send shipping_amount). |
code_rejected | The store refused the code. Show a friendly message and charge without the discount. |
unavailable | No 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 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
- Vora charges the card.
- 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. - The order appears in your 29next admin with your line items, customer, address, and attribution.
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.created—order_number(+order_status_urlwhen 29next provides one) are present.failed— terminal: stop polling. The response adds a self-healingcodeand a merchant-safemessage. Branch oncodebefore you act — most values mean a rejection you need to fix (e.g. anexternal_product_refpointing 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 Whatcodetells 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 reportspendingfor 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_numberis 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.
pending is ambiguousHandle 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_refmakes 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_refaccepts either the parent order'snumberor the parent charge's Vora payment-intent id (vpi_…, theidin 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 whatupsell_keybelow is for.) To sell several upsell products, include them all inline_itemson one charge, or send them as separate charges, each with its ownparent_order_refappend. customerandshipping_addressare 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:
| Type | string, 1–64 chars, optional |
| Where | inside the mirror block, alongside parent_order_ref |
| Scope | one key per connected store |
| Valid on | upsells 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.
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:
| Rule | Why |
|---|---|
Requires parent_order_ref | It names the existing order the payment is recorded against. Without it there is nothing to settle. |
Must not carry line_items | You 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 only | Not supported on other destinations. |
Cannot combine with skip | settle_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 call | If 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 viaGET /v1/payment_intents/{id}.mirror.metadata(neutralRecord<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
- Connect your 29next store and confirm the wizard shows connected.
- Grab a real 29next variant id (use as
external_product_ref). - Fire the initial charge with a Vora test card; complete the returned
checkout_url. - Catch the
mirror.order.createdwebhook (or poll the retrieve) → note theorder_number. - In 29next → Orders, confirm a new paid order with the right line items, customer, address, attribution — and the
vora_payment_intent_idin the order metadata. - Send an upsell charge with
parent_order_ref= thatorder_numberand anupsell_key; confirm the same order now has a second line + a second payment. - Replay that exact upsell charge with the same
upsell_key. Confirm you get409 upsell_duplicate, that no second charge appears, and thatoriginal_payment_intent_idpoints at the charge from step 6.
Errors
All are 400 unless noted.
| Response | Meaning | Fix |
|---|---|---|
validation_unknown_field | Unexpected key inside the mirror block | Send 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_malformed | The 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_conflict | POST /v1/payment_intents carried a top-level mirror and a metadata.mirror, and the two differ | Send one block. Identical blocks in both places are accepted; only a disagreement is refused. See Where the mirror block goes. |
mirror_too_large | Block > 4096 bytes | Trim line items / metadata. |
mirror_amount_below_line_items | The 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 there | Don'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_ref | A line has no external_product_ref | Add the 29next variant id to each line. |
mirror_missing_shipping_address | Post-charge, not a 400. An order create carried no shipping_address. The payment succeeded; no 29next order was created | Send 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_authorized | shop isn't a connected, active store for your account | Reconnect; confirm shop = your *.29next.store host. |
mirror_dual_specification | On POST /v1/sessions: sent both the top-level mirror and a legacy stringified metadata.mirror | Keep 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_disabled | A 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 charge | Move 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_duplicate | This 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. |