Skip to content
Documentation

Orders come from your server.

A browser can be made to say any number, so revenue travels server to server: one endpoint, one key, and the field names your shop already computes for GA4.

Why server to server

Anything the browser sends, the browser can be made to lie about, and revenue is the number least worth guessing. Orders therefore reach Kehai through a server integration after payment confirmation: from your shop's backend or a supported commerce connection.

Choose one source: your existing commerce API integration or a supported connection in Settings → Integrations. If your server or WooCommerce already sends confirmed sales and refunds, no additional Stripe connection is needed. The built-in connections map supported confirmed payments and refunds and expose delivery status. A custom integration must provide its own durable queue and monitoring; the reference below shows that pattern for Node and PostgreSQL.

After a one-time Stripe setup, supported Checkout payments, subscription payments and renewals, and confirmed refunds appear automatically in the ecommerce reports. There is no manual invoice import or accounting feature. See the Stripe instructions and supported payment limits.

Can I just tag the thank-you page?

Yes. If you are coming from GA4, Matomo, Plausible, or Umami, the browser-side purchase event you already know works here too, through the same snippet as everything else, and for a lot of shops it is all they need. What differs is where each number is allowed to land, so pick the path that matches how much you will lean on it.

The simple path: one line on the confirmation page

on the thank-you page
kehai('purchase', {}, { amount: 224.00, currency: 'EUR' })

Create an Event goal named purchase and the Goals screen gives you daily completions, converters, a conversion rate, and the amounts summed per currency. No backend, no key, nothing else to wire. Add to cart and checkout steps along the way are ordinary custom events, and a Page goal on /thank-you works with no code at all.

Be clear about what this path does not do: it never touches the ecommerce reports. Side by side:

Browser eventServer order
shows up inThe Events report and the Goals screenRevenue, Products, Coupons, and revenue on Channels
ecommerce reportsNothing, ever. A browser amount never enters themEverything on this page
carriesOne amount and a currencyItems, coupons, tax, shipping, refunds, and the visit context
missesWhatever content blockers ate, plus anything a console typed inRequires verified payment/refund hooks, durable retries, and monitoring; delivery can still fail

The accurate path: the rest of this page

The Revenue, Products, and Coupons reports read orders delivered through your selected server integration. Two structural reasons. A number the browser sends is a number anyone with a console can send, and revenue is the figure you make decisions with. Browser blocking, a closed tab, and repeated confirmation-page visits can affect browser events. A backend integration can record asynchronous payments after the tab closes, provided your payment handler accepts those payment methods and retries failed deliveries.

When to graduate

Start on the simple path if it gets you moving. The day revenue starts steering spend, or you want products, coupons, taxes, and refunds accounted for, move the number to the server: browser events and server orders feed separate reports. Choose one source for each goal; do not add both reports together as revenue.

The request

For a custom sender, create a key with the commerce scope on the panel's API keys screen. While a commerce connection is active, only its dedicated key can deliver commerce for the site; disconnect it before switching to your custom sender. Then, once per completed order:

POST /api/v1/commerce
curl -X POST "https://stats.kehai.io/api/v1/commerce" \
  -H "Authorization: Bearer $KEHAI_COMMERCE_KEY" \
  -H "Idempotency-Key: 5f0c2f0a-6b1e-4c67-9be6-2f4bfe0f2a11" \
  -H "Content-Type: application/json" \
  -d '{
    "site": "YOUR_SITE_ID",
    "currency": "EUR",
    "net_amount": 224.00,
    "tax_amount": 51.52,
    "shipping_amount": 12.00,
    "discount_amount": 25.00,
    "coupon": "SUMMER25",
    "country": "PL",
    "landing_path": "/coffee?utm_source=newsletter&utm_medium=email",
    "referrer": "https://search.example/results",
    "items": [
      { "item_id": "TSHIRT-M", "item_name": "T-shirt",
        "price": 249.00, "quantity": 1, "discount": 25.00 }
    ]
  }'
HeaderWhat it does
AuthorizationrequiredBearer plus a key with the commerce or full scope. The key names one site. Nothing else can choose where the money lands.
Idempotency-KeyrequiredA random UUID your shop stores beside the order, never the order number itself. It makes retries safe. See retries.
Content-Typerequiredapplication/json

The body repeats your site's public id on purpose: a key wired to the wrong shop is refused with 403 instead of writing revenue into someone else's reports. An accepted order answers 202. A rejected one answers 422 with the reason spelled out. Validate the response and keep refused deliveries for review.

For Google Ads conversion exports, also send occurred_at: the actual sale time in ISO 8601 with seconds and an explicit timezone, for example 2026-09-12T14:35:00Z. It is optional for ordinary commerce reporting, but missing or null values cannot be replaced with the collection time for an Ads export. Kehai generates a separate stable conversion identifier; do not send conversion_id or ingest_id yourself.

Durable delivery from your backend

Save a frozen payload and a random UUID in the same database transaction that records the confirmed payment. The local event key must be unique: one for each purchase and a different one for each partial refund. Order and refund IDs stay in your database; only the random UUID is sent to Kehai.

Download the Node/PostgreSQL outbox reference. It includes the schema, an idempotent enqueue function, a worker with leases for concurrent processes, bounded backoff, and a review state for refusals or expired retry windows. It requires the pg package and your own payment-handler transaction. Run the worker on a schedule and alert when rows need review.

inside your verified payment handler
// client is the pg client holding your payment transaction.
// Record the payment and enqueue before COMMIT; ROLLBACK both on failure.
await enqueue(client, 'purchase:' + order.id, payload);
// For a partial refund, persist only the refunded amounts and quantities:
await enqueue(client, 'refund:' + refund.id, refundPayload);
// In a separate scheduled worker, with a pg Pool:
while (await deliverOne(pool, process.env.KEHAI_COMMERCE_KEY)) {}
// Monitor SELECT * FROM kehai_outbox WHERE state = 'review'.

Use the same API key for all retries of pending rows. Changing it changes the collector's deduplication scope. Reconcile ambiguous deliveries before rotating the key or retrying outside the 48-hour window.

WooCommerce field mapping

This maps an order's line items; it is not a complete WooCommerce plugin or a delivery hook. Connect it to the payment/refund events that match your shop's payment methods, and store the result in a durable queue before sending. Repeated status transitions must reuse the original delivery record.

WooCommerce line mapping, excluding tax
$items = [];
foreach ($order->get_items() as $item) {
  $product = $item->get_product();
  $items[] = [
    'item_id' => $product ? $product->get_sku() : '',
    'item_name' => $item->get_name(),
    'price' => (float) $order->get_item_subtotal($item, false),
    'quantity' => $item->get_quantity(),
    'discount' => (float) ($item->get_subtotal() - $item->get_total()),
  ];
}
$codes = $order->get_coupon_codes();
// Kehai has one coupon dimension. Omit it when several codes were redeemed.
$coupon = count($codes) === 1 ? $codes[0] : '';
// net_amount is the sum of the items' get_total(), excluding tax and shipping.
// Persist payload + UUID once; never call wp_generate_uuid4() per retry.

Do not join several coupon codes into one string: the Coupons report would treat that combination as one campaign code. Keep full coupon detail in your shop. WooCommerce item reference.

The money

The one number shops get wrong

net_amount is the item total after discounts, excluding tax and shipping: the number GA4 calls value, which your shop already computes. Sending the grand total instead inflates revenue by the tax rate, quietly, in every report built on it, and it is the mistake the validation below exists to catch.

FieldMeaning
net_amountrequiredSum of item price times quantity, after discounts. Excludes tax and shipping.
currencyrequiredISO 4217, three letters. Unlike currencies never enter one sum, and Kehai converts nothing.
payment_typeoptionalone_time, subscription_initial, subscription_renewal, subscription_change, or unknown. Missing or null means unknown. Initial means the first positive payment for that subscription, not necessarily a new customer. This allows a narrower Google Ads export without removing other payments from revenue.
tax_amountoptionalTax on the order, VAT included.
shipping_amountoptionalDelivery charged to the customer.
discount_amountoptionalWhat the coupon took off the order, before tax. Already excluded from net_amount, never subtracted again. It exists so a code's cost can sit beside what it earned.
couponoptionalThe code the customer redeemed. A campaign code, never a per-customer one.
countryoptionalTwo letters. Anything else is stored as unknown rather than truncated, because truncating Germany gives Georgia.

Gross is derived on read as net plus tax plus shipping and is never sent, so the parts and the total cannot disagree. Amounts are JSON numbers, four decimal places at most, under one billion in absolute value.

When every item carries a price and a quantity, the collector checks that price times quantity less discounts reconciles with net_amount, within a cent per line. This catches many net/gross mapping mistakes when complete item data is supplied; it does not verify the payment against your shop.

Items

Up to 200 per order. Item names follow GA4, but price and discount arithmetic differ. Apply the adapter below before reusing a GA4 purchase payload:

FieldMeaning
item_idYour SKU or product code
item_nameThe product's name
item_brandBrand, if you track one
item_categoryCategory, if you track one
item_variantVariant, like a size or color
pricePrice per unit before the item discount, excluding tax and shipping
quantitySend an explicit whole number. Negative on a refund; omission does not default to one in Kehai
discountTotal discount for the entire line, before tax; negative on a refund

Adapting GA4 purchase items

GA4 uses the discounted unit price and a per-unit discount; omitted quantity defaults to one. Kehai's existing contract uses the original unit price and the discount for the whole line. For a GA4 item priced at 90 after a discount of 10, quantity 2, send price 100, quantity 2, discount 20, and net_amount 180.

GA4 purchase item adapter, included in the download
const quantity = ga4Item.quantity ?? 1;
const item = {
  item_id: ga4Item.item_id,
  item_name: ga4Item.item_name,
  price: ga4Item.price + (ga4Item.discount ?? 0),
  quantity,
  discount: (ga4Item.discount ?? 0) * quantity,
};
// The downloadable fromGa4Item validates inputs and rounds to four decimals.
// Set net_amount to GA4 value; do not subtract the discount again.

Use only the documented fields, rather than spreading the original GA4 event into a Kehai payload. Transaction IDs and customer fields are refused. Google's purchase and item definitions.

Attribution: your session, your evidence

Kehai never joins an order to a browser event, and accepts no visitor, session, customer, or transaction identifier to make that join. Continuity between landing and purchase is the shop's: record the visitor's first site-relative URL and referrer in your own session when the visit starts, and send them with the order.

FieldMeaning
landing_pathSite relative, starting with /, query included. Campaign parameters in it are read with the same filters browser events get
referrerMay be a full URL. Only its hostname is kept
utm_source, utm_medium, utm_campaignOptional explicit values, which win over ones derived from the landing address

An order sent without landing_path is valid, and its revenue reports as unattributed: empty context is not evidence of direct traffic. This is what feeds Revenue by channel and campaign. Skip it and the money still counts, but it cannot say where it came from.

Google Ads exports additionally need the original gclid in that landing URL. Keep its value unchanged and check your site's query-parameter exclusions. Kehai does not infer an order's click ID from browser events or recover an earlier presence-only marker. See conversion metadata for the complete requirements.

Refunds

A refund is the same event with negative money and negative quantities. It carries no reference to the original order. Repeat the original order's landing context, so the refund reduces the same channel the sale once raised. Refunds are excluded from the Google Ads sales export and do not automatically adjust previously imported conversions.

a refund is a negative order
{
  "site": "YOUR_SITE_ID",
  "currency": "EUR",
  "net_amount": -224.00,
  "tax_amount": -51.52,
  "discount_amount": -25.00,
  "landing_path": "/coffee?utm_source=newsletter&utm_medium=email",
  "items": [
    { "item_id": "TSHIRT-M", "price": 249.00,
      "quantity": -1, "discount": -25.00 }
  ]
}

For a partial refund send only the refunded quantities, net amount, tax, shipping, and reversed discount. Give every refund its own persisted UUID; repeated delivery of that refund reuses it. Keep unit prices positive and quantities negative. The payload does not identify its original order, so Kehai cannot prevent over-refunds or reconcile an order ledger.

Negative throughout, not in parts. An order whose money fields disagree on direction is refused: it is neither a sale nor a refund, and every total built on it would be wrong invisibly.

Refused, by name

Do not send personal data. The collector rejects known identifying field names and checks selected text values, but these checks cannot recognize every identifier inside a product code, URL, or free-text value. Your integration must minimize the payload before sending.

SentWhat happens
transaction_id, order_id, customer_id, emailThe whole order is refused. An order number identifies a person inside your systems
a value matching an identifying pattern in coupon, item_name, item_brand, item_category, or item_variantThe whole order is refused, with the field named
amount, tax, shipping, discountThe pre-September-2026 names. Refused with the replacement named, because amount never said whether the tax was inside it
sku, name, unit_price, and the other old item namesRefused. The error names the GA4 replacement for each

Retries

Reuse the same API key, idempotency key, and frozen payload when retrying. A completed claim suppresses duplicates for 48 hours. An unconfirmed write also depends on ClickHouse retaining its deduplication token among the latest 1,000 inserted blocks; after that token is evicted, a retry can duplicate an order even inside 48 hours. Retries keep the first collection timestamp across month boundaries, but this does not extend the token window. Reconcile older uncertain deliveries rather than treating the claim lifetime as a guarantee.

AnswerMeaning
202 acceptedThe order is durably stored
200 duplicateThis key already stored this payload. Nothing was written twice
409 pendingA request with this key is still in flight. Retry after the moment the retry-after header names
409 conflictThis key was already used with a different payload. Stop and review the frozen delivery record; do not automatically mint another key
429Rate limited. Keep the same key and body; respect Retry-After
400 / 401 / 403 / 404 / 422Review configuration or payload before retrying. Do not retry unchanged forever
503Storage did not confirm. The claim is preserved. Retry the same request

The reference stops automatic retries after 47 hours from the first attempt, leaving margin inside the 48-hour claim. After that, review the source event and delivery evidence before resending: an unknown result is not proof that nothing was stored.

Where the numbers land

Filter orders by country, channel, any of the five UTM values, referring domain, product, SKU, or coupon. Product and SKU select orders containing that item, and both must match the same item when combined. Revenue includes the complete selected baskets; Products also shows their other items. The panel labels this scope explicitly. Browser traffic cannot be joined to these orders, so Channels and Campaigns leave visits and conversion unavailable under an order filter.

Revenue, Products, and Coupons read these rows, and Channels groups net revenue by the same rules as traffic. Every figure is net of refunds and grouped per currency. The share of orders with a code, on Coupons and on the Overview box, is a share of sales: refunds reduce the money but never join the denominator. Raw rows leave completely through the export, so nothing a report does not surface is trapped. An order goal counts these rows too. See goals.

One rule reaches these screens from Settings. An order records the country, the campaign, and the referring site it came from, and nothing about a page, a browser, a device, or a system. An exclusion rule on any of those four therefore cannot be applied to money, and the three screens, the Overview commerce boxes, and the revenue column on Channels say so, naming the rule, rather than showing a total the rule was meant to change.