LVNDRby Toolfora

HelpREST API

REST API

Basics

ItemValue
Base URLhttps://lvndr.toolfora.com/api/v1
AuthAuthorization: Bearer lv_… (or x-api-key: lv_…) with a key from Settings > Agents & API, or an OAuth access token
FormatJSON in, JSON out. Every response has Cache-Control: no-store.
ContractOpenAPI 3.1 at GET /api/v1/openapi.json, no auth needed
PlanIncluded on every LVNDR plan and free on development stores. A store without an active plan gets 402.

A key is shown once when created; only a hash is stored. A key that is unknown, revoked or expired gets 401. Uninstalling the app revokes every key.

Scopes

A new key gets read and undo. Rollback and export are opt-in when the key is made. An OAuth client that asks for no scope gets the same two.

ScopeAllows
readEvery GET, including job progress; marking a restore point; starting and ending runs
undoUndoing one change, a batch or a run; dismissing alerts; a run started with it captures every unattributed change
rollbackRolling back to a restore point
exportDownloading the change log
export LVNDR_API_KEY=lv_...
export LVNDR=https://lvndr.toolfora.com/api/v1

Routes

A route needs the scope in the table. A key without it gets 403 insufficient_scope. GET routes count against the read limit, POST routes against the write limit. Every write to the store needs the planHash of a dry run or preview: dry-run first, show the plan, then send the hash back.

Method and pathScopeWhat it does
GET /api/v1/mereadThe key, its scopes and expiry, and the plan
GET /api/v1/statusreadPlan, tracked counts, pipeline health
GET /api/v1/changesreadChanges, newest first, paged
GET /api/v1/changes/:idreadOne change with its diff
POST /api/v1/changes/:id/undoundoPreview (default) or run the undo of one change; the run needs the preview's planHash
GET /api/v1/records/:record/historyreadOne record: latest state, versions, changes
GET /api/v1/batches/:idreadEverything one actor did around a change
POST /api/v1/batches/:id/undoundoPreview (default), or queue the undo of a batch with the preview's planHash (202 with a job)
GET /api/v1/restore-pointsreadThe newest 50 restore points
POST /api/v1/restore-pointsreadMark a restore point now
GET /api/v1/restore-points/:idreadOne point and how many changes came after it
GET /api/v1/restore-points/:id/previewreadDry run of a rollback
POST /api/v1/restore-points/:id/rollbackrollbackQueue the undo of everything after the point (202 with a job); needs confirm: true and the preview's planHash
GET /api/v1/alertsreadAnomaly alerts
POST /api/v1/alerts/:id/dismissundoDismiss an alert
POST /api/v1/runsreadStart an agent run (with a restore point unless markRestorePoint is false)
GET /api/v1/runsreadAgent runs, newest first
GET /api/v1/runs/:idreadOne run with its changes, attributedThrough and pendingDeliveries
POST /api/v1/runs/:id/endreadEnd a run
POST /api/v1/runs/:id/undoundoPreview (default), or queue the undo of a whole run with the preview's planHash (202 with a job)
GET /api/v1/jobs/:idreadOne background undo job and its progress
GET /api/v1/exportexportDownload the change log; audited
GET /api/v1/openapi.jsonnoneThe OpenAPI document

A path that exists with another method gets 405 and an Allow header. An unknown path gets 404 and does not count against the rate limit. HEAD works on every GET route. A body field a route does not take gets 400 unknown_field naming it, so a typo such as plan_hash cannot silently skip a check. Bodies over 64 KB get 413 payload_too_large.

Headers

HeaderDirectionMeaning
Authorization or x-api-keyrequestThe key
Idempotency-Keyrequest (POST)Makes a retried write safe; see below. Up to 255 characters.
X-Lvndr-AgentrequestThe agent's name, up to 120 characters. Stored on every write and used as the run's agent when POST /runs has none.
X-Lvndr-Run-IdrequestThe run a write belongs to. Must name a run on this store, else 400 unknown_run.
X-RateLimit-LimitresponseRequests per minute for this key and kind (read or write). Follows the plan; during early access (before managed billing) every store gets the top rate, 300 reads and 60 writes
X-RateLimit-RemainingresponseRequests left before the limit
Retry-Afterresponse (429)Seconds to wait
Idempotency-KeyresponseEchoed when the request sent one
X-Lvndr-Rowsresponse (export)Rows in the file

Rate limits

Each key has two token buckets, one for reads (GET) and one for writes (POST). They refill continuously. Over the limit, the answer is 429 with Retry-After. The size follows the store's plan:

PlanReads per minuteWrites per minute
Starter6010
Growth12020
Scale30060
Development store12020

Until managed billing is on, every store gets the Scale numbers. The server can override all of them with LVNDR_RATE_READ and LVNDR_RATE_WRITE. MCP tool calls share the same buckets. Requests with a missing or wrong key are limited per IP address (30 a minute, then 429).

The buckets live in each server process. With one server that is exact; with several, a key can get up to that many times the limit.

Idempotency

Send Idempotency-Key on a POST and retry with the same key after a timeout. The first answer is stored for 24 hours and replayed, so the write happens once. Keys are per store.

CaseAnswer
Same key, same requestThe stored answer, status and body
Same key, different body, agent or run id409 idempotency_key_reused
Same key while the first request is still running409 idempotency_key_in_progress; retry shortly
The first request failed with a 5xxNothing stored; a retry runs again
Key longer than 255 characters400 invalid_idempotency_key
curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: 7f0c2a" \
  -d '{"dryRun": false, "planHash": "q3Xl0c2V4Zk9b1Rm"}' $LVNDR/changes/cm3abc/undo

Pagination

Lists that page return nextCursor. Pass it back unchanged as cursor for the next page; it is null on the last page. A cursor that cannot be read gets 400 invalid_cursor. Pages apply to changes, a record's history and a run's changes.

Change object

Lists and single changes share one shape.

{
  "id": "cm3...",
  "resourceType": "PRODUCT",
  "resourceId": "gid://shopify/Product/123",
  "resourceTitle": "The Minimal Snowboard",
  "action": "update",            // create | update | delete
  "occurredAt": "2026-11-10T14:03:12.000Z",
  "fields": "price (variant 456)", // or "new record" / "record removed"
  "summary": "The price of one variant went from 885.95 to 750.", // null when summaries are off
  "runId": null,                 // the agent run it belongs to
  "actor": {
    "tier": "exact",             // exact | channel | inferred | unknown (no actor known: common for field edits)
    "kind": "staff",             // staff | app | shopify | unknown | agent
    "name": "Joe Pace",
    "appTitle": "Shopify Web",
    "note": "Named by Shopify's admin event",
    "label": "Joe Pace"
  }
}

actor.tier is unknown for many field edits: Shopify records no actor for them, and LVNDR shows “Not recorded” until it can make a labelled guess (inferred). Report it as unknown, never as a person. LVNDR's own undos are always exact with name lvndr undo.

GET /api/v1/me

Which store and key this is. Use it to check a key before anything else.

curl -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/me
{
  "shop": "example.myshopify.com",
  "key": { "id": "cm9k...", "label": "Claude Code", "scopes": ["read", "undo"], "expiresAt": "2027-02-11T09:00:00.000Z", "clientName": null },
  "plan": { "handle": "scale", "label": "Scale" },
  "limits": { "products": 20000, "retentionDays": 365, "alerts": true, "api": true, "apiRate": { "read": 300, "write": 60 }, "multiStore": true }
}

Until managed billing is on, every store reports the Scale limits, labelled “Early access (free)”.

GET /api/v1/status

Plan, tracked counts and pipeline health.

curl -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/status
{
  "shop": "example.myshopify.com",
  "plan": { "handle": "scale", "products": 20000, "retentionDays": 365, "alerts": true, "api": true, "label": "Scale" },
  "initialSyncAt": "2026-11-10T09:00:00.000Z",
  "lastPolledAt": "2026-11-10T14:05:00.000Z",
  "tracked": { "PRODUCT": 412, "COLLECTION": 18, "PAGE": 6, "BLOG": 1, "ARTICLE": 14, "MENU": 3, "THEME": 2, "METAFIELD_DEFINITION": 9 },
  "deliveries": { "pending": 0, "failed": 0 }
}

plan.products is null when unlimited.

GET /api/v1/changes

Changes, newest first.

Query parameterMeaning
since, untilISO timestamps (2026-11-10T00:00:00Z); changes at or after, at or before
typePRODUCT, COLLECTION, PAGE, BLOG, ARTICLE, MENU, THEME or METAFIELD_DEFINITION
actioncreate, update or delete
resourceIdOne record: its Shopify gid (gid://shopify/Product/123) or the short form Product-123.
actorCase-insensitive part of the staff, app or agent name
qCase-insensitive part of the record title
hideUndos1 or true: leave out LVNDR's own undos
runIdOnly changes tagged with this agent run
limit1 to 100, default 50
cursornextCursor from the previous page
curl -H "Authorization: Bearer $LVNDR_API_KEY" \
  "$LVNDR/changes?since=2026-11-10T00:00:00Z&type=PRODUCT&limit=20"
{ "changes": [ { ...change }, ... ], "nextCursor": "eyJ0Ijo...", "limit": 20 }

GET /api/v1/changes/:id

One change with its full diff.

curl -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/changes/cm3abc
{
  "change": {
    ...change,
    "diff": [ { "path": "variants[ProductVariant/456].price", "before": "885.95", "after": "750.00" } ],
    "beforeKnown": true
  }
}

POST /api/v1/changes/:id/undo

Preview or run the undo of one change. The default is a preview; nothing is written unless dryRun is false, and then only with the preview's planHash. When it runs, the record is snapshotted first, so the undo can itself be undone.

Body fieldDefaultMeaning
dryRuntruefalse writes to the store
includeConflictsfalsetrue also overwrites fields changed again since
planHashnoneRequired when dryRun is false: the planHash the dry run returned. Missing is 400 plan_required; the write is refused with 409 plan_changed if the plan is no longer the same (the record changed since).
reasonnoneWhy, up to 500 characters. Stored with the undo and in the audit log.
# preview
curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" -H "Content-Type: application/json" \
  -d '{"dryRun": true}' $LVNDR/changes/cm3abc/undo

# run exactly what the preview showed
curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" -H "Content-Type: application/json" \
  -d '{"dryRun": false, "planHash": "q3Xl0c2V4Zk9b1Rm", "reason": "price import went wrong"}' $LVNDR/changes/cm3abc/undo
{
  "plan": {
    "mode": "fields",            // fields | delete | recreate
    "counts": { "ready": 1, "conflict": 0, "noop": 0, "unsupported": 0 },
    "items": [
      { "path": "variants[ProductVariant/456].price", "status": "ready", "reason": null, "current": "750.00", "target": "885.95" }
    ]
  },
  "planHash": "q3Xl0c2V4Zk9b1Rm",  // send back with the real run
  "run": null                    // on a real run: { "id", "status": "done" | "partial" | "failed", "ops": [ { "op", "ok", "errors" } ] }
}

A run is recorded in the change log as “LVNDR undo”, requested by api-key:<key id>, with the agent and run id from the headers.

GET /api/v1/records/:record/history

One record: its latest stored state, up to 50 versions and its changes, newest first. :record is Product-123 or a URL-encoded gid. Pages with cursor.

curl -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/records/Product-123/history
{
  "record": { "resourceId": "gid://shopify/Product/123", "resourceType": "PRODUCT", "title": "The Minimal Snowboard", "deleted": false,
              "latest": { "version": 7, "capturedAt": "...", "source": "sweep", "deleted": false, "fields": [ { "label": "Title", "value": "The Minimal Snowboard" }, ... ] } },
  "versions": [ { "id": "...", "version": 7, "capturedAt": "...", "source": "sweep", "sourceUpdatedAt": "..." } ],
  "changes": [ { ...change } ],
  "nextCursor": null
}

GET /api/v1/batches/:id

Everything the same actor did around a change with no gap over ten minutes, newest first. :id is any change in the batch.

curl -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/batches/cm3abc
{ "anchorId": "cm3abc", "actorLabel": "Joe Pace", "changes": [ { ...change }, ... ] }

POST /api/v1/batches/:id/undo

Preview (the default) or undo the whole batch, newest first. Body: dryRun, planHash (required with dryRun: false), reason. A batch holds up to 200 changes. LVNDR's own undos in the batch are skipped.

The dry run plans the newest 50 changes and returns total, more (changes the undo covers that the preview did not plan) and planHash. With dryRun: false the undo is queued as a background job: the answer is 202 with the job (see GET /api/v1/jobs/:id below), or 409 plan_changed when the planHash sent no longer matches the batch.

curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" -H "Content-Type: application/json" -d '{"dryRun": true}' $LVNDR/batches/cm3abc/undo
{
  "anchorId": "cm3abc", "dryRun": true, "actorLabel": "price-bot",
  "counts": { "planned": 12, "done": 0, "partial": 0, "failed": 0, "skipped": 1 },
  "results": [ { "changeId": "...", "resourceTitle": "...", "action": "update", "status": "planned", "detail": "...", "undoRunId": null } ],
  "total": 13, "more": 0, "remaining": 0, "planHash": "Zk9b1RmN8pA7wYsE"
}
curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" -H "Content-Type: application/json" -d '{"dryRun": false, "planHash": "Zk9b1RmN8pA7wYsE"}' $LVNDR/batches/cm3abc/undo
# 202
{ "anchorId": "cm3abc", "actorLabel": "price-bot", "dryRun": false, "job": { "id": "cm9job...", "status": "queued", ... } }

GET /api/v1/restore-points

The 50 most recent restore points.

curl -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/restore-points
{ "points": [ { "id": "cm3p1", "kind": "manual", "label": "before the import", "at": "2026-11-10T13:00:00.000Z", "createdBy": "api-key:cm9k...", "runId": null } ] }

kind is manual, burst or sweep.

POST /api/v1/restore-points

Mark a restore point now. Needs only the read scope: marking never writes to the store. The label is optional and cut at 120 characters; a reason is stored in the audit log.

curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" -H "Content-Type: application/json" \
  -d '{"label": "before the import"}' $LVNDR/restore-points
{ "point": { "id": "cm3p2", "kind": "manual", "label": "before the import", "at": "2026-11-10T14:10:00.000Z", "createdBy": "api-key:cm9k...", "runId": null } }

GET /api/v1/restore-points/:id

One point, with changesSince: how many changes came after it.

curl -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/restore-points/cm3p2

GET /api/v1/restore-points/:id/preview

What rolling back would do to each change after the point, for the first 50, without writing. total and remaining say how many there are beyond those. planHash identifies the set of changes; send it with the rollback.

curl -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/restore-points/cm3p2/preview
{ "preview": { "pointId": "cm3p2", "changes": [ ... ], "totals": { ... }, "total": 14, "previewed": 14, "remaining": 0, "limit": 50 },
  "planHash": "N8pA7wYsE5uJhTgD" }

POST /api/v1/restore-points/:id/rollback

Undo every change after the point, newest first, as a background job. Refused with 400 confirm_required unless the body has "confirm": true, and with 400 plan_required without the preview's planHash. This also reverts changes other people made after the point. It is refused with 409 plan_changed if changes arrived since the preview. One job covers up to 500 changes; if the finished job's remaining is above 0, preview and roll back again.

curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" -H "Content-Type: application/json" \
  -d '{"confirm": true, "planHash": "N8pA7wYsE5uJhTgD", "reason": "bad import"}' $LVNDR/restore-points/cm3p2/rollback
# 202
{ "pointId": "cm3p2", "job": { "id": "cm9job...", "kind": "rollback", "status": "queued", "total": 14, ... } }

GET /api/v1/alerts

Anomaly alerts, newest first. status is open (default), dismissed or all; limit 1 to 100, default 50. An open alert becomes resolved when a batch, run or rollback undo that covers its burst finishes; resolved alerts are listed under all.

curl -H "Authorization: Bearer $LVNDR_API_KEY" "$LVNDR/alerts?status=all"
{ "alerts": [ { "id": "cm9al...", "kind": "burst", "status": "open", "actorLabel": "price-bot", "count": 42,
  "windowStart": "...", "windowEnd": "...", "anchorChangeId": "cm9ch...", "resourceTypes": ["PRODUCT"], "createdAt": "..." } ] }

POST /api/v1/alerts/:id/dismiss

Mark an alert as seen. Needs the undo scope, so a read-only key cannot silence the alert its own burst raised. dismissed is false when it was not open (already dismissed or resolved).

curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/alerts/cm9al/dismiss
{ "alert": { ... }, "dismissed": true }

POST /api/v1/runs

Start an agent run. Changes made while it is open are tagged with its id; see Running agents safely for the rules.

Body fieldMeaning
agentUp to 120 characters. Defaults to X-Lvndr-Agent; one of the two is required.
taskWhat the run is for, up to 500 characters
clientIdUsed only when it is the key's own OAuth client id; otherwise ignored, and captureNote says so
markRestorePointDefault true: marks a restore point one second before the run. false skips it.

Needs only the read scope. The answer says what the run will capture: all-unattributed when the key has the undo scope (every unknown or inferred change while the run is open, staff edits included), client for an OAuth token without undo (only its own client's changes), none for a Settings key without undo.

curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" -H "Content-Type: application/json" -H "X-Lvndr-Agent: Claude Code" \
  -d '{"task": "Raise winter prices 10%"}' $LVNDR/runs
{ "run": { "id": "cm9run...", "agent": "Claude Code", "task": "Raise winter prices 10%", "clientId": null,
  "restorePointId": "cm9pt...", "startedAt": "...", "endedAt": null, "status": "open", "changeCount": 0 },
  "capture": "all-unattributed",
  "captureNote": "This run records every change Shopify left unattributed or LVNDR inferred while it is open (up to 24 h), staff edits included." }

GET /api/v1/runs

Runs, newest first. open=1 for open runs only; limit 1 to 100, default 20.

curl -H "Authorization: Bearer $LVNDR_API_KEY" "$LVNDR/runs?open=1"
{ "runs": [ { ...run } ] }

GET /api/v1/runs/:id

One run with its restore point and its changes, newest first. limit 1 to 200 (default 50) and cursor page through them. attributedThrough is the time through which LVNDR's last completed attribution pass covered the store (null before the first), and pendingDeliveries counts Shopify deliveries not yet processed.

Changes are tagged within about 4 minutes; collection rules and SEO, menus, themes and metafield definitions come from a 10-minute check. After the last write, poll this route about every 20 seconds until attributedThrough is after that write and pendingDeliveries is 0, then review the changes.

curl -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/runs/cm9run
{ "run": { ...run, "restorePoint": { ...point } }, "changes": [ { ...change } ], "nextCursor": null,
  "attributedThrough": "2026-11-13T10:06:00.000Z", "pendingDeliveries": 0 }

POST /api/v1/runs/:id/end

Close a run. Later changes do not join it. Ending an ended run returns it unchanged.

curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/runs/cm9run/end
{ "run": { ...run, "status": "ended" } }

POST /api/v1/runs/:id/undo

Preview (the default) or undo every change tagged with the run, newest first. Body: dryRun, planHash (required with dryRun: false), reason. The dry run has the same fields as a batch undo's, with runId. With dryRun: false the answer is 202 with a job that covers up to 500 changes; remaining counts the rest.

# preview
curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" -H "Content-Type: application/json" -d '{"dryRun": true}' $LVNDR/runs/cm9run/undo

# run (202 with a job)
curl -X POST -H "Authorization: Bearer $LVNDR_API_KEY" -H "Content-Type: application/json" -d '{"dryRun": false, "planHash": "yWsE5uJhTgDq3Xl0", "reason": "wrong collection"}' $LVNDR/runs/cm9run/undo

GET /api/v1/jobs/:id

A run undo, batch undo or rollback is a background job. The write answers 202 at once with the job; nothing has been written yet. Poll this route every few seconds until status is done, partial or failed. It needs only the read scope.

curl -H "Authorization: Bearer $LVNDR_API_KEY" $LVNDR/jobs/cm9job
{ "job": {
  "id": "cm9job...", "kind": "run", "targetId": "cm9run...",
  "status": "running",            // queued | running | done | partial | failed
  "total": 40, "done": 12, "failed": 0, "skipped": 1,
  "remaining": 0,                 // changes beyond this job; undo again afterwards
  "error": null,                  // why the whole job failed; starts with plan_changed when the set moved
  "createdAt": "...", "startedAt": "...", "finishedAt": null,
  "results": [ { "changeId": "...", "resourceTitle": "...", "action": "update", "status": "done", "detail": "...", "undoRunId": "..." } ]
} }

partial means some changes failed and others were undone; failed means none were. A request for a run, batch or point whose job is still queued or running returns that job instead of starting another. The job checks the planHash again before it writes and fails with plan_changed if the set of changes moved. The CLI polls for you; MCP clients use lvndr_get_job.

GET /api/v1/export

Download the change log as a file. Every export is written to the store's audit log with the key that asked.

Query parameterMeaning
formatcsv (default) or json
sinceISO timestamp; changes at or after it
untilISO timestamp; changes at or before it
curl -H "Authorization: Bearer $LVNDR_API_KEY" -o changes.csv \
  "$LVNDR/export?format=csv&since=2026-01-01T00:00:00Z"

The response is an attachment named lvndr-<store>-changes-<date>.csv (or .json). The X-Lvndr-Rows header carries the row count. Large exports stream.

Columns: id, occurredAt, resourceType, resourceId, resourceTitle, action, actorTier, actorKind, actorName, actorAppTitle, summary, diff (JSON).

GET /api/v1/openapi.json

The full contract: every route, parameter, body and response schema. No key needed; cached for five minutes.

curl https://lvndr.toolfora.com/api/v1/openapi.json

Errors

Every error has one shape:

{ "error": { "code": "insufficient_scope", "message": "This key lacks the undo scope", "status": 403 } }
StatuscodeWhen
400invalid_parameterA bad parameter: since/until not ISO timestamps, an unknown type or action, a malformed id, a body field that should be true or false
400invalid_jsonThe body is not a JSON object
400invalid_cursorA cursor that was not passed back unchanged
400confirm_requiredA rollback without confirm: true
400plan_requiredA write (dryRun false, or a rollback) without the planHash of its preview; dry-run first
400unknown_fieldA body field the route does not take, named in the message
400unknown_runX-Lvndr-Run-Id names no run on this store
400invalid_idempotency_keyIdempotency-Key longer than 255 characters
401unauthorizedMissing, malformed, revoked or expired key
402subscription_requiredThe store has no active LVNDR plan (managed billing only)
403insufficient_scopeThe key lacks the route's scope
403not_installedThe app is not installed on this store any more
404not_foundUnknown route, change, record, batch, restore point, alert, run or job
405method_not_allowedThe path exists with another method
413payload_too_largeA body over 64 KB
409idempotency_key_reused, idempotency_key_in_progressSee Idempotency
409plan_changedThe planHash sent with an undo or rollback no longer matches; preview again
409conflictAn undo of this change is already running, or the deleted record was already rebuilt
429rate_limitedOver the read or write limit, or too many failed key lookups from one address; wait Retry-After seconds
500internal_errorSomething failed on the server; retry, then contact support