# 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](/guides/api-keys), 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:

```json
{
  "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](/guides/authentication):

```ts
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.

```ts
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](/guides/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:

```ts
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-lookup` takes 200 values; `bulk-update` takes 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`](https://github.com/Orbit-Web-Solutions/orbit-commerce-samples/tree/main/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`](https://github.com/Orbit-Web-Solutions/orbit-commerce-samples/blob/main/plugins/starter/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](/guides/authentication).
- Which scopes to request, and how consent works, in [Scopes](/guides/scopes).
- Installing on a store before you are listed, in
  [Testing your plugin](/guides/testing).
