Skip to content

Transactions

Posting an order is how a member earns points, spends coupons and collects stamps. It is the highest-value call in the integration and the one with the most fields.


When the money is taken. Not when the cart is created, not when the order is placed pending payment.

If you post on order creation and the payment then fails, the member has points for a purchase that never happened, and you have to cancel. Posting after capture avoids the whole class of problem.

For a shop that captures on dispatch rather than on order, post on dispatch.


POST /api/v2.0/stores/{storeId}/members/{memberId}/transactions
Authorization: Bearer {token}
Content-Type: application/json
{
"external_id": "ORDER-1001",
"external_receipt_id": "REC-55021",
"created_at": "2026-09-10T12:30:00+02:00",
"currency": "NOK",
"total": 424.15,
"total_tax": 84.83,
"total_discount": 74.85,
"round_off_amount": 0,
"eft_pos": "Paid",
"send_email_receipt": false,
"payment_details": [
{ "payment_method": "Card", "sub_total": 424.15 }
],
"department_id": "101",
"origin": {
"system_id": "my-webshop",
"employee_id": null,
"department_id": "101",
"channel": "OnlineStore"
},
"coupon_codes": ["WELCOME15"],
"stamp_cards": [
{ "stamp_card_id": 12, "action": "collected", "count": 1 }
],
"details": [
{
"product": {
"external_id": "1042",
"sku": "SKU-123",
"name": "T-shirt, large",
"brand": "Acme",
"category": { "name": "Tops", "external_id": "cat-11" },
"gtin": { "type": "EanGtin13", "code": "7036772255075" }
},
"quantity": 1,
"unit_price": 499.00,
"unit_measure": "pcs",
"tax_percentage": 25,
"discount": 74.85,
"total_price": 424.15,
"coupon_codes": [],
"stamp_cards": []
}
]
}
{ "is_ok": true, "details": "" }

The status is 200 whether or not the order was recorded. Check is_ok — a duplicate, for one, comes back 200 with is_ok: false.

Posting the transaction also releases any coupon reservation made for this order. You do not need to call cancel-reservation afterwards.


FieldTypeNotes
external_idstringRequired. Your order id. Used later to cancel or refund, and to catch duplicates
external_receipt_idstringOptional receipt number
created_atdate-timeRequired. See Timestamps
currencystringNOK, SEK, EUR, USD, GBP
totalnumberAmount the customer actually paid, after discount, including tax
total_taxnumberTax included in total
total_discountnumberTotal discount given
round_off_amountnumberRounding adjustment; 0 if none
eft_posstringCard terminal text. Webshops typically use "Paid"
send_email_receiptboolLeave false unless the merchant has asked us to send receipts
payment_details[]arrayOne entry per tender. Must sum to total
department_idstringWhich department the sale belongs to
originobjectSet channel: "OnlineStore"
coupon_codes[]string[]Order-level coupons. Repeat a code for multiple redemptions
coupons[]arrayPoint-redemption coupons — see below
stamp_cards[]arrayStamps collected or deducted
details[]arrayRequired, non-empty. Line items
FieldTypeNotes
product.external_idstringYour product id
product.skustringThe SKU shared across channels. Set this — it is how we match products between your shop and the till
product.namestring
product.brandstringOptional
product.categoryobject{ name, external_id }
product.gtinobject{ type, code }, type is Isbn, UpcGtin12 or EanGtin13
quantitynumberFractional allowed, for weight
unit_pricenumberIncluding tax
unit_measurestringpcs, kg, …
tax_percentagenumber0.00–100.00. A 25% rate is 25, not 0.25
discountnumberDiscount on this line
total_pricenumberquantity x unit_price - discount

total_price is quantity times unit price, minus discount. Compute it rather than copying unit_price, or your line totals will not reconcile on multi-quantity lines.

One entry per tender. A split payment is several entries:

"payment_details": [
{ "payment_method": "Gift card", "sub_total": 100.00 },
{ "payment_method": "Card", "sub_total": 324.15 }
]

payment_method is free text and appears in merchant reporting — keep it consistent across your integration.


A transaction carries two coupon fields, and which one you use depends on the coupon type.

FieldShapeUse for
coupon_codes["WELCOME15"]Ordinary coupons — percentage, fixed amount, free shipping
coupons[{ "code": "PTS500", "redeemed_value": 50.00 }]Point-redemption coupons, where points are converted to money
"coupon_codes": ["WELCOME15"],
"coupons": [
{ "code": "PTS500", "redeemed_value": 50.00 }
]

redeemed_value is an amount of money, in the transaction’s currency — how much of the order the coupon paid for. It is not a number of points, and not a count of uses. We need it because a points-to-currency coupon has no fixed value; the customer chooses how much to redeem.

Both fields exist at order level and per line item, and both are optional — send only what applies.

For an ordinary coupon used twice, repeat the code rather than reaching for coupons:

"coupon_codes": ["STAMP5", "STAMP5"]

Send created_at with an explicit UTC offset.

"created_at": "2026-09-10T12:30:00+02:00"

or, if the value is genuinely UTC:

"created_at": "2026-09-10T10:30:00Z"

The field is a DateTimeOffset. When an offset is present the instant is unambiguous, and that is the end of the matter. Include the seconds — 12:30:00, not 12:30.

Off-by-a-few-hours timestamps are quietly destructive: campaign windows, “purchases this month” and expiry all shift, and nothing errors.


Reverses the whole transaction — points, coupons and stamps — and releases any coupon reservation still held for the order.

POST /api/v2.0/stores/{storeId}/transactions/{externalTransactionId}/cancel
{
"reason": "Customer cancelled the order",
"origin": {
"system_id": "my-webshop",
"department_id": "101",
"channel": "OnlineStore"
}
}

Use a refund for a partial return; use cancel for a full one.

POST /api/v2.0/stores/{storeId}/members/{memberId}/transactions/{transactionId}/refund

{transactionId} is the original order’s external_id — ORDER-1001 — the same value cancel takes. Unlike cancel, this path does include the member segment.

The body is the transaction shape with a reason added, and per-line coupon and stamp fields removed. Send only the lines being returned.

We set the refund’s own external_id for you, whatever the body says: the first refund of ORDER-1001 is recorded as ORDER-1001_refund1, the second as ORDER-1001_refund2, and so on. Do not try to invent your own.

{
"external_id": "ORDER-1001",
"reason": "Item returned, wrong size",
"created_at": "2026-09-12T09:15:00+02:00",
"currency": "NOK",
"total": 424.15,
"total_tax": 84.83,
"total_discount": 0,
"round_off_amount": 0,
"payment_details": [ { "payment_method": "Card", "sub_total": 424.15 } ],
"department_id": "101",
"origin": {
"system_id": "my-webshop",
"department_id": "101",
"channel": "OnlineStore"
},
"details": [
{
"product": { "sku": "SKU-123", "name": "T-shirt, large" },
"quantity": 1,
"unit_price": 499.00,
"tax_percentage": 25,
"discount": 74.85,
"total_price": 424.15
}
]
}

If the entire transaction needs to be refunded, use the cancel endpoint instead — it fully reverses the transaction, including coupons and stamp cards.

flowchart TD A["Order changes after<br/>the transaction was posted"] --> B{"Whole order?"} B -->|Yes| C["POST /stores/{storeId}/transactions/{external_id}/cancel"] B -->|No| D["POST /stores/{storeId}/members/{memberId}/<br/>transactions/{external_id}/refund<br/>with only the returned lines"] C --> E["Points, coupons and stamps<br/>fully reversed"] D --> F["Points adjusted.<br/>Coupons are NOT returned<br/>automatically"]

A partial refund does not give the coupon back. If the merchant wants that, it is a manual decision on their side — say so in your own support notes rather than assuming.


"stamp_cards": [
{ "stamp_card_id": 12, "action": "collected", "count": 1 }
]
FieldNotes
stamp_card_idFrom GET /stores/{storeId}/stamp-cards
action"collected" or "deducted"
countMust be positive

Works at order level and per line item.


We catch duplicate transactions for you. A transaction is a duplicate when its external_id, origin.department_id and origin.system_id all match one we already have. A duplicate is not recorded again; it comes back as:

{ "is_ok": false, "details": "You cannot add duplicate transaction." }

The status is 200, so the only signal is is_ok.

That makes retries safe. Order webhooks retry, and a handler that times out reading our response can simply post again: if the first attempt landed, the second is rejected as a duplicate and nothing is counted twice. Treat this exact details message as “already recorded” and move on.

Three rules keep it working:

  1. Make external_id deterministic from the order — ORDER-{id}. Never include a timestamp or random component, or every retry looks like a new order.
  2. Keep origin.department_id and origin.system_id fixed. They are part of the match. Change either between attempts and a retry is no longer a duplicate.
  3. Check is_ok on every response. A duplicate is a success for a retry, but a failure for a genuinely different order that reused an external_id.

An order from someone who is not a member has nowhere to go. Do not invent a member for it — that requires consent you do not have. Post nothing.

If your checkout offers loyalty signup at payment and the customer accepts, create the member first, then post the transaction against the new member id. Order of operations matters: no member, no transaction.


FieldRule
external_idRequired, non-empty
created_atRequired
detailsRequired, non-empty
originRequired

Failures return 400 with a validation error.