Integrations

Everything on this page lives under Settings → Integrations.

There is no app store here and no write API, and that is deliberate. What there is instead is the data: your bookkeeper, your accountant and your warehouse can read what they need and be told when something happens, without anyone installing anything.

Webhooks

A webhook tells another system when something happens in your shop. Add an endpoint, choose the events, and we POST a signed JSON body to it.

The events

EventWhen it fires
order/createdAn order exists — online, at the counter, or converted from a draft.
order/paidThe payment succeeded.
order/fulfilledA shipment went out.
order/refundedMoney went back, by refund or by chargeback.
inventory/adjustedStock changed for a reason other than a sale.
product/updatedA product or one of its variants changed.

This list is closed and it grows deliberately. Something you build against one of these names will keep working.

The body

{
  "id": "8f14e45f-ceea-467a-9a3e-1b6b2ff0e2b4",
  "event": "order/paid",
  "created_at": "2026-01-01T00:00:00.000Z",
  "shop_id": 7,
  "data": {
    "order_id": 41,
    "order_number": "#1001",
    "currency": "AUD",
    "financial_status": "paid",
    "fulfillment_status": "unfulfilled",
    "total": 14190,
    "total_refunded": 0,
    "customer_id": 3,
    "placed_at": "2026-01-01T00:00:00.000Z"
  }
}

Amounts are in cents, always, everywhere. 14190 is $141.90.

Payloads carry ids, amounts and states — never contact details, addresses or payment details. If your integration needs a customer's email or a delivery address, read it from the API with a token scoped to say so. A webhook is a POST to a URL somebody typed into a form; an API request is authenticated and logged.

Verifying the signature

Every delivery carries three headers:

HeaderWhat it is
X-UniShopping-Signaturet=<unix seconds>,v1=<hex>
X-UniShopping-Event-IdThe id from the body. Dedupe on this.
X-UniShopping-EventThe event name, so you can route without parsing.

The signature is an HMAC-SHA256 over `${t}.${rawBody}`, keyed on the SHA-256 of your signing secret:

import { createHash, createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => part.trim().split("=")),
  );

  // Reject anything too old, so a captured delivery cannot be replayed.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (age > toleranceSeconds) return false;

  const key = createHash("sha256").update(secret).digest();
  const expected = createHmac("sha256", key)
    .update(`${parts.t}.${rawBody}`)
    .digest();

  const given = Buffer.from(parts.v1, "hex");

  return (
    given.length === expected.length && timingSafeEqual(expected, given)
  );
}

Sign the raw body bytes, before any JSON parsing. A re-serialised body will not match.

Compare in constant time — === on an HMAC leaks how much of it was right through how long the comparison took.

Delivery, retries and duplicates

Delivery is at-least-once. Answer with any 2xx as soon as you have accepted the event; do your work afterwards. A non-2xx, a redirect, or no answer within five seconds counts as a failure.

Failures are retried with a growing delay — ten seconds, then twenty, doubling up to six hours — and given up on after eight attempts. A subscription that keeps failing is marked, and you can see every attempt and its response code on the Integrations screen.

You will occasionally receive the same event twice. Dedupe on X-UniShopping-Event-Id, which is stable across every retry of one event. Exactly-once across a network nobody controls is not on offer, and a subscriber that assumes it will silently drop a retry that mattered.

The secret

Your signing secret is shown once, when you create the endpoint. It is stored hashed, so we cannot show it to you again — if you lose it, rotate it, which issues a new one and invalidates the old.

The read API

A token authenticates a machine to one shop, and it can only read.

Authorization: Bearer ust_live_…

Tokens are read-only. Not "we only wrote read endpoints" — the layer that reaches the database refuses a write whatever asks for it, so a route added in a year's time cannot quietly become writable to a token you issued today.

A token for one shop gets a 404 on another shop's data, exactly as a person would.

Scopes

ScopeWhat it reads
orders:readOrders, lines, payments and fulfilments.
products:readProducts, variants, prices and barcodes.
inventory:readStock on hand and available, per location.
customers:readNames, email addresses and order history.

customers:read is the one that carries personal data. A courier does not need it. Grant it only to something that has to reach your customers.

Cost prices are never exposed, on any scope.

Endpoints

MethodPath
GET/api/v1/orders
GET/api/v1/orders/{id}
GET/api/v1/products
GET/api/v1/inventory
GET/api/v1/customers

/api/v1/orders accepts updated_since, financial_status and fulfillment_status. /api/v1/products accepts status. /api/v1/inventory accepts location_id.

An order's email appears only when the token holds customers:read. Without it the field is absent, not null.

Pagination

Every list is cursor paginated:

{
  "data": [ … ],
  "next_cursor": "djE6NDIxMA",
  "has_more": true
}

Pass cursor to get the next page, and limit (default 50, maximum 250) to change the size. Keep following next_cursor until has_more is false.

Cursors are stable while your shop is still trading: a row created between two of your requests will not shift the window, so nothing is skipped or repeated. Do not use offsets — there aren't any.

Rate limits and errors

Each token may make 120 requests a minute. Over that you get 429 with a Retry-After header.

StatusMeaning
400The cursor was not one we issued.
401No token, or a token that is unknown, revoked or expired.
403The token does not hold the scope this endpoint needs. The body names it.
404No such resource — including one belonging to another shop.
429Too many requests.

Revoking a token takes effect on the next request. There is no cache.

The accounting export

Settings → Integrations → Accounting export produces one CSV of invoice, payment, refund and fee lines for a period, with GST split out per line and the platform fee on a line of its own.

It reads the same transaction ledger your orders are built from, which is why it agrees with them to the cent. Before you download it, the screen tells you whether it reconciles — and if it does not, by how much.

ColumnNotes
line typeinvoice, shipping, rounding, payment, refund, chargeback, chargeback_reversed, chargeback_lost, fee, unclassified.
account codeA sensible default. Remap the column to your own chart of accounts.
tax type / tax codeXero's name and MYOB's code for the same thing.
gst modeWhether your shop's prices include GST.
sourceThe order or ledger row the line came from.

Two of those line types exist to keep the file honest:

  • rounding — where an invoice's lines do not sum to the order's own total, the difference is a line rather than being pushed into the last product.
  • unclassified — a ledger entry the export did not recognise. It is shown rather than dropped, and it makes the reconciliation report as not balanced, because a total that agrees only because something was ignored is worse than one that plainly does not.

A lost chargeback appears with a zero amount. The money left when the dispute opened; counting it again would take it off twice.

Direct Xero and MYOB synchronisation is not available, and is not planned. The export proves the shape first.

Payout reconciliation

This payout, these orders, these fees, this refund.

The payout report matches a Stripe payout back to your orders and shows what made it up: sales, refunds, chargebacks, Stripe's fees and our application fee.

The components sum exactly to the payout. Anything that cannot be explained appears as an unreconciled line rather than being absorbed — if you see one, the report is telling you something is genuinely unaccounted for, and it is worth a call to Stripe rather than a shrug.

Our application fee is shown as reported, not reconciled. On a direct charge it is taken at our end and never passes through your Stripe balance, so it tells you what the platform cost you without being part of the payout arithmetic.