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
| Event | When it fires |
|---|---|
order/created | An order exists — online, at the counter, or converted from a draft. |
order/paid | The payment succeeded. |
order/fulfilled | A shipment went out. |
order/refunded | Money went back, by refund or by chargeback. |
inventory/adjusted | Stock changed for a reason other than a sale. |
product/updated | A 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:
| Header | What it is |
|---|---|
X-UniShopping-Signature | t=<unix seconds>,v1=<hex> |
X-UniShopping-Event-Id | The id from the body. Dedupe on this. |
X-UniShopping-Event | The 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
| Scope | What it reads |
|---|---|
orders:read | Orders, lines, payments and fulfilments. |
products:read | Products, variants, prices and barcodes. |
inventory:read | Stock on hand and available, per location. |
customers:read | Names, 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
| Method | Path |
|---|---|
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.
| Status | Meaning |
|---|---|
400 | The cursor was not one we issued. |
401 | No token, or a token that is unknown, revoked or expired. |
403 | The token does not hold the scope this endpoint needs. The body names it. |
404 | No such resource — including one belonging to another shop. |
429 | Too 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.
| Column | Notes |
|---|---|
line type | invoice, shipping, rounding, payment, refund, chargeback, chargeback_reversed, chargeback_lost, fee, unclassified. |
account code | A sensible default. Remap the column to your own chart of accounts. |
tax type / tax code | Xero's name and MYOB's code for the same thing. |
gst mode | Whether your shop's prices include GST. |
source | The 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.