Documentation
Open the console

Developer documentation

Getting Started API and SDK

Everything a host app needs to embed the checklist: one script tag, the whole attribute and event surface, and every HTTP call the widget makes — with the payloads each one takes and returns.

Base URL https://gettingstarted.marthaia.com REST prefix /api/gs/v1 Bundle /sdk/v1/gs.min.js

Quick start

Three things: load the bundle, mount the element, and tell the platform when a step is really finished. The checklist itself — steps, their texts, the theme — lives in the console, not in your code.

1. Load the bundle and mount the widget

Paste this into the layout your authenticated app already has. The element boots itself: it reads the project config, generates a visitor id for people you don't know yet, fetches their progress, and renders.

In your app (index.html)
<script
  src="https://gettingstarted.marthaia.com/sdk/v1/gs.min.js"
  defer></script>

<getting-started
  endpoint="https://gettingstarted.marthaia.com"
  project-key="pk_your_publishable_key"
  user-id="u_1234"
  locale="en"></getting-started>
  • project-key is the project's publishable key (pk_…). It ships to browsers by design; the secret key never does.
  • user-id is optional. Omit it while you don't know who is looking and the widget tracks an anonymous visitor instead; hand it over later and the anonymous history is merged into that user once.
  • disabled on the element renders nothing — a kill switch you can ship ahead of a rollout.

2. Report a step from your app

The SDK tells you when a step is clicked, but almost nothing is finished by a click. When the real event happens in your own code, say so and the widget re-renders:

Your app's JavaScript
const gs = document.querySelector('getting-started')

await gs.complete('first_project')   // POST /complete → the new state
console.log(gs.state.progress.percent)

3. Or report it from your backend

For events your browser never sees — a payment settled, an integration connected — use the secret key server-to-server. It never leaves your server.

From your backend
curl -X POST https://gettingstarted.marthaia.com/api/gs/v1/server/complete \
  -H "X-GS-Secret: sk_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{"userId":"u_1234","stepKey":"first_payment"}'
200 — the state after the change
{
  "data": {
    "subjectId": "k9f2xq1m4b7w3zt",
    "locale": "en",
    "dismissed": false,
    "completed": ["first_payment", "first_project"],
    "steps": [
      { "key": "first_project", "completed": true },
      { "key": "first_payment", "completed": true },
      { "key": "invite_teammate", "completed": false }
    ],
    "progress": { "completed": 2, "total": 3, "nextKey": "invite_teammate", "percent": 67 },
    "version": 7
  }
}

Both keys are in the console at /preview/keys — every section has its own URL (/preview/steps, /preview/users, /preview/stats, …), so a page can be bookmarked, shared and reloaded. A key is handed over when it is created, and it can be shown again at any time from that screen: the platform keeps an encrypted copy beside the hash it authenticates with, and only your account can read it. When a credential has to be replaced, rotate it rather than revoking it and starting over.

Embedding

The widget is one custom element, <getting-started>, built from a plain script tag with no bundler and no framework, and a thin React wrapper for hosts that would rather pass props.

Attributes

Every attribute is observed. Values in the Default column marked "theme" come from the project config fetched at boot, not from the element.

The attributes of the getting-started element
AttributeTypeDefaultWhat it does
endpoint string — required The platform base URL. The SDK appends /api/gs/v1. Changing it reboots the widget.
project-key string — required The project's publishable key, pk_…. Optional only when a host injects configOverride.
user-id string empty Your opaque id for the signed-in user. Enables progress across devices. Going from empty to a value calls /identify exactly once.
visitor-id string generated: v_ + 24 base62 chars, kept in localStorage['gs_<projectId>_visitor_id'] Overrides the auto-generated visitor id. Sent on every call.
locale string browser language → project defaultLocale Forces one of the project's locales.
meta JSON object absent Small, host-supplied facts about this person — a plan, a role, a cohort. Stored with the subject and sent on every call; see Metadata.
theme auto | light | dark theme mode (default auto) Sets color-scheme and data-mode on the widget root. The palette itself always comes from the project theme.
position right | left theme position (default right) Which edge the desktop panel docks to.
layout auto | panel | drawer | peek | none auto auto picks desktopLayout or mobileLayout for the current viewport; none renders nothing.
z-index number theme zIndex (default 2147483000) Stacking order, for hosts that already own the top of the page.
disabled boolean (presence) absent Present, whatever its value: the element renders nothing and never boots.

Host behaviour

  • What reboots the widget: endpoint, project-key, locale and visitor-id — the config and state are read again. theme, position, layout and z-index re-render in place.
  • Two bundles, one element: /sdk/v1/gs.min.js (IIFE — registers the tag and exposes window.GS) and /sdk/v1/gs.esm.js (ESM). Load one of them, never both: each carries its own copy of the class and the first registration wins.
  • npm, if you prefer: import '@gs/web' registers <getting-started>; defineGettingStarted('my-checklist') picks a different tag name.
  • SSR-safe: importing @gs/web on the server touches no window and no document; the React wrapper renders the element tag and hydrates on the client.
  • Shadow DOM: the widget owns its styles, so it cannot collide with yours. Hosts style it through the --gs-* custom properties and the part="…" hooks — see Theming.
  • Accessibility: the panel and the sheet are labelled dialogs, the sheet traps focus, Esc closes both, the mobile peek is a role="button" operable with Enter/Space, and prefers-reduced-motion disables the entry animations.
  • No third-party requests: the bundle has zero runtime dependencies and calls nothing but your endpoint.

Metadata

A checklist is rarely just a checklist: which plan someone is on, whether they are an admin, which cohort they signed up in. Pass those facts as meta and they are stored with the subject, sent back on every read, and visible in the console's Users tab. The server never interprets them.

<getting-started
  endpoint="https://gettingstarted.marthaia.com"
  project-key="pk_your_publishable_key"
  meta='{"plan":"pro","tenant":"acme"}'></getting-started>

Any call may carry metadata, and what arrives is merged into what is stored. From the imperative API:

// Identifying is the natural moment to say who this user is.
await widget.identify('u_4821', { plan: 'team', seats: 12 })

// Or later, when the host learns something new — this persists immediately.
await widget.setMeta({ plan: 'enterprise' })

// Read back what every call currently carries.
widget.meta
Shape
A flat JSON object of strings, numbers, booleans or null. Nested objects and arrays are refused.
Size
At most 1 KB of serialised JSON, 24 keys, 40-character key names and 200-character string values. A bigger payload is refused with meta_too_large, never truncated.
Merging
Keys merge over what is already stored. Sending null for a key deletes it.
Never sent
Omit meta and nothing changes: existing metadata is kept, and a subject that never had any has no meta field at all.
Not for secrets
This is not a place for personal data or credentials — it is handed to the widget in plain sight and rendered in the console.

A malformed meta attribute is reported through the gs:error event rather than silently ignored.

React

The wrapper maps props to attributes (projectKey → project-key), events to callbacks, and exposes the imperative API through a ref. React is a peer dependency; it is never bundled.

App.tsx
import { GettingStarted, useGettingStarted } from '@gs/react'

export function App({ me }) {
  return (
    <GettingStarted
      endpoint="https://gettingstarted.marthaia.com"
      projectKey="pk_your_publishable_key"
      userId={me?.id}
      locale="en"
      onStepCompleted={(key, state) => console.log(key, state.progress)}
      onCompletedAll={() => celebrate()}
      onError={(error) => console.warn('gs', error.code, error.message)}
    />
  )
}

// imperative API + live state
function Progress() {
  const gs = useGettingStarted()
  return (
    <>
      <getting-started ref={gs.ref} endpoint="https://gettingstarted.marthaia.com"
        project-key="pk_your_publishable_key" user-id="u_1234" />
      <p>{gs.state?.progress.percent}%</p>
      <button onClick={() => gs.complete('first_project')}>I shipped it</button>
    </>
  )
}
React props and callbacks
PropTypeMaps to
endpointstring (required)attribute endpoint
projectKeystring (required)attribute project-key
userIdstring | nullattribute user-id
visitorIdstring | nullattribute visitor-id
localestring | nullattribute locale
themeauto | light | dark | nullattribute theme
positionright | left | nullattribute position
layoutauto | panel | drawer | peek | none | nullattribute layout
zIndexnumber | string | nullattribute z-index
disabledbooleanattribute disabled
configOverrideconfig object | nullelement property (draft mode)
className, stylestring / objectthe element itself
onLoaded(detail) => voidgs:loaded
onStepClicked(detail) => voidgs:step-clicked
onStepCompleted(key, state) => voidgs:step-completed
onOpened, onClosed, onDismissed() => voidthe matching events
onCompletedAll(state) => voidgs:completed-all
onError(error) => voidgs:error
onStateChange(state) => voidevery state the wrapper observes

The wrapper's ref (and useGettingStarted()) exposes element, state, complete, identify, refresh, open, close, dismiss, reset, getState, getConfig and setState.

Events

Every event is a bubbling, composed CustomEvent dispatched on the element, so a listener anywhere in your app can hear it.

SDK events and their payloads
EventdetailFired when
gs:loaded { subjectId, state } The widget has a config and a state: at boot, and after refresh().
gs:step-clicked { key, actionUrl } A pending step was clicked. The link still navigates — the SDK does not complete the step for you.
gs:step-completed { key, state } complete(key) succeeded, with the state it returned.
gs:opened / gs:closed {} The panel or sheet opened or closed.
gs:dismiss-requested { percent } The visitor pressed dismiss on an unfinished checklist, so the confirmation is on screen.
gs:dismissed { scope } A dismissal happened. scope: "visit" is the confirmation's "later" answer — hidden until the next visit, nothing stored; scope: "permanent" is dismiss() or "never", and is stored on the subject.
gs:completed-all { state } The last required step was completed.
gs:error { code, message, details?, status? } Any failed call. code is an API error code, or one the SDK raises itself.
Handing events to your analytics
const gs = document.querySelector('getting-started')

gs.addEventListener('gs:step-completed', (event) => {
  const { key, state } = event.detail
  track('checklist_step', { key, percent: state.progress.percent })
})

gs.addEventListener('gs:error', (event) => {
  const { code, message, status } = event.detail
  report(code, message, status)
})

The SDK's own error codes, which never come from the API, are network_error (the request failed or timed out), invalid_response (a response missing its data envelope), not_configured (no config, no endpoint or no key), unsupported and sdk_error.

JavaScript API

Everything the widget does is available to the host on the element itself.

Methods

The element's methods
SignatureReturnsDoes
complete(stepKey: string) Promise<State | null> POST /complete for this subject, then re-renders. Idempotent: completing a step twice returns the same state.
identify(userId: string, meta?: object) Promise<State | null> POST /identify — links the anonymous visitor to this user id and merges the history. The optional meta is merged into what the subject already carries.
setMeta(meta: object) Promise<State | null> Merges metadata into what every subsequent call sends, and persists it straight away. Use it when the host learns something mid-session that the snippet's meta attribute could not know.
refresh() Promise<State | null> Re-fetches the state and emits gs:loaded. Useful after a server-side completion.
open() void Opens the panel or the bottom sheet, and emits gs:opened.
close() void Closes it and emits gs:closed.
dismiss() Promise<State | null> POST /dismiss — hides the mobile peek, persisted on the subject.
reset() Promise<State | null> POST /reset — clears every step this subject completed.

The methods that talk to the API reject with the error they got, and emit gs:error. Await them in a try/catch if a failure needs to change what your app does next.

Properties

The element's properties
PropertyTypeWhat it is
stateState | null (read-only)The current state, in the shape of GET /state. null before boot.
configConfig | null (read-only)The resolved config the widget is rendering from.
subjectIdstring | null (read-only)This visitor's subject on the server. Store it if you need to look the person up later.
visitorIdstring (read-only)The visitor id actually in use — the generated one, or the visitor-id attribute.
localestring (read-only)The locale in use after resolution.
draftboolean (read-only)True when the config was injected rather than fetched.
versionstring (read-only)The running SDK bundle version — not the config version, which is state.version.
configOverrideConfig | null (settable)A whole config object to render instead of fetching one. Setting it marks draft.
setState(next)(Partial<State> | null) => voidReplaces the local state and re-renders, with no request.

Draft previews

The console's Preview tab builds the widget with unsaved changes by injecting the config as a property. With no endpoint and no project-key, nothing ever leaves the browser:

Draft mode, no network
const el = document.createElement('getting-started')
el.configOverride = myConfig   // a whole /config payload
el.setAttribute('locale', 'en')
document.body.appendChild(el)

el.setState({ completed: ['first_project'] })   // drive the UI locally

These two members are additive: with both endpoint and project-key set, state calls still reach the server; without them, complete, dismiss and reset change only the local state.

REST API

Everything the widget does is a documented HTTP call you can make yourself — from a browser with the publishable key, from your backend with the secret key, and from the console with your account session.

Conventions

API conventions
ConcernHow it works
Base URL https://gettingstarted.marthaia.com/api/gs/v1
Content type application/json; charset=utf-8 on every POST body.
Success envelope Every answer is wrapped in { "data": … }.
Error envelope { "error": { "code", "message", "details"? } } — see Errors.
Publishable key X-GS-Key: pk_…, or ?key=pk_… for callers that cannot set headers (an EventSource). A pk_ key reaches every public endpoint and nothing else.
Secret key X-GS-Secret: sk_… only. A secret key in a URL is rejected — URLs leak into logs, history and referrers.
Account session The /admin/* endpoints take your console sign-in token in the Authorization header. The account must own the project; anything else answers 404 project_not_found rather than admitting the project exists. Operator (superuser) tokens are rejected here — operations go through the PocketBase dashboard.
Identity A request must carry userId and/or visitorId; the pair resolves to one subject, and a new one is created on first sight. userId wins when both are known.
Origins Browser calls are checked against the project's origin allowlist (Origin header). Server-to-server calls are exempt. Every response varies on Origin.
Caching Only /config is cacheable: it returns an ETag and Cache-Control: private, max-age=60, and answers 304 to a matching If-None-Match. State is never cached.
Preflight OPTIONS on an API path is answered 204 with the CORS headers, once the request has passed the same key and origin checks as the call it precedes.

Rate limits

Exceeding a budget answers 429 with a Retry-After header (seconds) and the rate_limited code.

Per-route request budgets
RouteBudgetCounted per
GET /config300 / minutekey + IP
GET /state120 / minutekey + IP
GET /streamshares the /state budgetkey + IP
POST /complete30 / minutekey + subject
POST /dismiss10 / minutekey + subject
POST /reset10 / minutekey + subject
POST /identify20 / minutekey + IP
POST /server/complete, POST /server/state600 / minutekey

14 endpoints

Publishable key — the widget's own calls

These seven are what the widget calls, and what you may call with a publishable key from a browser. A secret key works here too.

GET/api/gs/v1/config

publishable or secret key

The assembled project configuration: which terminal the panel docks to, every step with its texts resolved for one locale, and the theme. Served from an in-memory cache, so a cache hit touches the database zero times.

QueryTypeMeaning
localestring, optionalOne of the project's locales. An unknown locale falls back to defaultLocale.
keystring, optionalThe publishable key, if you cannot send X-GS-Key.
Request
curl https://gettingstarted.marthaia.com/api/gs/v1/config?locale=en \
  -H "X-GS-Key: pk_your_publishable_key"
200 — ETag and Cache-Control on the response
{
  "data": {
    "project": { "id": "2yz1a0b9c8d7e6f", "slug": "snkconnect", "name": "SNKConnect" },
    "defaultLocale": "en",
    "locales": ["en", "fr"],
    "version": 7,
    "desktopLayout": "panel",
    "mobileLayout": "peek",
    "completedBehavior": "hide",
    "theme": { "...": "see Theming" },
    "customCss": null,
    "steps": [
      {
        "key": "first_project",
        "position": 1,
        "actionUrl": "/projects/new",
        "openInNewTab": false,
        "icon": "🚀",
        "required": true,
        "enabled": true,
        "texts": {
          "label": "Create your first project",
          "description": "Everything else starts here.",
          "doneText": "Project created",
          "ctaText": "Create"
        }
      }
    ]
  }
}

enabled is always present; eventName appears only when the step sets it. Editing a project, a step or a translation bumps version and therefore the ETag, so a cached copy costs one cheap 304.

GET/api/gs/v1/state

publishable or secret key

One subject's progress: what they finished, what is next, and how far along they are. This is the object every mutating endpoint returns.

QueryTypeMeaning
userIdstring, optionalYour opaque id for the signed-in user.
visitorIdstring, optionalThe anonymous visitor id. At least one of the two is required.
localestring, optionalLocale for the state that comes back — its locale, and the labels the widget will render from the config.
keystring, optionalThe publishable key, if you cannot send X-GS-Key.
Request
curl "https://gettingstarted.marthaia.com/api/gs/v1/state?userId=u_1234&locale=en" \
  -H "X-GS-Key: pk_your_publishable_key"
200
{
  "data": {
    "subjectId": "k9f2xq1m4b7w3zt",
    "locale": "en",
    "meta": { "plan": "pro", "tenant": "acme" },
    "dismissed": false,
    "completed": ["first_payment", "first_project"],
    "steps": [
      { "key": "first_project", "completed": true },
      { "key": "first_payment", "completed": true },
      { "key": "invite_teammate", "completed": false }
    ],
    "progress": { "completed": 2, "total": 3, "nextKey": "invite_teammate", "percent": 67 },
    "version": 7
  }
}
subjectId
The subject this identity resolves to. Opaque and stable; it is a record id, not a prefixed token.
locale
The locale actually served, after resolution.
meta
The host metadata stored for this subject (§9), omitted entirely when the client has never sent any. See Metadata.
dismissed
True once the mobile peek was dismissed for this subject.
completed
The step keys this subject has finished.
steps
Every enabled step with its completion flag, in configured order.
progress.completed / progress.total
Finished vs. total required, enabled steps.
progress.nextKey
The first enabled step still pending, or "" when there is none. This is what the mobile peek shows.
progress.percent
round(completed / total × 100); 0 when the project has no required steps.
version
The project's config version at the time of the read.
disabled
True when an operator switched this person off from the console's Users tab. Not part of the spec. While it is true the widget renders nothing for them; identity, aliases and completed steps are untouched, and clearing the flag restores them exactly as they were.

POST/api/gs/v1/complete

publishable or secret key

Marks one step complete for this subject. Idempotent: a second call for the same step returns the same state and records nothing new.

Body fieldTypeMeaning
stepKeystring — requiredThe step's stable key. It must belong to the project and be enabled, otherwise 400 invalid_step.
userIdstring, optionalYour opaque user id.
visitorIdstring, optionalThe visitor id. At least one of the two is required.
localestring, optionalLocale for the state that comes back.
Request
curl -X POST https://gettingstarted.marthaia.com/api/gs/v1/complete \
  -H "X-GS-Key: pk_your_publishable_key" \
  -H "Content-Type: application/json" \
  -d '{"userId":"u_1234","stepKey":"first_project","locale":"en"}'
200 — the updated state
{
  "data": {
    "subjectId": "k9f2xq1m4b7w3zt",
    "locale": "en",
    "dismissed": false,
    "completed": ["first_project"],
    "steps": [
      { "key": "first_project", "completed": true },
      { "key": "first_payment", "completed": false },
      { "key": "invite_teammate", "completed": false }
    ],
    "progress": { "completed": 1, "total": 3, "nextKey": "first_payment", "percent": 33 },
    "version": 7
  }
}

Finishing the last required step stamps completed_at on the subject and publishes an event on the stream.

POST/api/gs/v1/dismiss

publishable or secret key

Hides the mobile peek for this subject, stored on the subject — so it survives a reload and a new device. The desktop trigger stays available as a floating action button in the bottom-right corner of the page; on mobile, reopen it with el.open() from your own trigger.

Body fieldTypeMeaning
userIdstring, optionalYour opaque user id.
visitorIdstring, optionalThe visitor id. At least one of the two is required.
localestring, optionalLocale for the state that comes back.
200
{
  "data": {
    "subjectId": "k9f2xq1m4b7w3zt",
    "locale": "en",
    "dismissed": true,
    "completed": ["first_project"],
    "steps": [
      { "key": "first_project", "completed": true },
      { "key": "first_payment", "completed": false },
      { "key": "invite_teammate", "completed": false }
    ],
    "progress": { "completed": 1, "total": 3, "nextKey": "first_payment", "percent": 33 },
    "version": 7
  }
}

POST/api/gs/v1/reset

publishable or secret key

Clears every step this subject completed, and with it the dismissal and the completion stamp. Meant for "start over" affordances and for tests — not for your users' logout.

Body fieldTypeMeaning
userIdstring, optionalYour opaque user id.
visitorIdstring, optionalThe visitor id. At least one of the two is required.
localestring, optionalLocale for the state that comes back.
200 — an empty checklist
{
  "data": {
    "subjectId": "k9f2xq1m4b7w3zt",
    "locale": "en",
    "dismissed": false,
    "completed": [],
    "steps": [
      { "key": "first_project", "completed": false },
      { "key": "first_payment", "completed": false },
      { "key": "invite_teammate", "completed": false }
    ],
    "progress": { "completed": 0, "total": 3, "nextKey": "first_project", "percent": 0 },
    "version": 7
  }
}

POST/api/gs/v1/identify

publishable or secret key

Links an anonymous visitor to a real user id, so someone who started before signing up keeps what they finished. The visitor's progress moves to the user's subject, the earliest completion and dismissal win on duplicates, and the emptied subject is deleted — all in one transaction.

Body fieldTypeMeaning
visitorIdstring — requiredThe anonymous id to absorb. Both identities are required here; one alone answers 400 missing_identity.
userIdstring — requiredThe user id that survives.
localestring, optionalLocale for the merged state.
Request
curl -X POST https://gettingstarted.marthaia.com/api/gs/v1/identify \
  -H "X-GS-Key: pk_your_publishable_key" \
  -H "Content-Type: application/json" \
  -d '{"visitorId":"v_9Kd2mQ4pLz1rTb7n","userId":"u_1234"}'
200 — the merged state
{
  "data": {
    "subjectId": "k9f2xq1m4b7w3zt",
    "locale": "en",
    "dismissed": false,
    "completed": ["first_project"],
    "steps": [
      { "key": "first_project", "completed": true },
      { "key": "first_payment", "completed": false },
      { "key": "invite_teammate", "completed": false }
    ],
    "progress": { "completed": 1, "total": 3, "nextKey": "first_payment", "percent": 33 },
    "version": 7
  }
}

The widget calls this for you the moment user-id goes from empty to a value.

GET/api/gs/v1/stream

publishable or secret key

Server-sent events for one subject, so a step completed in your backend moves the checklist in an open browser tab. One event carries the whole completion list, not a delta.

QueryTypeMeaning
userId / visitorIdstring, optionalThe subject to follow. At least one is required.
localestring, optionalLocale for the opening state.
keystringRequired in practice: EventSource cannot set the X-GS-Key header.
Request
curl -N "https://gettingstarted.marthaia.com/api/gs/v1/stream?key=pk_your_publishable_key&userId=u_1234"
200 — one event on connect, then one per change
event: progress
data: {"subjectId":"k9f2xq1m4b7w3zt","completed":["first_project"],"version":7}

: heartbeat

A heartbeat comment arrives every 25 seconds; the stream closes when the client disconnects. The bus behind it lives in the process, so this is a single-instance feature. The SDK does not consume the stream: the widget refreshes on its own state-changing calls, and refresh() is the supported way to pull a server-side change in.

GET /api/gs/v1/themes

No key required.

The ready-made themes, so a host can start from a complete look instead of designing one. Each preset is a whole theme — colours, radius, font, texts and icons — and can be copied into a project's theme as it stands. The console offers the same list as swatches on its Theme tab.

{ "data": { "presets": [
  { "id": "martha", "name": "Martha IA", "description": "…", "theme": { "accentColor": "#EF562F", … } },
  { "id": "indigo", "name": "Indigo", "description": "…", "theme": { … } }
] } }

martha is drawn from marthaia.com's own palette (accent #EF562F over #FFF5F2, Instrument Sans); mono, indigo, forest, midnight and sunset cover the other common directions. Applying a preset replaces the look and leaves your texts and icons alone.

Secret key — server-to-server

For your backend. A publishable key is refused here with 401 secret_required, and these calls skip the origin allowlist because the caller is not a browser.

POST/api/gs/v1/server/complete

secret key only

The same completion as POST /complete, recorded with source server so you can tell backend-driven progress from clicks. No locale is needed: title, texts and the browser are none of a backend's business.

Header / bodyTypeMeaning
X-GS-Secretheader, requiredThe project's secret key, sk_….
stepKeystring — requiredThe step's stable key. Unknown or disabled → 400 invalid_step.
userIdstring, optionalThe user this event belongs to.
visitorIdstring, optionalOnly if the person is still anonymous.
localestring, optionalLocale for the state that comes back.
Request
curl -X POST https://gettingstarted.marthaia.com/api/gs/v1/server/complete \
  -H "X-GS-Secret: sk_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{"userId":"u_1234","stepKey":"first_payment"}'
200 — the updated state
{
  "data": {
    "subjectId": "k9f2xq1m4b7w3zt",
    "locale": "en",
    "dismissed": false,
    "completed": ["first_payment"],
    "steps": [
      { "key": "first_project", "completed": false },
      { "key": "first_payment", "completed": true },
      { "key": "invite_teammate", "completed": false }
    ],
    "progress": { "completed": 1, "total": 3, "nextKey": "first_project", "percent": 33 },
    "version": 7
  }
}

POST/api/gs/v1/server/state

secret key only

The same state object as GET /state, but with the identity in the body — a backend has no query string to spare and no browser to keep it out of logs.

Body fieldTypeMeaning
userIdstring, optionalThe user to read.
visitorIdstring, optionalThe visitor id. At least one of the two is required.
localestring, optionalLocale for the state that comes back.
Request
curl -X POST https://gettingstarted.marthaia.com/api/gs/v1/server/state \
  -H "X-GS-Secret: sk_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{"userId":"u_1234"}'
200 — the same shape as GET /state
{
  "data": {
    "subjectId": "k9f2xq1m4b7w3zt",
    "locale": "en",
    "dismissed": false,
    "completed": ["first_payment"],
    "steps": [
      { "key": "first_project", "completed": false },
      { "key": "first_payment", "completed": true },
      { "key": "invite_teammate", "completed": false }
    ],
    "progress": { "completed": 1, "total": 3, "nextKey": "first_project", "percent": 33 },
    "version": 7
  }
}

Account session — admin

What the console calls on your behalf. Every path parameter is a record id, and the project must belong to the account you signed in with.

POST/api/gs/v1/admin/projects/{id}/keys

Mints a key for a project. This is the only time the full key is ever returned — the platform stores a SHA-256 hash and shows the first 12 characters from then on.

Body fieldTypeMeaning
typepublishable | secret, optionalDefaults to publishable. Anything else is 400 bad_request.
labelstring, optionalA note for the keys list — "production", "staging".
Request
curl -X POST https://gettingstarted.marthaia.com/api/gs/v1/admin/projects/2yz1a0b9c8d7e6f/keys \
  -H "Authorization: your_account_session" \
  -H "Content-Type: application/json" \
  -d '{"type":"publishable","label":"production"}'
200 — the key
{
  "data": {
    "id": "k8m2p4q6r8s0t2u",
    "key": "pk_2yz1a0b9c8d7e6f_9xQ2vLm4Tz1rB7nWc8Ka5Pd3Fg6Hj0Kq",
    "prefix": "pk_2yz1a0b9c",
    "type": "publishable",
    "label": "production",
    "revealable": true
  }
}

POST/api/gs/v1/admin/keys/{id}/revoke

Revokes a key for good. It stops resolving immediately, everywhere — the key cache is dropped the moment the revocation is stored. A revoked key answers 401 revoked_key.

200
{
  "data": {
    "id": "k8m2p4q6r8s0t2u",
    "prefix": "pk_2yz1a0b9c",
    "revoked": true
  }
}

GET/api/gs/v1/admin/keys/{id}/reveal

Returns the full key again, for a key that belongs to one of your projects. Every key is stored twice — a hash to authenticate with, and a copy sealed with the instance's GS_KEY_SECRET for exactly this call. It is a read: revealing changes nothing, and the key stays valid. A key that cannot be produced answers with a code saying so rather than a guess, which is what the three 409s below mean.

200
{
  "data": {
    "id": "k8m2p4q6r8s0t2u",
    "key": "pk_2yz1a0b9c8d7e6f_9xQ2vLm4Tz1rB7nWc8Ka5Pd3Fg6Hj0Kq",
    "prefix": "pk_2yz1a0b9c",
    "type": "publishable",
    "label": "production",
    "revealable": true
  }
}

POST/api/gs/v1/admin/keys/{id}/rotate

Replaces a key in one step: the replacement is minted first, then the old record is revoked, and both facts come back in the same response. This is the way out of a key that cannot be revealed and the right answer to a credential that has leaked, because there is never a moment with no working key. The replacement carries the same type and label, and is revealable the same way. A key that is already revoked cannot be rotated: it answers 409 revoked_key.

200
{
  "data": {
    "id": "m4n6p8q0r2s4t6u",
    "key": "pk_2yz1a0b9c8d7e6f_A1b2C3d4E5f6G7h8J9k0L1m2N3o4P5q",
    "prefix": "pk_2yz1a0b9c",
    "type": "publishable",
    "label": "production",
    "revealable": true,
    "revokedKeyId": "k8m2p4q6r8s0t2u"
  }
}

GET/api/gs/v1/admin/projects/{id}/stats

Counts for one project: how many subjects exist, how many steps they finished, how many hid the peek, and how many completed everything required.

200
{
  "data": {
    "subjects": 1284,
    "completions": 5310,
    "dismissed": 214,
    "completedAll": 356,
    "dismissRate": 0.1667,
    "perStep": { "first_project": 842, "first_payment": 511, "invite_teammate": 129 }
  }
}
subjects
Subjects this project has ever seen.
completions
Completion rows, i.e. completed steps summed over subjects.
dismissed / dismissRate
Subjects that hid the peek, and their share of subjects.
completedAll
Subjects that finished every required step.
perStep
Completions per step key, counted from the stored progress rows.

GET/api/gs/v1/admin/projects/{id}/subjects

The people this project has seen, most recently active first — the data behind the console's Users tab. end_users carries no PocketBase rules, so this and the console are the only ways to read it.

search
Optional. Matches a user id, a visitor id or a subject id, as a substring.
limit
Optional. Page size, 1–200, default 50.
offset
Optional. Rows to skip, default 0.
200
{
  "data": {
    "items": [
      {
        "id": "5agfb5nbdc0si7b",
        "userId": "user-42",
        "visitorId": "v_9f1c…",
        "locale": "fr",
        "disabled": false,
        "dismissed": false,
        "completed": 2,
        "total": 3,
        "percent": 67,
        "lastSeenAt": "2026-09-26 14:54:40.425Z",
        "completedAt": ""
      }
    ],
    "totalItems": 1284,
    "requiredTotal": 3,
    "limit": 50,
    "offset": 0
  }
}
items[].id
The subject id — the same value /state returns as subjectId.
items[].userId / visitorId
The external id and the browser id behind this subject, either of which may be empty.
items[].disabled
Whether the widget renders for this person at all.
items[].dismissed / completed
Finished steps of any kind; see back to GET /state for what the two mean.
items[].percent
round(completed / requiredTotal × 100), capped at 100.
totalItems
How many subjects match, ignoring limit — read it to page.

POST/api/gs/v1/admin/projects/{id}/subjects/{subjectId}/disabled

Switches one person on or off. A disabled subject keeps their identity, aliases and progress — the widget simply renders nothing for them, which is what you want for a test account or to take the checklist away from somebody without erasing their history. They still appear in /stats.

200
{
  "data": { "subjectId": "5agfb5nbdc0si7b", "disabled": true }
}
disabled
Request body, required: true to switch the person off, false to switch them back on. Anything else is invalid_request.
Errors
not_found when the subject does not belong to this project.

POST/api/gs/v1/admin/projects/{id}/subjects/{subjectId}/reset

Resets one subject's progress, for support cases — a returned widget, a demo account. Same effect as POST /reset, reached by subject id instead of by identity.

200 — that subject's cleared state
{
  "data": {
    "subjectId": "k9f2xq1m4b7w3zt",
    "locale": "en",
    "dismissed": false,
    "completed": [],
    "steps": [
      { "key": "first_project", "completed": false },
      { "key": "first_payment", "completed": false },
      { "key": "invite_teammate", "completed": false }
    ],
    "progress": { "completed": 0, "total": 3, "nextKey": "first_project", "percent": 0 },
    "version": 7
  }
}

DELETE/api/gs/v1/admin/projects/{id}/subjects/{subjectId}

Deletes a subject and everything attached to it — progress, aliases and analytics rows. This is the erasure endpoint for a data-deletion request.

200
{
  "data": {
    "deleted": true,
    "subjectId": "k9f2xq1m4b7w3zt"
  }
}

A subject of another project answers 404 subject_not_found; a project of another account answers 404 project_not_found, so neither leaks that the record exists.

Theming

The theme lives on the project and arrives inside the config. The widget renders in a shadow root, so your page's CSS cannot reach in, and only the properties below reach out. A project that has never been themed still renders: every missing field falls back to the default theme.

Ready-made themes

You do not have to design a theme to have one. Six presets ship with the product and are also served at GET /api/gs/v1/themes. Each is a complete theme — colours, radius, font, texts and icons — so applying one cannot leave a field behind. The console's Theme tab shows them as swatches; pick one, then adjust anything you like.

The ready-made themes
PresetAccentMood
martha — Martha IA#EF562F on #FFF5F2Warm orange on light surfaces, from marthaia.com's palette, with Instrument Sans. The one to copy if you are embedding this on a Martha IA page.
indigo#4F46E5 on whiteThe familiar SaaS look: calm, unopinionated, suits a product page.
forest#15803D on off-whiteDeep green that reads as growth without shouting.
midnight#5B8DEF on #0E1116Dark slate with a cool accent, for products that already live in the dark.
sunset#FB923C on #1B1310Amber on warm charcoal — dark, but still warm.
mono#111827, radius 6Near-black and square: quiet enough for documentation and internal tools.

Theme fields

The theme object's fields
FieldTypeDefaultControls
modeauto | light | darkautoWhether the widget reports light or dark to the browser. auto follows the operating system unless your page sets data-theme="light|dark".
accentColorCSS colour#E5352BThe progress bar fill, the completed check, a pending step's CTA and hover border, the peek's arrow, focus outlines, and the drawer launcher.
accentTextColorCSS colour#FFFFFFText on the accent fill — the drawer launcher's label.
backgroundColorCSS colour#111111The panel, the sheet and the peek bar.
surfaceColorCSS colourrgba(255,255,255,0.06)Step rows and other raised blocks inside the panel.
textColorCSS colour#FFFFFFStep labels and the panel title.
mutedTextColorCSS colourrgba(255,255,255,0.45)Descriptions, done-text and the subtitle.
borderColorCSS colourrgba(255,255,255,0.08)Hairlines between rows, and the panel's own edge.
radiusnumber (px)16Corner radius of the panel, sheet, rows and controls.
fontFamilyCSS font listDM Sans, system-ui, sans-serifThe widget's type. Set a stack, never a single family.
panelWidthnumber (px)360The desktop panel's width.
peekOffsetnumber (px)74How far the mobile peek sits above the bottom edge — set it to clear your bottom navigation.
progressStylebarbarThe progress indicator in the panel header.
zIndexnumber2147483000Stacking order of everything the widget draws.
breakpointnumber (px)1024The desktop/mobile switch. A host can also override it with --gs-breakpoint.
positionright | leftrightWhich edge the desktop panel docks to. The position attribute wins over it.
icons{ peek, close, arrow }👋 / × / →The three glyphs in the widget chrome.
textsobject of 9 stringsFrench defaults — see belowThe widget's own copy, in the project's default locale.
textsByLocale{ [locale]: texts }absentPer-locale overrides for those same strings. Non-empty fields win over texts.

Texts, and where each one appears

The shipped defaults are French, because the product's first project was — override them for your own language. Step labels and descriptions are not here: they come from each step's translations.

Widget chrome strings
Text fieldDefaultWhere it shows
titleBien démarrerPanel header, the dialog's accessible name, the reopen handle
subtitle{count, plural, =0 {Parcours terminé.} one {# action restante.} other {# actions restantes.}}Under the progress bar; count is the number of steps still to do
nextCtaProchaine étapeThe mobile peek's leading line, above the next step's label
noteTitleVous n'avez pas besoin de suivre un ordre.The note at the foot of the checklist
noteBodyComplétez les actions qui vous intéressent.Under the note title
continueContinuerReserved. It is part of the theme contract and the config carries it, but the shipped widget renders no element from it.
dismissMasquerThe peek's dismiss button and its label
closeFermerThe panel's and sheet's close control
reopenBien démarrerLabel of the floating action button — bottom-right on desktop — that brings a collapsed panel back
dismissTitleMasquer la checklist ?Heading of the dismissal confirmation
dismissBody{count, plural, =0 {Tout est terminé.} one {Il reste # action.} other {Il reste # actions.}} Votre avancement est enregistré.Under it: the same ICU plural, plus the reassurance that nothing is lost
dismissLaterRevenir à ma prochaine visite"Later": hidden for this visit, back at the next one, nothing written server-side
dismissNeverNe plus l'afficher"Never": the permanent dismissal, the same call dismiss() makes
dismissCancelAnnulerKeeps the checklist open, with progress untouched

The subtitle is an ICU plural: {count, plural, =0 {…} one {# …} other {# …}}, with count, completed, total and percent available to it. The dismissal copy takes the same variables.

Dismissing a checklist that is not finished asks first: it used to be one silent click with no way back, so a visitor could lose their checklist without meaning to. A checklist at 100% is still dismissed in one click, and dismiss() — the host's own call — stays immediate either way.

A theme, in full

The project's theme field
{
  "mode": "auto",
  "accentColor": "#E5352B",
  "accentTextColor": "#FFFFFF",
  "backgroundColor": "#111111",
  "surfaceColor": "rgba(255,255,255,0.06)",
  "textColor": "#FFFFFF",
  "mutedTextColor": "rgba(255,255,255,0.45)",
  "borderColor": "rgba(255,255,255,0.08)",
  "radius": 16,
  "fontFamily": "DM Sans, system-ui, sans-serif",
  "panelWidth": 360,
  "peekOffset": 74,
  "progressStyle": "bar",
  "zIndex": 2147483000,
  "breakpoint": 1024,
  "position": "right",
  "icons": { "peek": "👋", "close": "×", "arrow": "→" },
  "texts": {
    "title": "Getting started",
    "subtitle": "{count, plural, =0 {All done.} one {# action left.} other {# actions left.}}",
    "nextCta": "Next step",
    "noteTitle": "You don't have to follow an order.",
    "noteBody": "Complete the actions that matter to you.",
    "continue": "Continue",
    "dismiss": "Hide",
    "close": "Close",
    "reopen": "Getting started",
    "dismissTitle": "Hide the checklist?",
    "dismissBody": "{count, plural, =0 {All done.} one {# action left.} other {# actions left.}} Your progress is saved.",
    "dismissLater": "Back at my next visit",
    "dismissNever": "Don't show it again",
    "dismissCancel": "Cancel"
  },
  "textsByLocale": {
    "fr": { "title": "Bien démarrer", "nextCta": "Prochaine étape" }
  }
}

Styling from the host

The theme is applied to the element as CSS custom properties, so a host can override any of them from its own stylesheet. Values you set before the widget's first render win over the config.

Your app's stylesheet
getting-started {
  --gs-accent: #7C3AED;
  --gs-panel-width: 400px;
  --gs-peek-offset: 88px;   /* clear your bottom nav */
  --gs-breakpoint: 900px;   /* host-only: where the peek takes over */
}
  • The properties are --gs-accent, --gs-accent-text, --gs-bg, --gs-surface, --gs-text, --gs-muted, --gs-border, --gs-radius, --gs-font, --gs-z-index, --gs-panel-width and --gs-peek-offset. --gs-breakpoint exists only for the host — it is not part of the theme.
  • customCss on the project is injected into the shadow root after the built-in stylesheet, for anything the properties cannot reach.
  • Structural parts carry part names — panel, sheet, peek, handle, launcher, step, backdrop — so you can style them with ::part() from your own CSS.

Errors

Every failure — validation, key, origin, limit — uses one envelope. The HTTP status says how to react; code says what happened, and is stable enough to branch on.

400 — the envelope
{
  "error": {
    "code": "invalid_step",
    "message": "Unknown step key",
    "details": { "stepKey": "foo" }
  }
}

details appears only when there is something structured to say — invalid_step carries stepKey, origin_not_allowed carries origin, and rate_limited carries retryAfterSeconds in addition to the Retry-After header.

Error codes, their statuses and their causes
StatuscodeRaised when
400bad_requestThe body is not JSON, or a required field is empty — stepKey, most often, or a key type that is neither publishable nor secret.
400invalid_stepstepKey is not a step of this project, or the step is disabled. Carries details.stepKey.
400missing_identityNeither userId nor visitorId was supplied — or, on /identify, only one of them was.
401invalid_keyNo key, an unknown key, or a secret key sent in the query string.
401revoked_keyThe key was revoked in the console.
401secret_requiredA publishable key called /server/*.
401unauthorizedAn /admin/* call without an account session.
403origin_not_allowedThe browser's Origin is not in the project's allowlist. Carries details.origin.
403project_pausedThe project's status is paused: every public endpoint answers this until it is resumed.
403forbiddenAccount creation while public signup is switched off on the instance.
404project_not_foundAdmin: the project does not exist, or belongs to another account.
404subject_not_foundAdmin: the subject does not exist, or belongs to another project.
404not_foundAdmin: the key does not exist.
409reveal_disabledReveal: this instance was started without GS_KEY_SECRET, so no key material is retained for any key.
409key_not_revealableReveal: the key was minted before key material was retained, so the plaintext was never kept. Rotate it to get a key you can reveal.
409key_material_unreadableReveal: the stored copy cannot be decrypted — GS_KEY_SECRET changed since this key was minted. Rotate the key.
409revoked_keyRotate: the key is already revoked. Rotation replaces a live credential; there is nothing to replace here.
429rate_limitedA rate budget was exhausted. The Retry-After header carries the wait in seconds.
500internal_errorSomething on our side failed — a config that cannot be assembled, a write that would not commit. Safe to retry with backoff.

Two statuses are not errors: 304 from /config when your If-None-Match still matches, and 204 from the CORS preflight. One documented code, 409 conflict, is reserved but no v1 endpoint returns it.

AI prompt

The fastest installation is to hand the job to a coding assistant. Each prompt below is self-contained — it names the endpoint, the file to edit and the attributes to set — so the assistant answers with a diff instead of a guess. Replace pk_… and sk_… with your own keys from the console, and the step key with one of the steps you configured there. These are the same three prompts the landing page shows, from one file, so the site and this reference cannot disagree about how the widget is installed.

Any stack — HTML, Rails, Django, Laravel
Add the Getting Started onboarding checklist to this project. Two edits, no package, no wrapper component.

1. Load the widget once, just before </body> in the app's main layout:
<script src="https://gettingstarted.marthaia.com/sdk/v1/gs.min.js" defer></script>

2. Render it once, in the layout that signed-in users see:
<getting-started
  endpoint="https://gettingstarted.marthaia.com"
  project-key="pk_REPLACE_WITH_MY_KEY"
  user-id="REPLACE_WITH_MY_USER_ID"
  locale="en"></getting-started>

Rules: user-id must be my app's stable id for the signed-in user, because that is what progress is stored against; if nobody is signed in yet, omit user-id and the widget keeps progress per browser. locale should follow the user's language. Do not restyle the widget with CSS — it renders inside a shadow root and takes its colours from the project theme.
Next.js / React
Wire the Getting Started checklist into this Next.js app (App Router).

1. Load the script once, in app/layout.tsx:
import Script from 'next/script'
<Script src="https://gettingstarted.marthaia.com/sdk/v1/gs.min.js" strategy="afterInteractive" />

2. Render the widget in the authenticated layout only, e.g. app/(app)/layout.tsx:
<getting-started
  endpoint="https://gettingstarted.marthaia.com"
  project-key="pk_REPLACE_WITH_MY_KEY"
  user-id={session.user.id}
  locale={locale} />

3. It is a custom element, so declare it once for TypeScript:
declare global {
  namespace JSX {
    interface IntrinsicElements {
      'getting-started': { endpoint?: string; 'project-key'?: string; 'user-id'?: string; locale?: string }
    }
  }
}

Do not write a React wrapper or a context provider: the element fetches its own config and keeps its own state.
Server-side step completion
Mark a checklist step complete from my backend when the user actually does the thing, instead of waiting for them to click it.

POST https://gettingstarted.marthaia.com/api/gs/v1/server/complete
X-GS-Secret: sk_REPLACE_WITH_MY_SECRET_KEY
Content-Type: application/json

{"userId":"REPLACE_WITH_MY_USER_ID","stepKey":"REPLACE_WITH_MY_STEP_KEY"}

Call this after the transaction that performs the action commits. Treat a non-2xx as a logged warning, never as a reason to fail the user's action. Keep the secret key on the server — it must never reach the browser. The widget shows the change on its next state fetch.

Replace pk_… and sk_… with your own keys from the console — and the step key with one of the steps you configured there. Nothing in these prompts is tied to a framework version, and none of them asks an assistant to invent an API: every endpoint they name is documented in full in the reference.