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 fulfilment 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 → Integrations → Connected Platforms → Connect 29next. Paste the store host (
your-store.29next.store) and a 29next Admin API token (Settings → API Access → Create App). 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. Repeat 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 the same code when you connect the store; it is per store. Set it explicitly — a blank Code is auto-generated.
When the store key is refused
Vora tests the key with a live read against your store before saving it; a refused key stores nothing, so fix it and paste it again. These codes come from the Connect step in the dashboard, not from 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. |
A refund only reaches the card once Vora is told about it
A refund you enter in your store reaches the buyer's card only through the notification your store sends us, which Connect registers. A first-time connect that cannot register it is refused outright. A reconnect is not: if a working secret is already on file, a notification failure keeps that secret and reports connected — even though the subscription may have been removed at your store, and nothing surfaces that. After re-pasting a key, confirm one real refund reaches the card before relying on the connection.
What the store key must be able to do
Vora talks to your store for the whole order lifecycle, so a read-only key is not enough. Grant a key that can do all of this:
| Vora needs to | So that |
|---|---|
| Read store details | The Connect step can confirm the key works before saving it. |
| 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 full refund you issue through Vora is recorded on the store order too; a partial one is not. Once any amount has been refunded through the API, refund the remainder the same way — a refund entered in the store after that does not reach the card. |
| 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 and the notification setup in the last; everything in between is untested at that moment. A key that can read the store and manage notifications connects cleanly and then fails every order. 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 also accepts it under metadata.mirror, and the two positions are equivalent there — both supported, no sunset.
POST /v1/sessions
{ "amount": 2900, "currency": "USD", "mirror": { "...": "..." } }
POST /v1/payment_intents
{ "amount": 2900, "currency": "USD", "metadata": { "mirror": { "...": "..." } } }
Send the block as an object, and send it once
On the direct-charge call, two refusals land before the buyer is charged:
- A stringified block returns
mirror_malformed. In either position the mirror must be a JSON object; a block coerced to a string on the way out (String(block), template-literal interpolation, an extraJSON.stringify()) arrives as"[object Object]". - Two different blocks in one request — a top-level
mirrorand ametadata.mirrorthat do not match — returnmirror_alias_conflict. Identical blocks are fine.
On both surfaces the block is validated strictly: an unknown key 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, block size, store authorization and variant ids are all checked pre-charge on both surfaces: an unconnected store or a missing variant id is a 400, not a payment followed by a failed mirror. shipping_address is checked pre-charge on the direct charge only: an order create without one is refused with mirror_missing_shipping_address and nothing is billed. On hosted checkout the buyer's typed address fills it; if none is usable, 29next refuses the order after the charge and the poll reports failed.
The mirror block — field reference
| Field | Required | Type / limit | Next Commerce notes |
|---|---|---|---|
contract_version | ✅ | string (1–10) | "2026-05-15". |
destination | ✅ | string: shopify | nextcommerce | Selects the 29next adapter. |
shop | ✅ | string (1–255) | 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 }. |
skip | optional | boolean | Record the payment normally but create no store order for this one charge. Only true does anything: skip: false is treated exactly as if you had not sent the field. 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. |
settle_only | optional | boolean | Record a payment against an order you built yourself: settles the existing order's balance and appends no line. Requires parent_order_ref, forbids line_items, and cannot be combined with skip. Every one of those is refused before any charge, so a bad combination costs you nothing. |
customer | ✅* | object | email (required), first_name, last_name. Not required on an upsell. |
shipping_address | effectively required | object | Neutral address. On POST /v1/payment_intents an order create without one is refused before the charge (400 mirror_missing_shipping_address); on hosted checkout the buyer's typed address fills it, and if none is usable 29next refuses the order 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 | object of 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 and must be the sellable (variant) id — the id of the specific buyable variant, a positive integer as a string. It maps to the order line'sproduct_id. A line with no ref is refused before the charge withmirror_line_missing_product_ref.- Do not send the parent product id. A parent product has no price of its own, so 29next rejects the whole order — after the buyer has been charged, because a parent id is a valid positive integer that passes our pre-charge checks and only 29next can tell the two apart. The payment succeeds, no order is created, and the mirror-order poll reports
failed. 29next resolves the parent from the sellable id itself; there is no separate variant or package field. - Finding a variant id: in 29next, call
GET /api/admin/products/{product_id}/and use each entry invariants[].id. A single-variant product still has its own variant id — use that.
mirror.line_items describes the 29next order; on hosted checkout the buyer's page reads the optional top-level lineItems, and neither derives from the other — describing the order only inside mirror yields a correct store order behind a checkout page showing nothing but a total. Send both, or use order.lineItems, which feeds both. The shapes differ: name / unitAmount (minor units) on the buyer's list; title / price (decimal string) 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 fields are conditional:
| Field | Rule |
|---|---|
city | Required on shipping, optional on billing — but see below. |
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 cannot be delivered, and the refusal names shipping_address.city. mirror.billing_address does not: it exists for address verification, which needs only street and postal code. An absent billing address makes this connector send billing_same_as_shipping_address, so the order carries the shipping address as its billing address.
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 the city into a field the store requires. What happens depends on when it is caught:
- Before the charge — refused outright, nothing 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 is already paid) — it cannot refuse without leaving a paid buyer with no order, so the billing address is dropped and the order carries the shipping address as billing.
Send the city.
state is required by country, not always
Roughly 150 countries have no state or province. state is required only for countries that have one, and must be omitted entirely for countries that do not — an empty string is not the same as omitting it; "" lands on the store order as the province.
| Country | state |
|---|---|
| Has subdivisions (US, CA, AU, …) | Required. A US address without one is refused. |
| Has none (SG, MC, …) | Omit the field. |
The same per-country rule governs the address a buyer types on our hosted page — the form, the pay button and the store projection read one rule.
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, with no store-default shipping charge. Set price (and optionally code) so the store's shipping total matches what Vora charged; a code without a price lets that store method price 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.*, capped at 255 characters each; anything under passthrough lands in order.attribution.metadata, capped at 2000 characters per value, so a full landing-page URL fits.
Discounts: two options
Pick by whether the code should change the order total, and send one of them for a given discount, not both:
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) |
Coupon (display-only)
"coupon": { "code": "SUMMER25", "amount": "10.00" }
Optional. code (required) is the code itself — an opaque code, never buyer-identifying text; amount (optional) is a display-only decimal string. It never changes the mirrored order total: it is written to the 29next order's metadata as coupon_code / coupon_amount, not as a discount line, and only on the initial order create — not on an upsell append.
Voucher (real discount)
"voucher": { "code": "SUMMER25" }
A real discount: your store's own discount engine applies the code, a discount line appears on the order and the order total is reduced. 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 bills the amount fixed at session-create and never re-checks a discounted total, so a voucher on POST /v1/sessions returns 400 mirror_voucher_disabled; use coupon there to record the code without changing the total.
Your store owns the discount math — computed from the prices you send, excluding shipping, with 29next's own rounding — so you charge the amount it returns:
- Send full-price line items and the
vouchercode. Pre-discounted prices with a voucher would be discounted a second time, leaving the order total 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. Vora re-checks it against the store before the charge and refuses a mismatch — a clean rejection you retry, never a mis-charge.
Rules, all enforced before the charge:
- The order total must equal what you charged — verified before the card is charged and again before the order is marked paid; a mismatch there 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. A shipping
codewith nopricealongside a voucher is rejected: the store would price that method itself, so the total cannot be known before the charge. codemust be an opaque code, never buyer-identifying text.
Pre-charge errors from POST /v1/payment_intents:
| 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, secret key. Call it on an explicit "apply code" action, not on every page load — it shares the connected-store request budget with 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, the same value asexternal_product_ref;unit_amountis your full price for one unit, in minor units. The store discounts off this.voucher_codeis optional. Omit it to price the basket with no code: a store can run always-on promotions that apply to any basket, and quoting once without the code and once with it shows how much the code itself is worth.shipping_amount(optional, minor units) is the shipping you will send. Supply it and the response includesamount_to_charge— one number to bill; omit it and add your own shipping toitem_total.
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"
}
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 items and the discount only; charging it directly undercharges by your shipping. If your code discounts shipping, the quote cannot reflect that — it prices items only.
How much of the discount the code actually earned
discount_amount is the whole discount. The store also returns the offers it applied, and the two do not always agree — an always-on store promotion lands in the total without appearing in the list. Three fields report that gap:
| 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. unattributed_discount_amount is signed: a negative value means the store's itemised offers add up to more than its stated total — a misconfiguration in that store, surfaced rather than hidden.
discount_attribution, never on the amounts being zerocomplete means every unit is accounted for; unknown means the store returned an amount we could not read — and in that case both amounts are null, never 0, because zero would assert that everything was accounted for.
Multiple stores
One Von Payments merchant can connect several 29next stores. 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.
There is no separate routing rule — mirror.shop on each block is the router:
"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. A mirror.shop that is not a connected, active store for your account returns 400 mirror_shop_not_authorized before any charge.
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.
A capture_method: "manual" charge creates no 29next order while it sits authorized; the poll below reports pending and stays there until you capture (POST /v1/payment_intents/{id}/capture).
Duplicate protection is per payment, not per cart. Vora guarantees at most one 29next order per payment intent: re-driving a mirror internally never duplicates, and reusing your Idempotency-Key on a retry gives one charge and one order. Re-sending the same cart with a fresh Idempotency-Key, or as a new payment intent, is a new payment — a second charge and a second order. An upsell sent without upsell_key has the same gap across charges.
Getting the order back (webhook + retrieve)
The 29next order is created asynchronously, a few seconds after the charge succeeds, so the order number is not in the charge response.
Webhook (recommended). Subscribe to mirror.order.created:
{
"payment_intent_id": "vpi_...",
"order_number": "10023",
"order_status_url": "https://your-store.29next.store/..."
}
Retrieve (poll). 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. On the discrete route it is also what you get when no record exists at all — the block never arrived — and while a charge is authorized but not captured. Apendingthat never resolves means one of those three; check them rather than continuing to poll.created—order_number(andorder_status_urlwhen 29next provides one) are present. Useorder_numberfor your thank-you page and asparent_order_refon upsells;order_status_urlis 29next's signed customer order page: a private link, so show it only to that shopper and do not log, store or share it.failed— terminal: stop polling. The response adds acodeand a merchant-safemessage; branch oncode. Most values are a rejection you fix and resend (anexternal_product_refpointing at a parent product, say). One,mirror_order_cancelled, means an order was created before the payment, the payment did not complete and the order was released — nothing is wrong and nothing is owed. See Whatcodetells you.not_mirrored— also terminal, reachable on both routes, and it does not prove no order exists: the charge carried no mirror block (hosted route only), the mirror wasskipped, or it was recorded but never dispatched (store disconnected, authorization revoked, mirroring switched off), or an order was created and later cancelled in the store.order_numberisnullin every case.
If the order was created and cancelled in 29next, a refund may already have been issued there; a second one on the strength of not_mirrored is money out twice. Once you have confirmed no order exists, the charge still stands — enter the order in 29next yourself, or refund it.
Post-purchase upsells (one order, multiple payments)
A post-purchase upsell is another Vora charge — there is no separate upsell API. To append it to the buyer's existing 29next order, send the charge with mirror.parent_order_ref:
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; the top-level mirror position works the same on this call and is the only position on POST /v1/sessions.
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. Omit it to create a new order.- You need not wait for the
order_number.parent_order_refaccepts the parent order'snumberor the parent charge's payment-intent id (vpi_…, theidin the base charge response). With the payment-intent id you can fire the upsell immediately: Vora resolves it to the order, retrying the append until the base order lands; if it never lands, the upsell's mirror-order status fails rather than stalling. - An upsell carries one or more
line_items(up to 10), each appended to the parent order in one charge and checkpointed independently, so Vora re-driving that charge never double-appends. Several upsell products go in one charge'sline_items, or in separate charges each withparent_order_ref. customerandshipping_addressare not required on an upsell (inherited from the parent).- The reference is validated against your account and store — an upsell to an order you do not own, or on a different store, is rejected.
upsell_key — don't let a retry charge twice
Reusing the Idempotency-Key header on a retry is the first-line answer to a timeout. upsell_key covers the case that header cannot: an integration that generates a fresh idempotency key on each attempt. It is your identifier for the buyer's purchase decision — an offer-acceptance id, not a per-request id — sent 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 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. Treat it as success you already have: read that payment via GET /v1/payment_intents/{original_payment_intent_id} and show the buyer the result. Do not retry with the same key, and do not strip the key to force the charge through.
If two re-fires of the same key are 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 is refused before any line is appended and raised as a high-priority alert for reconciliation — the order never gains a duplicate line, but that second charge needs reversing. Space your retries instead of firing them in parallel.
Settle-only: record a payment against an order you built yourself
When you have already added the line to the 29next order yourself and only need Vora to take the money and record it against that order's balance, send 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, appending no line. 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. |
Correlating the order back to Vora
Every mirrored order carries the Vora payment reference in its 29next order.metadata. The canonical key is vora_payment_intent_id; the older von_payment_intent_id is still written alongside it with the identical value. Both are stamped last, so a key of your own in mirror.metadata can never shadow them. Resolve the full Vora record via GET /v1/payment_intents/{id}.
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(Record<string,string>) is carried onto the 29next order itself, visible directly in the CRM.
Complete example (initial checkout)
Hosted checkout, so this session carries two item lists — lineItems for the buyer's page and mirror.line_items for the 29next order:
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 and a customer email at a reserved test domain, such as
@example.com; complete the returnedcheckout_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 | An order create carried no shipping_address. On POST /v1/payment_intents this is a 400 before the charge. On hosted checkout, where the buyer's typed address normally fills it, a create that still has none fails at the store after the payment — the poll / webhook reports a terminal failed | Send shipping_address on every order create, or collect it on the hosted page with shipping: "required". |
mirror_sandbox_email_not_reserved | While testing, the store customer's email is not at a reserved test domain. Refused before the charge | Use an @example.com (or .test / .invalid / .localhost) address. See Testing. |
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. |