Skip to main content
This guide covers the write half of the Transactions API: recording a transaction your own system processed, such as an order taken on your e-commerce site, so it shows up in Vori reporting and back office alongside in-store sales. See Download transaction data if you want to download transactions Vori already recorded.

Authenticate with an API key

See the authentication docs for how to create an API key. Make sure the role you assign it can write transactions.

Create an order

POST /v1/transactions records one completed transaction, including its line items, payments, and totals, against a store. A minimal order for a single product paid by card looks like this:
A few things about the shape:
  • Line items are keyed by store_product_id, the ID of the product in your Vori catalog, not by barcode. store_product_id is the ID that matters here: if you only have a barcode, resolve it to a store_product_id against the store products endpoint before submitting, since a barcode by itself isn’t accepted as a line item key.
  • Each line’s total must equal (retail_price x quantity) plus promo_savings, discount_total, any item_modifiers amounts, and that line’s tax_total. Savings are signed: send promo_savings and discount_total as negative amounts, so adding them is what reduces the line.
  • For a product your catalog sells by weight, the line prices off (retail_price x weight) instead: send quantity as 1, send the measured weight, and set retail_price to the price per unit of weight.
  • At the transaction level, tax_total must equal the sum of the line-item tax totals, total must equal the sum of the line totals, and the payments must add up to total.
  • Every one of these is checked rather than accepted as reported, and a mismatch is rejected rather than silently corrected.
  • Payments are recorded as given: payment_type, amount, and your processor’s own reference in external_transaction_id are stored so your systems can look the payment up later, but none of it is interpreted or re-verified against a processor.

Idempotency

Generate id once per order, before your first attempt, and reuse that same value on every retry. Minting a fresh id per attempt breaks idempotency and can create duplicate transactions.
id is a UUIDv7 you generate, and it is the idempotency key for the request. Generate it once per order, before your first attempt, and reuse that same value on every retry. Sending the same id again returns the transaction already recorded rather than creating a duplicate. If you mint a new id every time you retry, a retry after a dropped response creates a second transaction instead of deduplicating against the first. The API can only recognize a replay if the replay carries the same key the original attempt did. id is unique globally, but external_id (your own order number, like an e-commerce order ID) is scoped to your banner and is not required to be unique. Use it for lookups and reporting; use id for retry safety.

Conflicts

Sending the same id twice can resolve two ways:
  1. Identical request. Every compared field matches what was recorded the first time: same store, lane, employee, shopper, totals, line items, and payments. The endpoint returns the transaction already on file with a normal success response, not an error. This is the safe case to build retries around: on a timeout or a dropped connection, resend the exact same payload under the same id and treat a success response as confirmation, whether or not the first attempt actually landed.
  2. Same id, different values. The request names a transaction already recorded but disagrees with it on some field, such as a different total, a different line item, or a different payment. This returns a 409 Conflict error that names which fields don’t match, meaning the transaction is already recorded and what you just sent doesn’t describe it. This isn’t a transient failure, so retrying the same divergent payload will just keep returning a 409. If you see one outside of a deliberate retry, it means id was reused across two different orders in your own system, which is a bug on the calling side to fix rather than a condition to retry through.

Virtual lanes and employees

store_id is the only attribution field this endpoint requires. Vori automatically creates and maintains one virtual lane (per store) and one virtual employee (per banner) the first time an order is submitted through this API, and attributes the transaction to them when you don’t specify lane_id or employee_id yourself. These virtual records aren’t provisioned like a physical lane or a real staff member. There’s no terminal or login behind them, and they are not editable. If you do pass lane_id or employee_id explicitly, it has to be the store’s virtual lane or virtual employee; naming a staffed lane or a real employee is rejected, since this API isn’t a substitute for completing a sale on an actual terminal. Filter or group by lane_id in your own reporting (or in Vori’s) to separate transactions your system submitted from everything completed in-store. Every API-originated order carries the same virtual lane and employee, so this split is reliable.

Assumptions and caveats

  • No promotion or rewards math yet. Vori doesn’t compute promotions, discounts, or loyalty rewards for transactions submitted this way. promo_savings, discount_total, and any per-line fees are recorded exactly as you send them, with no independent calculation or validation against Vori’s own offer or loyalty configuration. Support for applying Vori promotions and loyalty rewards to API-submitted orders is planned; until then, don’t use promotions or loyalty rewards with orders submitted through this API.
  • metadata is create-only. Whatever key/value pairs you attach at creation are stored and returned unchanged, but the transaction is otherwise immutable once recorded. There’s no update endpoint to revise metadata, or anything else, after the fact. Constraints:
    • Up to 50 keys.
    • Each key is 1-40 characters: letters, numbers, underscores, and hyphens.
    • Values must be strings, up to 500 characters.
    • Keys starting with vori are reserved for Vori’s own use and are rejected if you try to write them.
  • Totals are enforced, not inferred. As above, get the line-item and transaction-level math right on your side before submitting; the endpoint checks consistency rather than deriving one figure from the others.

Refund a transaction

A transaction can only be refunded if it’s a completed sale that was itself created through this API. Attempts to refund in-store transactions or non-sale transactions will be rejected.
POST /v1/transactions/{id}/refunds records a refund your own system processed against a transaction Vori already recorded, and returns it as a transaction of its own.
A few things about the shape:
  • The request mirrors what a refund reads back: quantities, weights, and the money coming back — taxable_amount, tax_total, each line total, the transaction totals, and every payment amount — are negative.
  • Three fields stay positive: retail_price, because it is the price the item sold at rather than an amount moving; and promo_savings and discount_total, because they reverse the negative savings recorded on the original sale.
  • Each line and payment names the one it reverses (transaction_line_item_id, transaction_payment_id). Retrieve the original transaction to read those IDs.
  • A line cannot return more than earlier refunds left it, checked component by component (quantity or weight, total, taxable amount, tax, promo savings, discount, and each fee or deposit), not just against the line’s overall total. A payment cannot return more than it has left either.
  • retail_price on a refund line must match the price the product actually sold at; a refund cannot restate it.
  • Fees and deposits are refunded as they were charged on the original line, by item_modifier_id, even if the fee has since been renamed or removed from your catalog.
  • id is a UUIDv7 and works the same idempotency way as on the create-transaction endpoint: generate it once per refund, before your first attempt, and reuse it on every retry. Sending the same refund again under that ID returns the refund already recorded; sending different values under it is rejected.