Orbit Commerce
Plugin guides

Storing your own data

Your plugin has its own database. Almost every plugin needs one, and the decisions you make in the first hour of building it are the ones that are expensive to change later.

This guide is about that side of the line: what belongs to you, what stays in Orbit, and the handful of rules that keep a plugin serving many merchants from becoming a plugin that leaks between them.

What goes where

Orbit's data stays in Orbit. Products, orders, customers, inventory — read them through the API when you need them. Copying the catalogue into your database and treating that copy as the truth is the most common way plugins go wrong: the merchant edits a product, your copy does not know, and you write stale values back over their work.

Caching for performance is fine. Caching and then writing back from the cache is not.

Your database holds what only you know:

  • The credential for each store (see Authentication, or a store API key for a single-store integration)
  • Per-store settings
  • Sync state — checkpoints, cursors, last-run timestamps
  • Records the merchant's own dashboard has no concept of
  • Webhook events you have received, so you can process them once

Everything is per store

One plugin serves every merchant who installs it. There is no global configuration, no "the store", and no single set of credentials.

So the store id is a column on nearly every table you own, and the first thing you resolve on every request and every background pass. In practice:

model Connection {
  storeId      String   @id
  accessToken  String   // encrypted
  refreshToken String   // encrypted
  settings     String   @default("{}")
}

model Record {
  id      String @id @default(uuid())
  storeId String
  // ...
  @@index([storeId])
}

This holds even if you are building for one client. It costs a column, and it means the second merchant is a non-event rather than a rewrite.

Filter every query by store

The rule that matters most, and the one that is easiest to break in a hurry: every read and every write is scoped to the store from the verified token. Never to a store id the caller sent you.

// The store comes from the token, which Orbit verified.
const { storeId } = await verifyRequest(request.headers.get('Authorization'))

const records = await db.record.findMany({
  where: { storeId },        // <- not optional
  orderBy: { createdAt: 'desc' }
})

Deletes and updates are where this is most often missed, because an id already looks unique:

// WRONG — a valid id from another store deletes another merchant's record
await db.record.delete({ where: { id } })

// RIGHT — the store is part of the condition, so a foreign id matches nothing
await db.record.deleteMany({ where: { id, storeId } })

Forgetting the filter on one query does not produce a bug that a merchant reports. It produces one merchant quietly seeing another's data, and you find out from them.

Encrypt credentials at rest

A refresh token is a ninety-day key to a merchant's store. Encrypt access and refresh tokens before they touch your database, and keep the key out of the database that holds them.

await db.connection.upsert({
  where: { storeId },
  create: { storeId, accessToken: encrypt(tokens.accessToken), refreshToken: encrypt(tokens.refreshToken) },
  update: { accessToken: encrypt(tokens.accessToken), refreshToken: encrypt(tokens.refreshToken) }
})

Two consequences worth planning for:

  • Rotating the encryption key makes every stored credential unreadable. There is no recovery path except every merchant reopening your plugin to reconnect. Decide how you would do that before you need to.
  • Never log a token, including inside error objects. A stack trace that echoes a request body is the usual leak.

Record webhook events before you process them

Webhook delivery is at-least-once: a network problem on our side means you see the same event twice. Make that harmless by recording the event id with a unique constraint, and doing the work afterwards.

try {
  await db.webhookEvent.create({ data: { eventId: envelope.id, storeId, topic } })
} catch {
  // Already seen. Acknowledge and stop — reprocessing helps nobody.
  return Response.json({ received: true, duplicate: true })
}

Answering quickly matters too. Orbit retries slow deliveries and eventually gives up, so a handler that does real work inline turns a slow database into lost events. Record, return 200, and let a background pass do the work — see Background jobs.

When a merchant uninstalls

Their credential is revoked immediately and your webhook subscriptions are removed. Anything your plugin was doing for that store stops working, which is the intended behaviour: uninstalling is how a merchant withdraws consent.

Do not rely on the plugin.uninstalled webhook to notice. It is emitted, but the subscriptions it would be delivered through are removed as part of the same uninstall, so whether you receive it is a race you will usually lose. Detect it the reliable way instead: a refresh that returns 401 and does not recover means the credential is gone for good.

What to do with the data you still hold is your decision, and worth making deliberately rather than by default:

  • Keeping it means a merchant who reinstalls picks up where they left off.
  • Keeping it also means you are storing data for someone who has withdrawn consent, which your privacy policy needs to cover.

A common middle path is to keep it for a stated period, then delete it. Say which you do.

Choosing a database

Anything you already run. Plugins in our own catalogue use Postgres with Prisma, but nothing about the platform cares.

Two practical notes:

  • One store's failure must not stop the others. Whatever you use, a background pass should handle a single store erroring without abandoning the remaining ones.
  • Only one process may hold a store's refresh token. If you run redundant workers, elect a leader — two processes refreshing the same credential means one of them ends up holding a dead one.

A worked example

plugins/starter implements all of this: a per-store schema in prisma/schema.prisma, store-scoped CRUD in app/api/notes/route.ts, encryption in lib/orbit-auth.ts, and duplicate-safe webhook recording in app/api/webhooks/orbit/route.ts.

Next steps