Background jobs
Most of these guides assume a merchant is looking at your plugin: they open it in their dashboard and it does something for them. This one is about the work that happens when nobody is there — a scheduled sync, a queue worker, a nightly job, a service polling another system.
Plenty of plugins need it. An integration with an ERP or accounting package, a stock feed, anything that has to keep two systems agreeing. For some plugins it is the entire product and the page exists only to get them started.
Nothing about the platform changes. What changes is which details matter, and they are the ones that only bite a process that outlives a browser tab:
- Your credential has to survive restarts, and it expires.
- Nobody sees the error when something breaks at 3am.
- There is no user session to tell you which store you are acting for.
There is no API key
If you have integrated with other platforms, you are probably expecting to be handed a key to paste into a config file. Orbit does not issue those. Every credential belongs to a plugin installed on a specific store, which is what makes it scoped to the data the merchant consented to and revocable by them without involving you.
You still end up with what you wanted — a long-lived credential your service keeps in its configuration. You obtain it by registering a plugin rather than by asking us for a string. (If you know Shopify, this is the same idea as one of their custom apps — we call them plugins.)
Where the credential comes from
Your background job cannot fetch its own credential. For a plugin, the first one arrives through a browser, and there is no way around it — so this is worth understanding before you design around it. (For a service that belongs to one store's own team, there is a way around it: a store API key, minted in the dashboard, needs no browser and never expires until revoked. The rest of this section is the plugin story.)
When a merchant installs your plugin and opens it, their dashboard loads your
page
in an iframe and hands it a short-lived session token over postMessage.
Your backend trades that token for a long-lived access + refresh pair. From
that point on your service runs headlessly, for as long as it keeps refreshing.
merchant installs and opens your plugin
│
▼
your page loads in their dashboard ──── short-lived session token
│ (postMessage)
▼
your backend exchanges it ───────────── access token (1h) + refresh token (90d)
│
▼
stored encrypted ────────────────────── your service runs from here,
no browser involved
So even a plugin that is entirely a background job needs a page, though that page can do nothing except the handshake. A status line and nothing else is a perfectly respectable implementation of it.
Most plugins give the merchant a real page as well — field mappings, sync history, a "run now" button — and that is a good idea when they need to see or change something. It is a choice, not a requirement.
Declare the page as an extension point in your manifest:
{
"extensionPoints": [
{ "target": "dashboard.main.embed", "url": "https://your-host/embed" }
],
"oauth": { "scopes": ["order:list", "product:read", "product:update"] }
}
The exchange itself is three lines, and is covered in full in Authentication:
import { OrbitClient } from '@orbitcommerce/sdk'
const { accessToken, refreshToken } = await OrbitClient.exchangeToken(sessionToken)
Store the pair encrypted, keyed by store. That record is what your service loads on every run.
The mistake that costs people a weekend
A refresh rotates the pair. The refresh token you just used is dead the moment the API answers, and the response carries its replacement.
If you do not persist that replacement, your next refresh presents a token the server has already invalidated, every call returns 401, and there is no recovery except having the merchant reinstall. The failure is silent when you cause it and only appears an hour later, when the access token expires — often in the middle of the night, on a service nobody is watching.
const orbit = OrbitClient.fromRefreshToken({
token: accessToken,
refreshToken,
storeId,
// Not optional. This is where the rotated pair gets saved.
onTokenRefreshed: ({ accessToken, refreshToken }) =>
persist(storeId, { accessToken, refreshToken })
})
Three consequences worth designing around:
- Persist before you use the new token, not after. A crash in between loses the rotation.
- Only one process may hold a refresh token. Two workers refreshing concurrently means one of them ends up holding a dead one. If you run redundantly, elect a leader.
- A 401 from the refresh endpoint is terminal. Treat it as "credential lost, alert a human", never as something to retry.
As long as your service refreshes at least once every 90 days, it runs indefinitely with no human involvement.
Webhooks first, polling if you must
Reach for webhooks before you write a polling loop. Subscribe at runtime, once, after your connection is stored:
POST /v1/webhooks
{ "topic": "order.created", "webhookUrl": "https://your-host/webhooks/orbit" }
Requires the webhook:create scope, plus the read scope for the topic itself
(order.* needs order:read).
One subscription per topic. Uniqueness is the pair of you and the topic —
your URL is not part of it. So you cannot send one topic to two endpoints, and
a topic already subscribed to a different address cannot simply be created
again: that returns 409.
You here means the credential, not the store: a plugin's installation, or — for an API key — that individual key. A store that wants two systems on the same topic gives each its own key, and revoking a key takes its subscriptions with it.
This catches people out because your URL changes between environments and when you redeploy, so "am I subscribed?" and "am I subscribed to the right place?" are different questions. Reconcile rather than create:
GET /v1/webhooks # what you have now
DELETE /v1/webhooks/{id} # if the topic points somewhere stale
POST /v1/webhooks # then create the correct one
Deleting needs webhook:delete, listing needs webhook:list. Written this
way the whole thing is safe to run on every start.
You get order, fulfilment, payment, product, customer and cart events. The full list, the payload shape, and the signature verification you must perform are in Webhooks.
Polling is the right answer in one common case: your service cannot accept inbound HTTPS. A daemon inside a corporate network often can't, and that is fully supported — just know you are choosing it, rather than reaching for it by default.
If you do poll, use updatedFrom rather than a creation filter, so you catch
orders whose state changed — a cancellation on a week-old order matters to
your ERP as much as a new order does.
GET /v1/orders?updatedFrom=2026-08-11T09:00:00Z&limit=50
Checkpoint the time you started the pass, not the time it finished, so anything that changes mid-run falls into the next window instead of the gap between them. Make your own writes idempotent and a slight overlap costs you nothing, while a gap loses records silently.
A store id is never configuration
One plugin serves every merchant who installs it. So your worker should read the set of stores that have connected and iterate it, rather than being told about one:
for (const storeId of await store.listConnectedStoreIds()) {
await syncOneStore(storeId)
}
Keep everything per store — the credential, the checkpoint, the error state. Merchants then appear when they install and stop appearing when the credential is revoked, with no deployment either way.
This matters even if you are building for one client. It costs a loop, and it means the second merchant is a non-event rather than a rewrite. It also forces the isolation you want anyway: one store's expired credential should never stop the others syncing.
Matching your records to ours
Your system knows SKUs; Orbit knows UUIDs. One call bridges them, rather than a search per product:
POST /v1/products/batch-lookup
{ "field": "sku", "values": ["PROD-001", "PROD-002"] }
Match on sku, handle or barcode, up to 200 values per call. It returns a
{ matchValue: productId } map.
To write back, batch it:
PATCH /v1/products/bulk-update
{ "products": [{ "id": "...", "quantity": 42 }] }
Up to 50 products per call. The response is per-product —
{ results: [{ id, success, error? }] } — so check it rather than assuming the
whole batch landed.
Field names in a request body are not validated. An unrecognised field is
stripped and the write still reports success: true, so a misspelling syncs
nothing at all, silently, for as long as it goes unnoticed. Write quantity,
not stockQuantity.
This is the opposite of the query-string behaviour above, where an unknown parameter is a 400 — do not infer one from the other. When you add a field to a sync, confirm the first write actually changed the product before trusting it.
Limits worth knowing before you size a sync
- 600 reads and 300 writes per minute, per installed plugin.
- A platform-wide 600 requests per minute per IP address on top.
batch-lookuptakes 200 values;bulk-updatetakes 50 products. Both reject oversized batches with a 400 rather than truncating silently.- Unknown query parameters are rejected — a guessed filter name surfaces as a 400 rather than quietly returning unfiltered data. Unknown body fields are stripped and reported as success. The two behave differently; see above.
A loop that pages as fast as it can will reach these. Pause between batches.
A worked example
plugins/starter
is a complete, runnable plugin that includes everything above: a connect page,
a separate worker process that iterates every connected store, the full token
lifecycle, webhook subscription and delivery, and per-store settings.
lib/orbit.ts
is the file worth reading first — it is where the credential is kept alive.
Next steps
- The full token contract, including manual refresh and verification, in Authentication.
- Which scopes to request, and how consent works, in Scopes.
- Installing on a store before you are listed, in Testing your plugin.