Skip to main content

Orders

Read the orders a site's store has taken, so you can push them into accounting, fulfillment, or a warehouse system — and record the shipment when your warehouse, 3PL or label printer sends the parcel.

That one write is the only one. Creating, cancelling or refunding an order moves money or stock, and those stay in the console and the storefront.

Orders belong to a site, not to the organization, because each site runs its own store with its own numbering. A multi-site organization reads each store separately.

Requires commerce on your plan

These endpoints need the orders:read scope (or orders:write to record a shipment) and a plan that includes commerce. If your plan doesn't, they answer 403 plan_required with code: "commerce" — see Errors.

The order object

{
"id": "8Kd0zX2mQ1",
"object": "order",
"number": 1042,
"status": "paid",
"channel": "online",
"currency": "usd",
"customerEmail": "shopper@example.com",
"customerName": "Avery Chen",
"lineItems": [
{
"productId": "p_sourdough",
"variantId": "v_large",
"name": "Sourdough",
"variantLabel": "Large",
"sku": "SD-L",
"productType": "physical",
"quantity": 2,
"unitAmountCents": 900
}
],
"totals": {
"itemsCents": 1800,
"shippingCents": 500,
"taxCents": 190,
"discountCents": 200,
"totalCents": 2290,
"feeCents": 45
},
"refundedCents": 0,
"disputed": false,
"shippingAddress": {
"name": "Avery Chen",
"line1": "500 Main St",
"city": "Austin",
"state": "TX",
"postalCode": "78701",
"country": "US"
},
"couponCode": null,
"fulfillments": [
{
"id": "f_8c21",
"lineItemIds": [0, 1],
"carrier": "USPS",
"trackingNumber": "9400111899223197428490",
"trackingUrl": null,
"at": "2026-08-15T14:20:00.000Z"
}
],
"created": "2026-08-14T18:02:11.400Z"
}
FieldTypeNotes
idstringOrder id — use it in the paths below. Not the same as number.
objectstringAlways "order".
numberinteger | nullThe human order number, sequential per site — what appears on the receipt and what a customer will quote at you.
statusstringSee statuses.
channelstringWhere the sale came from — see channels.
currencystringAlways "usd" today. Present so a client doesn't have to hard-code it.
customerEmailstring | nullThe join key to a contact — orders carry no contact id.
customerNamestring | nullAbsent on POS and draft orders.
lineItemsarraySee line items.
totalsobjectSee totals. All integer cents.
refundedCentsintegerMoney already returned, for any reason. A lost chargeback lands here too, so a non-zero value doesn't by itself mean the merchant chose to refund — check disputed.
disputedbooleanWhether a card dispute has ever been recorded against this order.
shippingAddressobject | nullPresent on orders that collected one. Digital and POS orders usually have none.
couponCodestring | nullThe discount code the shopper used, if any.
fulfillmentsarrayShipments recorded against this order — see fulfillments. Always present; [] on an order nothing has shipped for.
createdstring | nullISO 8601. For a subscription renewal this is the period start Stripe billed for, not the moment the row was written — which is what makes a revenue report line up with the invoice.

Statuses

statusMeans
pendingCreated, payment not settled. A POS card sale or an unpaid draft sits here.
paidPaid, nothing shipped yet.
partially_fulfilledSome line items have shipped.
fulfilledEverything has shipped.
deliveredConfirmed delivered.
cancelledCancelled; stock returned.
refundedRefunded — check refundedCents for how much, and disputed for why.

refunded and cancelled are terminal. Everything else can still move.

Fulfillments

status tells you an order shipped. fulfillments tells you what shipped, when, and under whose tracking number — which is the part a 3PL or accounting reconcile needs, and the part a split shipment makes essential: two shipments against one order are two entries here and one unchanged status.

FieldTypeNotes
idstring | nullShipment id, unique within the order.
lineItemIdsarrayIndexes into lineItems, not product or variant ids. [0, 1] means the first two line items on this order.
carrierstring | nullAs recorded by whoever shipped it. Free text, not a fixed list.
trackingNumberstring | nullFree text too — validate it against the carrier yourself.
trackingUrlstring | nullSet only when the shipper recorded one. Usually null; build your own from the carrier and number.
atstring | nullISO 8601, like every other time on this object. null if the stored timestamp is unusable.

To add one, record a shipment.

What isn't here

orders:write records shipments — that is, it moves an order forward to fulfilled or delivered and attaches a carrier and tracking number. It cannot do anything else to an order.

ActionOver the API?Why
Mark fulfilled / deliveredYes, orders:writeA forward status change and a timeline entry. No stock moves, no money moves.
Cancel an orderNo — consoleCancelling returns held stock, under its own transaction.
Refund an orderNo — consoleA refund moves money, under its own transaction.
Create an orderNo — storefront, POS or a console draftAn order is created by a payment, not by a status.

Asking for status: "cancelled" or "refunded" here answers 400, naming which of the two it is rather than silently doing nothing.

Channels

channelMeans
onlineThe storefront — a cart checkout or a buy-now button.
posRung up on a point-of-sale register.
draftA draft order you built in the console and sent as a payment link.
subscriptionA recurring renewal. One order per billing cycle.
online is a default, not a stored value

Orders taken before Aglyn had multiple sales channels carry no channel field at all. The API reports them as online, and ?channel=online does return them — but it filters after reading rather than in the query, so a page can come back with fewer rows than limit while has_more is still true. That's normal here; follow the pagination rule and trust has_more, never a page's length.

Line items

{
"productId": "p_sourdough",
"variantId": "v_large",
"name": "Sourdough",
"variantLabel": "Large",
"sku": "SD-L",
"productType": "physical",
"quantity": 2,
"unitAmountCents": 900
}

Line items are a snapshot taken at purchase. name, sku and unitAmountCents are what the shopper actually saw and paid — renaming or repricing the product afterwards never rewrites a past order, which is what makes an order safe to book as revenue. productId still points at the live product, so a sold item can be looked up; it may since have been deleted.

variantId is omitted when the product has only its default variant.

Totals

All values are integer cents, never floats.

FieldNotes
itemsCentsSum of line items before shipping, tax and discount.
shippingCentsShipping charged.
taxCentsTax charged.
discountCentsDiscount applied — a positive number that is subtracted.
totalCentsWhat the shopper paid: itemsCents + shippingCents + taxCents − discountCents.
feeCentsAglyn's platform fee.
feeCents is not part of the total

feeCents is Aglyn's cut of a total the shopper paid in full. It is not added to totalCents and not subtracted from it. If you're computing what landed in your bank account, that's roughly totalCents − feeCents − refundedCents minus Stripe's own processing fee; if you're computing what you sold, it's totalCents. Netting the fee out of revenue is the common mistake and it understates every order.

Very old orders (from the first version of Aglyn commerce) stored only a flat total and fee rather than a breakdown. The API fills their totals from those fields, so you always receive the same shape — but their itemsCents, taxCents and shippingCents are 0 and the money is all in totalCents. If you need the split, it isn't recoverable; those orders predate its being recorded.

Endpoints

List orders

GET /v1/sites/{siteId}/orders — scope orders:read. Paginated, ordered by order id, not by date or by number.

ParamNotes
statusFilter to one status, exact match.
channelFilter to one channel, exact match. See the caution above about online.
limit, cursorStandard pagination.
curl "https://app.aglyn.com/api/v1/sites/host_demo/orders?status=paid" \
-H "Authorization: Bearer aglyn_sk_…"
{
"object": "list",
"data": [ /* order objects */ ],
"next_cursor": "b3JkXzE",
"has_more": true
}

Retrieve an order

GET /v1/sites/{siteId}/orders/{orderId} — scope orders:read.

The path takes the order id, not the human number on the receipt. There is no lookup by number; if you need one, page the list once and build the map yourself.

curl "https://app.aglyn.com/api/v1/sites/host_demo/orders/8Kd0zX2mQ1" \
-H "Authorization: Bearer aglyn_sk_…"

Record a shipment

PATCH /v1/sites/{siteId}/orders/{orderId} — scope orders:write.

This is the fulfilment write: it moves the order forward and appends a fulfillment carrying the carrier and tracking number. It returns the full order object, so you can see the shipment you just recorded without a second request.

FieldTypeNotes
statusstringRequired. "fulfilled" or "delivered". Nothing else — see what isn't here.
carrierstringOptional. Free text, e.g. "UPS". Trimmed to 40 characters.
trackingNumberstringOptional. Free text. Trimmed to 60 characters.

Any other field in the body is refused by name, never ignored — so a typo like tracking_number comes back as a 400 telling you which key it didn't recognise rather than a 200 that quietly dropped half your shipment.

curl -X PATCH "https://app.aglyn.com/api/v1/sites/host_demo/orders/8Kd0zX2mQ1" \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Content-Type: application/json" \
-d '{"status":"fulfilled","carrier":"UPS","trackingNumber":"1Z999AA10123456784"}'
{
"id": "8Kd0zX2mQ1",
"object": "order",
"status": "fulfilled",
"fulfillments": [
{
"id": "f_8c21",
"lineItemIds": [0, 1],
"carrier": "UPS",
"trackingNumber": "1Z999AA10123456784",
"trackingUrl": null,
"at": "2026-08-22T09:14:02.000Z"
}
]
}
Retrying is safe, and needs no Idempotency-Key

Send the same PATCH twice — a lost response, a re-run cron — and the second call finds the order already fulfilled, writes nothing, and returns the same 200 with the same order. It cannot record the parcel twice. This is why the endpoint neither needs nor accepts an Idempotency-Key.

Which moves are allowed

The API and the console obey one status machine — the same code decides both, so the API can never make a move the console would refuse.

Fromfulfilleddelivered
pendingNo — nothing is paid for yetNo
paidYesNo — it has to ship first
partially_fulfilledYesNo
fulfilledAlready there → 200, no writeYes
deliveredNoAlready there → 200, no write
cancelledNoNo
refundedNoNo

A move this table refuses answers 409 conflict with code: "order_transition", and the message names the status that refused it. That is the answer to give up on, not to retry: it means the order moved on without you — usually refunded or cancelled in the console while your queue was still holding it.

{
"error": {
"type": "conflict",
"message": "Orders in \"refunded\" cannot be marked fulfilled",
"code": "order_transition"
}
}

Recipes

Sync new orders into another system

There is no created filter and no sort by date — see ordering. The reliable pattern is to page everything once, remember the ids, and then re-page and skip what you've seen:

async function fetchAllOrders(siteId, key) {
const orders = []
let cursor = null
do {
const url = new URL(`https://app.aglyn.com/api/v1/sites/${siteId}/orders`)
url.searchParams.set('limit', '100')
if (cursor) url.searchParams.set('cursor', cursor)
const page = await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
}).then((r) => r.json())
orders.push(...page.data)
cursor = page.next_cursor
} while (cursor)
return orders
}

const seen = await loadSeenIds() // your store
const all = await fetchAllOrders('host_demo', process.env.AGLYN_API_KEY)
for (const order of all) {
if (seen.has(order.id)) continue
await pushToAccounting(order)
seen.add(order.id)
}

Ids are stable forever, so a set of ids is a complete and idempotent watermark. Don't use number for this — it's per site, so two sites both have an order 1.

Ship a batch from a warehouse queue

The pattern that makes a fulfilment worker safe: no bookkeeping of what you already sent, because the API answers the retry identically.

async function recordShipment(siteId, orderId, carrier, trackingNumber, key) {
const response = await fetch(
`https://app.aglyn.com/api/v1/sites/${siteId}/orders/${orderId}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: 'fulfilled', carrier, trackingNumber }),
},
)
const body = await response.json()
if (response.ok) return body // the order, shipment included

// 409 order_transition: the order moved on (refunded, cancelled) without us.
// Never retry this one — it will refuse forever. Surface it to a human.
if (body?.error?.code === 'order_transition') {
throw new Error(`Order ${orderId} can no longer ship: ${body.error.message}`)
}
throw new Error(body?.error?.message ?? `HTTP ${response.status}`)
}

A timeout or a 500 is safe to retry as-is — the write is a single transaction, so either it landed or nothing did, and a repeat of a landed write is the no-op 200 above.

Reconcile a day's takings

const paid = all.filter(
(o) => o.created?.startsWith('2026-08-14') && o.status !== 'cancelled',
)
const gross = paid.reduce((sum, o) => sum + (o.totals.totalCents ?? 0), 0)
const refunded = paid.reduce((sum, o) => sum + o.refundedCents, 0)
const platformFees = paid.reduce((sum, o) => sum + o.totals.feeCents, 0)

console.log({ gross, refunded, net: gross - refunded, platformFees })

platformFees is reported separately on purpose — it is a cost, not a reduction in what you sold.

Errors

StatustypeWhen
400bad_requestcode: "validation_failed". An unknown body field, a missing or unrecognised status, or status: "cancelled" / "refunded" — which are console actions, and the message says so.
403insufficient_scopeKey lacks orders:read on a read, or orders:write on a PATCH (code is the scope).
403plan_requiredThe organization's plan no longer includes commerce (code: "commerce"). Paid features stop at the door when the plan drops — see downgrading.
404not_foundUnknown or unowned site ("No such site"), or unknown order ("No such order").
404not_foundAlso answered when this deployment ships no commerce plugin at all — self-hosted installs can leave it out.
405method_not_allowedAnything other than GET, or PATCH on one order. The Allow header lists what is.
409conflictcode: "order_transition" — the status machine refused the move; see which moves are allowed.

A site your organization doesn't own answers 404, never 403 — the API never reveals whether an id exists somewhere else. So a 404 means "not yours or not real".

  • Products — the catalog these orders sold from.
  • Contacts — joined to orders by customerEmail.
  • Commerce — the store itself.
  • Conventions — pagination, ordering, errors.