Developer Platform

Build a Fieldset

Developer reference for @on-belay/sdk@2.2.0. You write a webhook handler in your own Node.js service. On Belay calls you on a schedule with a signed payload, and you call back through the platform proxy to reach connected integrations on behalf of an enrolled org. No credentials in your code. No platform internals to import.

1. What is a fieldset?

A fieldset is an AI workflow that On Belay organizations can enroll in. You ship the workflow as a small HTTP service running on your own Railway project. On Belay handles:

  • Org enrollment, billing, and admin configuration UI
  • Credential storage and decryption for Shopify, HubSpot, Notion, Slack, and 60+ other integrations
  • Signed webhook delivery on a daily schedule (and on owner-triggered manual runs)
  • Audit logging, rate limiting, and observability

You handle the actual work: read the webhook, call connected integrations through the platform proxy, write to your per-enrollment Neon branch, and record publish events for billing.

Architecture at a glance

┌──────────────────────────────────┐         ┌─────────────────────────────┐
│  Your Railway service            │         │  On Belay Platform          │
│  (Node 20+, any framework)       │         │  app.onbelay.ai             │
│                                  │         │                             │
│  POST /api/onbelay-webhook        │◀────────│  external-fieldset-         │
│   (HMAC-signed inbound)          │ HTTPS + │   scheduler (Inngest)       │
│   uses ONBELAY_WEBHOOK_SECRET    │ HMAC256 │   daily 8am UTC fan-out     │
│                                  │         │                             │
│  POST /api/sdk/*                 │────────▶│  proxy-handler              │
│   Authorization: Bearer          │         │   - decrypt org creds       │
│     ONBELAY_FIELDSET_TOKEN       │         │   - sign upstream calls     │
│                                  │         │   - audit log + FieldsetRun │
│  Per-enrollment Neon branch      │         │                             │
│   NEON_CONNECTION_STRING         │         └────────────┬────────────────┘
└──────────────────────────────────┘                      │
                                                          ▼
                                            Shopify, HubSpot, Notion, ...

Two protocols, two secrets

Inbound (platform → you): signed with ONBELAY_WEBHOOK_SECRET using HMAC-SHA256. Verify with validateWebhookSignature.

Outbound (you → platform): Bearer ONBELAY_FIELDSET_TOKEN on every /api/sdk/* call. The token is read from your env var and is never included in the webhook payload.

2. Quickstart

End-to-end Hello World in five steps. By the end your service is running on Railway, a manual trigger from the platform owner produces a FieldsetRun row marked completed, and a publish event is recorded.

Step 1 — Apply

Before you apply, two repo prerequisites — On Belay deploys your fieldset through a dedicated GitHub account, so it needs access:

  • Add the GitHub user on-belay as a collaborator with write access on your fieldset repo (your repo → Settings → Collaborators). This single grant is the entire repo-connection step — you do not install a Railway GitHub App.
  • Make sure the repo has at least one commit on its default branch. An empty repo passes the visibility check but fails when Railway tries to connect it.

With those in place, submit your GitHub repo URL and fieldset concept at /developer/apply. On approval On Belay creates a Railway service from your repo, injects all platform-managed env vars, and emails you when the service is up.

Step 2 — Install the SDK

npm install @on-belay/sdk@2.2.0

The package has exactly one runtime dependency: jose. No peer deps. Node 20+ only.

Step 3 — Wire a webhook handler

Use createOnbelayWebhookHandler to validate the signature, parse the payload, and dispatch to your code. The returned handler is framework-agnostic — wrap it in Express, Next.js Route Handlers, Hono, Fastify, or anything that exposes the raw request body.

// src/server.ts
import express from "express"
import {
  createOnbelayWebhookHandler,
  executeProxyCall,
  recordPublish,
} from "@on-belay/sdk"

const app = express()

const handler = createOnbelayWebhookHandler({
  secret: process.env.ONBELAY_WEBHOOK_SECRET!,
  fieldsetSlug: "my-fieldset",
  onTrigger: async ({ payload }) => {
    const { orgId, runId, triggerType } = payload

    // Idempotency: scheduler retries reuse the same runId.
    if (await alreadyProcessed(runId)) return

    const products = await executeProxyCall(
      orgId,
      "shopify",
      "shopify.products.list",
      "/admin/api/2024-01/products.json",
    )
    if (products.ok && products.status === 200) {
      await recordPublish(orgId, "product_brief")
      await markProcessed(runId)
    }
  },
})

app.post(
  "/api/onbelay-webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const result = await handler(
      req.body.toString("utf8"),
      req.headers as Record<string, string | undefined>,
    )
    res.status(result.status).set(result.headers).send(result.body)
  },
)

app.listen(3000)

Step 4 — Push to Railway

git push origin main
# Railway auto-deploys. Verify:
curl https://<your-service>.up.railway.app/api/health
# → 200 OK

Step 5 — Trigger a manual run

From the On Belay owner dashboard at /owner/fieldsets/[id], click Trigger run next to your enrolled org. Within 5 minutes you should see:

  • A FieldsetRun row with status = "completed"
  • An AuditLog row with action = "external_fieldset_proxy"
  • An incremented PublishCounter row

3. SDK API reference

Every HTTP-bound function shares the same retry and error contract (described after the function list). All functions accept an optional OnbelayConfig as the final argument; defaults are read from the env vars in §6.

Wire envelope vs SDK return value

All /api/sdk/* success bodies are wrapped on the wire as { "data": T }; errors are { "error": { "code", "message" } }. The SDK functions unwrap the envelope, so the type signatures and example return values shown below are the unwrapped T — what your code receives. If you bypass the SDK and curl the endpoint directly, expect the wrapped wire shape (see §4 and §7).

interface OnbelayConfig {
  proxyUrl?: string     // defaults to process.env.ONBELAY_PROXY_URL
  token?: string        // defaults to process.env.ONBELAY_FIELDSET_TOKEN
  fieldsetSlug: string  // required — your fieldset's slug
  fetch?: typeof fetch  // override for testing
}

executeProxyCall

function executeProxyCall<T = unknown>(
  orgId: string,
  integrationSlug: string,
  operationKey: string,
  path: string,
  options?: {
    method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
    body?: Record<string, unknown>
    queryParams?: Record<string, string>
  },
  config?: OnbelayConfig,
): Promise<ProxyResult<T>>

type ProxyResult<T> =
  | { ok: true;  status: number; data: T }
  | { ok: false; status: number; blocked: true;  error: ProxyErrorCode }
  | { ok: false; status: number; blocked: false; error: string }

type ProxyErrorCode =
  | "invalid_token"
  | "operation_not_permitted"
  | "org_not_enrolled"
  | "integration_not_connected"
  | "fieldset_inactive"
  | "invalid_request"
  | "upstream_error"
  | "proxy_error"
  | "rate_limit_exceeded"

Endpoint: POST /api/sdk/proxy. Calls a connected integration on behalf of an enrolled org. The org must have the integration connected, your fieldset's manifest must declare operationKey in requiredOperations, and the org must be enrolled in your fieldset.

Errors with blocked === true are protocol-level (4xx) and returned as values, not thrown. Always check result.ok before reading data.

const result = await executeProxyCall(
  orgId,
  "shopify",
  "shopify.products.list",
  "/admin/api/2024-01/products.json",
  { method: "GET" },
)

if (!result.ok) {
  if (result.blocked) {
    // Protocol outcome — log and skip gracefully.
    console.warn("proxy blocked:", result.error)
    return
  }
  throw new Error(`upstream failed: ${result.error}`)
}

const products = result.data.products

Typed integration helpers

Added in 2.1.0

Every integration ships an auto-generated typed wrapper module over executeProxyCall. There are 75 integration modules — shopify, hubspot, klaviyo, and so on. Each helper has the operationKey and upstream path baked in, and returns the same Promise<ProxyResult<T>> with the identical retry and error contract described above. They are a typed convenience layer — not a new transport.

Individual operations are namespaced, never bare exports. Many integrations share operation names (listOrders, listCustomers), so the SDK exports one namespace object per integration rather than colliding top-level functions. Three ways to reach a helper:

import { shopify, createShopifySubClient, OnbelayClient } from "@on-belay/sdk"

// 1. Namespace object — pass orgId + integrationSlug on every call.
const a = await shopify.listOrders<MyOrder[]>(orgId, "shopify", {
  queryParams: { status: "any" },
})

// 2. Sub-client factory — bind orgId + slug + config once.
const sc = createShopifySubClient(orgId, "shopify", { fieldsetSlug: "my-fieldset" })
const b = await sc.listOrders<MyOrder[]>({ queryParams: { status: "any" } })

// 3. Via OnbelayClient — the accessor returns the same bound sub-client.
const client = new OnbelayClient({ fieldsetSlug: "my-fieldset" })
const c = await client.shopify(orgId).listOrders<MyOrder[]>()

Helpers that target a single resource take the id as a positional argument. getLocation on the Shopify integration looks up one location:

// Namespace form:
const loc = await shopify.getLocation<{ location: ShopifyLocation }>(
  orgId,
  "shopify",
  locationId,
)

// OnbelayClient form — orgId is already bound by the accessor:
const loc2 = await client.shopify(orgId).getLocation<{ location: ShopifyLocation }>(
  locationId,
)

if (loc.ok) console.log(loc.data.location.name)

GraphQL integrations (Linear, Monday) expose helpers that post { query, variables } to /graphql — pass the GraphQL document through options.body:

import { linear } from "@on-belay/sdk"

const issues = await linear.listIssues<{ issues: { nodes: unknown[] } }>(orgId, {
  body: {
    query: "query { issues(first: 20) { nodes { id title } } }",
    variables: {},
  },
})

Helpers do not bypass proxy permission gates

A typed helper is exactly as permitted as the raw executeProxyCallit wraps. The org must have the integration connected, and the helper's operationKey (e.g. get_location) must be declared in your fieldset's requiredOperations. Calling a helper for an operation you have not declared still returns 403 operation_not_permitted.

getOrgContext

interface OrgContext {
  orgId: string
  orgName: string
  connectedIntegrations: Array<{
    slug: string
    status: "active" | "error" | "pending"
    extraConfig: Record<string, string | null>
  }>
}

function getOrgContext(
  orgId: string,
  config?: OnbelayConfig,
): Promise<OrgContext>

Endpoint: GET /api/sdk/orgs/:orgId/context. Returns the org name and the integrations connected for this org — filtered to only integrations referenced in your fieldset's requiredOperations. Encrypted columns (API keys, secrets) are never returned; extraConfig is server-allowlisted per integration (e.g. shopDomain for Shopify).

const ctx = await getOrgContext(orgId)
const shopify = ctx.connectedIntegrations.find((i) => i.slug === "shopify")
if (shopify?.status === "active") {
  console.log("shop:", shopify.extraConfig.shopDomain)
}

getFieldsetConfig / setFieldsetConfig

function getFieldsetConfig<T = Record<string, unknown>>(
  orgId: string,
  config?: OnbelayConfig,
): Promise<T>

function setFieldsetConfig<T = Record<string, unknown>>(
  orgId: string,
  patch: Partial<T>,
  config?: OnbelayConfig,
): Promise<void>

Endpoints: GET /api/sdk/orgs/:orgId/config?fieldset=<slug>, PATCH /api/sdk/orgs/:orgId/config.

Both functions operate only on the fieldset namespace of OrgFieldset.config. The admin namespace (set by org admins through the platform UI) is read-only from the SDK; attempting to write a top-level admin key returns 400 forbidden_namespace.

setFieldsetConfig is a server-side shallow merge inside a transaction. Patch payloads are capped at 32 KB (client) and the resulting config.fieldset blob at 64 KB (server).

// Read your fieldset's per-org config
const cfg = await getFieldsetConfig<{ lastSyncedAt?: string }>(orgId)

// Patch — preserves any keys you don't include
await setFieldsetConfig(orgId, { lastSyncedAt: new Date().toISOString() })

recordPublish

function recordPublish(
  orgId: string,
  contentType: string,
  metadata?: Record<string, unknown>,
  config?: OnbelayConfig,
): Promise<{ count: number; freeAllowance: number; billable: boolean }>

Endpoint: POST /api/sdk/orgs/:orgId/billing/publish. Atomically increments the org's publish counter for this fieldset and content type. Returns the new count, the free allowance, and whether this publish is billable.

No idempotencyKey support

recordPublish does NOT accept an idempotencyKey argument. Implement dedupe on your side using runId from the webhook payload (see §4).

const result = await recordPublish(orgId, "product_brief", {
  productId: "gid://shopify/Product/123",
})
console.log(`publishes: ${result.count}/${result.freeAllowance} — billable: ${result.billable}`)

isEnrolled / getEnrolledOrgs

function isEnrolled(
  orgId: string,
  config?: OnbelayConfig,
): Promise<boolean>

function getEnrolledOrgs(
  config?: OnbelayConfig,
): Promise<string[]>

Endpoints: GET /api/sdk/orgs/:orgId/enrolled?fieldset=<slug>, GET /api/sdk/enrollments. getEnrolledOrgs returns only orgs enrolled in your fieldset. isEnrolled returns falsefor orgs that don't exist (no existence oracle).

if (!(await isEnrolled(orgId))) return
const orgs = await getEnrolledOrgs()
// → ["clk_abc123", "clk_def456"]

logAction

function logAction(
  orgId: string,
  action: string,
  metadata?: Record<string, unknown>,
  config?: OnbelayConfig,
): Promise<{ ok: boolean }>

Endpoint: POST /api/sdk/orgs/:orgId/audit. Writes a governance-visible AuditLog row for an org-scoped action your fieldset took. action must match /^[a-z0-9_-]+$/ and be at most 128 characters; the platform stores it namespaced as external_fieldset_<action>. The orgId, your fieldset id, and the timestamp are taken from the verified Bearer token — never from the request body — so the row cannot be spoofed to another org or fieldset.

logAction is not recordPublish

logAction is for audit and governance visibility — it is never billed. It does not touch any publish counter. Use recordPublish when content goes live and should count toward billing; use logAction to record everything else worth an audit trail (a sync started, an org skipped, a threshold crossed).

Errors follow the raw-value contract: OnbelayProtocolError is thrown on a 4xx (e.g. invalid_action when action fails the pattern or length check), and OnbelayTransportError is thrown on a transport failure or platform 5xx after the single retry.

const result = await logAction(orgId, "daily_sync_completed", {
  productsScanned: 142,
  runId,
})
// Stored as AuditLog action "external_fieldset_daily_sync_completed".
console.log(result.ok) // → true

Added in 2.2.0. Also available as an OnbelayClient method — client.logAction(orgId, action, metadata?).

validateWebhookSignature

interface WebhookVerifyOptions { maxAgeSeconds?: number }

function validateWebhookSignature(
  rawBody: string | Buffer,
  signatureHeader: string,
  secret: string,
  timestamp?: string,
  options?: WebhookVerifyOptions,
): boolean

Pure crypto. Constant-time HMAC-SHA256 compare against X-Onbelay-Signature. When timestamp is supplied, also rejects payloads older than maxAgeSeconds (default 300) or more than 30 seconds in the future. Never throws — returns false on any mismatch.

You normally don't call this directly — createOnbelayWebhookHandlerwraps it. Use it directly if you need framework integration that the handler doesn't cover.

validateDashboardToken

interface DashboardTokenPayload {
  orgId: string
  userId: string
  fieldsetSlug: string
  iat?: number
  exp?: number
}

function validateDashboardToken(
  token: string,
  secret: string,
): Promise<{ orgId: string; userId: string; fieldsetSlug: string } | null>

Validates an HS256 JWT issued by the platform when the user opens your embedded dashboard. Pass process.env.ONBELAY_DASHBOARD_SECRET. Returns null on any failure (expired, wrong secret, malformed). Never throws.

createOnbelayWebhookHandler

interface WebhookPayload {
  orgId: string
  fieldsetSlug: string
  triggerType:
    | "scheduled"
    | "manual"
    | "user_triggered"
    | "enrollment_changed"
    | "unenrollment"
  timestamp: string
  runId: string                     // stable per (enrollment, day, triggerType)
  neonConnectionString?: string     // only when an org has a Neon branch
  config?: Record<string, unknown>  // only when fieldset namespace is non-empty
}

interface WebhookHandlerOptions {
  secret: string         // ONBELAY_WEBHOOK_SECRET
  fieldsetSlug: string
  onTrigger: (ctx: { payload: WebhookPayload; rawBody: string }) => Promise<void>
  maxAgeSeconds?: number // default 300
}

interface WebhookResult {
  status: number
  body: string
  headers: Record<string, string>
}

function createOnbelayWebhookHandler(
  options: WebhookHandlerOptions,
): (
  rawBody: string,
  headers: Record<string, string | undefined>,
) => Promise<WebhookResult>

The handler validates HMAC, checks timestamp freshness, parses JSON, verifies payload.fieldsetSlug matches options.fieldsetSlug, then calls your onTrigger. Errors map to:

OutcomeHTTPBody
Invalid signature or stale/future timestamp401{"error":"invalid_signature"}
fieldsetSlug mismatch401{"error":"slug_mismatch"}
onTrigger500{"error":"handler_failed"}
Success200{"ok":true}

OnbelayClient class

An ergonomic wrapper. Construct it once with your config and call instance methods that mirror the free functions — plus one typed accessor per integration that returns a bound sub-client (see Typed integration helpers above):

class OnbelayClient {
  constructor(config: OnbelayConfig)

  executeProxyCall<T = unknown>(
    orgId: string,
    integrationSlug: string,
    operationKey: string,
    path: string,
    options?: { method?, body?, queryParams? },
  ): Promise<ProxyResult<T>>

  getOrgContext(orgId: string): Promise<OrgContext>
  getFieldsetConfig<T = Record<string, unknown>>(orgId: string): Promise<T>
  setFieldsetConfig<T = Record<string, unknown>>(
    orgId: string,
    patch: Partial<T>,
  ): Promise<void>
  recordPublish(
    orgId: string,
    contentType: string,
    metadata?: Record<string, unknown>,
  ): Promise<{ count: number; freeAllowance: number; billable: boolean }>
  logAction(
    orgId: string,
    action: string,
    metadata?: Record<string, unknown>,
  ): Promise<{ ok: boolean }>
  isEnrolled(orgId: string): Promise<boolean>
  getEnrolledOrgs(): Promise<string[]>

  // One typed accessor per integration — returns a bound sub-client.
  shopify(orgId: string, integrationSlug?: "shopify" | "shopify_temp")
  // + one accessor per integration (hubspot, klaviyo, linear, ...)
}

const obc = new OnbelayClient({ fieldsetSlug: "my-fieldset" })
await obc.isEnrolled(orgId)
await obc.shopify(orgId).listOrders()

OnbelayTransportError

class OnbelayTransportError extends Error {
  readonly status: number   // last observed HTTP status; 0 if no response
  readonly url: string      // the URL being called
  readonly attempts: number // 1 or 2
}

Thrown when a network failure or platform 5xx persists after the single retry. Catch this in your handler and either retry the run from your side or skip cleanly. Do not let an OnbelayTransportError propagate to your process root — Railway will log it as a 500, the platform will mark the run failed, and the next scheduled run will try again.

Network behavior — applies uniformly to every HTTP function

  • Per-request timeout: 30 seconds.
  • Retry policy: one retry with a fixed 250 ms delay, only on transport failures (network error, 502, 503, 504).
  • Non-retryable 5xx: 500 and 505599 throw immediately as OnbelayTransportError with attempts = 1.
  • 4xx: never a transport failure. Returned as typed result envelope or thrown as OnbelayProtocolError (functions that return raw values).

Removed in 2.0.0

If you upgraded from 1.0.0, delete every import below

These exports are gone. They are not deprecated — they no longer exist. Importing them will fail at install or compile time. See §9 for the migration playbook.

Removed exportReplacement in 2.0.0
createSchedulerPlatform now schedules dispatch. Your service exposes a webhook only.
createOrgRunnerThe webhook handler IS the runner. Use createOnbelayWebhookHandler.
createPortfolioRunnerPortfolio fieldsets are out of scope for the 2.x line.
defineFieldsetThe Fieldset row in the platform DB is the manifest source of truth, populated from your application form.
getNeonCacheClientYour NEON_CONNECTION_STRING arrives decrypted in the webhook payload and env var. Use pg, postgres, or any client directly.
Anything from cache.tsThe cache helpers imported platform internals and are removed entirely.

4. Webhook contract

The platform calls your service with POST and a signed JSON body. You answer 200 for success or 5xx to signal a transient failure (Inngest retries up to 3 times with backoff).

Headers

Content-Type: application/json
X-Onbelay-Signature: sha256=<hex HMAC-SHA256 of raw body>
X-Onbelay-Timestamp: <ISO 8601, equal to payload.timestamp>

Payload

{
  "orgId": "clk_abc123",
  "fieldsetSlug": "my-fieldset",
  "triggerType": "scheduled",
  "timestamp": "2026-05-07T08:00:00.000Z",
  "runId": "ofs_clk_abc123-2026-05-07-scheduled",
  "neonConnectionString": "postgresql://...",
  "config": { "slackChannelId": "C0123..." }
}

The token is NOT in the payload

The fieldset token lives in ONBELAY_FIELDSET_TOKEN in your Railway env only — it is never sent in the webhook payload. The SDK reads the env var by default.

Field reference

FieldNotes
orgIdThe enrolled org this run targets.
fieldsetSlugYour fieldset slug. The handler verifies this matches what you registered.
triggerTypescheduled (daily 8am UTC) or manual (owner clicked Trigger run). Other values reserved for future use; the SDK type accepts them.
timestampISO 8601. Used together with maxAgeSeconds for replay protection.
runIdStable per (enrollment, calendar day, triggerType). Inngest retries reuse the same runId — dedupe on this and you trivially survive retries.
neonConnectionStringPresent only when this org has a Neon branch provisioned. Full read/write on your branch only.
configPresent only when the fieldset namespace of OrgFieldset.config is non-empty. Contains only your namespace — not admin keys.

Signature verification

signature = "sha256=" + hex(HMAC_SHA256(ONBELAY_WEBHOOK_SECRET, rawBody))

rawBody is the exact bytes the platform serialized — verify against the raw request body, never against a re-stringified parsed object. Express needs express.raw(...); Next.js Route Handlers should call req.text() before parsing.

Replay protection

Timestamp-based, with a default 300-second window and 30-second future-skew tolerance. validateWebhookSignature enforces this when given a timestamp argument (the handler wraps this for you). For additional dedupe within the same calendar day, use runId — Inngest retries reuse it.

Response contract

Your responsePlatform interprets as
2xxSuccess. FieldsetRun marked completed.
4xxPermanent failure. NO retry. FieldsetRun marked failed.
5xxor timeout (>30s)Transient. Inngest retries with exponential backoff up to 3 attempts.

5. Embedded UI / dashboard tokens

Optionally, you can embed your own UI inside the On Belay dashboard. Declare an embeddedUiUrl when you register; the platform renders an iframe at /dashboard/fieldsets/[slug] with sandbox="allow-scripts allow-forms".

postMessage handshake

  1. Iframe loads. It posts { type: "onbelay:ready" } to https://app.onbelay.ai.
  2. Platform issues an HS256 JWT (15-minute expiry) and posts { type: "onbelay:context", token, orgId, userId } back to the iframe.
  3. Iframe sends the token to its OWN backend, which calls validateDashboardToken with ONBELAY_DASHBOARD_SECRET, then performs proxy calls scoped to the validated orgId.
  4. On visibilitychange, the platform re-issues a fresh token.
// Inside your iframe page (browser):
// 1. Set up listener BEFORE signaling ready, so you don't miss the message.
window.addEventListener("message", async (event) => {
  if (event.origin !== "https://app.onbelay.ai") return
  if (event.data.type !== "onbelay:context") return

  // Send token to your own backend — never validate in the browser.
  const res = await fetch("/api/dashboard-data", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ token: event.data.token }),
  })
  // ... render with the data
})

window.parent.postMessage({ type: "onbelay:ready" }, "https://app.onbelay.ai")

// Optional: ask the platform to resize the iframe (clamped to 400–2000 px).
window.parent.postMessage(
  { type: "onbelay:resize", height: 1200 },
  "https://app.onbelay.ai",
)
// Your backend route (Express):
import { validateDashboardToken, executeProxyCall } from "@on-belay/sdk"

app.post("/api/dashboard-data", express.json(), async (req, res) => {
  const ctx = await validateDashboardToken(
    req.body.token,
    process.env.ONBELAY_DASHBOARD_SECRET!,
  )
  if (!ctx) return res.status(401).json({ error: "invalid_token" })

  const result = await executeProxyCall(
    ctx.orgId,
    "shopify",
    "shopify.products.list",
    "/admin/api/2024-01/products.json",
  )
  res.json(result.ok ? result.data : { error: result.error })
})

Never call the proxy from the browser

ONBELAY_FIELDSET_TOKEN and ONBELAY_DASHBOARD_SECRET are server-side only. Proxy calls from the iframe MUST go through your own backend. Do not bundle these env vars into browser code.

6. Environment variables

On Belay injects platform-managed variables into your Railway service at provisioning time. You set developer-owned variables yourself in Railway. None of these go in your repo or in browser bundles.

Platform-managed (injected by On Belay)

VariableDescription
ONBELAY_FIELDSET_TOKENBearer token for every /api/sdk/* call. Never logged, never sent in webhook payloads. Single source of truth.
ONBELAY_WEBHOOK_SECRETHMAC-SHA256 secret for verifying inbound webhooks. Used with X-Onbelay-Signature.
ONBELAY_DASHBOARD_SECRETHS256 JWT signing secret for dashboard context tokens. Used in your iframe backend.
ONBELAY_PROXY_URLBase URL only — https://app.onbelay.ai. The SDK appends paths internally. Do NOT include /api/sdk/proxy here (this changed from 1.0.0).
NEON_CONNECTION_STRINGPer-enrollment Postgres connection string. Injected when an org enrolls. Read/write on your branch only.
ONBELAY_LANGSMITH_API_KEYPlatform monitoring key. Always injected. Do not consume in your code.
SENTRY_DSNPlatform-shared Sentry project. Catches uncaught exceptions in your service.

Developer-owned (you set in Railway)

VariableDescription
ANTHROPIC_API_KEYYour Anthropic key. By policy, On Belay's key is never shared with external fieldsets.
LANGSMITH_API_KEYYour own LangSmith key for your own trace visibility.
LANGSMITH_PROJECTYour LangSmith project name.
LANGCHAIN_TRACING_V2true to enable LangSmith trace collection.

7. Token rotation runbook

Rotate when a token is leaked, suspected leaked, or after any credential incident. The runbook is engineered to complete in 5 minutes end-to-end. Rotation is currently triggered manually by an On Belay platform engineer; self-serve rotation is on the roadmap.

Pre-flight

-- Capture the fieldset id and Railway service id
SELECT id, slug, "railwayServiceId"
  FROM "Fieldset"
  WHERE slug = '<your slug>';

Step 1 — Generate a new token

NEW_TOKEN="fst_$(openssl rand -hex 32)"
NEW_TOKEN_HASH=$(echo -n "$NEW_TOKEN" | shasum -a 256 | awk '{print $1}')

Step 2 — Encrypt the plaintext (for audit recovery)

NEW_TOKEN_ENC=$(railway run --service onbelay-app node -e "
  require('./src/lib/crypto').encrypt(process.argv[1], process.argv[2])
    .then(s => console.log(s));
" "$NEW_TOKEN" "$FIELDSET_ID")

Step 3 — Insert + revoke in a single transaction

BEGIN;
UPDATE "FieldsetToken"
   SET "revokedAt" = NOW(), "rotatedAt" = NOW()
 WHERE "fieldsetId" = '<FIELDSET_ID>' AND "revokedAt" IS NULL;
INSERT INTO "FieldsetToken"
  (id, "fieldsetId", "tokenHash", "tokenEnc", name, "createdAt", scope)
VALUES
  (gen_random_uuid()::text, '<FIELDSET_ID>',
   '<NEW_TOKEN_HASH>', '<NEW_TOKEN_ENC>',
   'rotation-<YYYY-MM-DD>', NOW(), 'org');
COMMIT;

Step 4 — Update the Railway env var and redeploy

railway variables --service "$RAILWAY_SERVICE_ID" \
  --set "ONBELAY_FIELDSET_TOKEN=$NEW_TOKEN"

railway redeploy --service "$RAILWAY_SERVICE_ID"

Step 5 — Verify (≤ 60 seconds)

Wire format: every /api/sdk/* success body is wrapped in { "data": T } by sdkSuccess(); errors are { "error": { "code", "message" } }. The SDK functions (e.g. getEnrolledOrgs) unwrap the envelope for you — these curl examples show the on-the-wire body, not the SDK return value.

# Old token must fail:
curl -H "Authorization: Bearer $OLD_TOKEN" \
  https://app.onbelay.ai/api/sdk/enrollments
# → 401 { "error": { "code": "invalid_token", "message": "..." } }

# New token must succeed:
curl -H "Authorization: Bearer $NEW_TOKEN" \
  https://app.onbelay.ai/api/sdk/enrollments
# → 200 { "data": { "orgs": [...] } }

If verification fails, the redeploy did not pick up the new env var. Check Railway logs for the service. Within 5 minutes the new token's lastUsedAt should advance in the FieldsetToken row.

8. Troubleshooting

SymptomCause + fix
Webhook returns 401 invalid_signatureHMAC mismatch. Most common: re-stringifying parsed JSON before validating. Capture the raw request body before parsing. In Express use express.raw({ type: "application/json" }); in Next.js use await req.text() first.
Webhook returns 401 slug_mismatchoptions.fieldsetSlug in your handler does not match what you registered. Update one of them.
Proxy returns 403 operation_not_permittedThe operationKeyis not in your fieldset's requiredOperations. Edit your registration to include it (or pick one that's already declared).
Proxy returns 403 org_not_enrolledThis org is not enrolled in your fieldset, or enrollment was paused. Check OrgFieldset.status = "active".
Proxy returns 403 integration_not_connectedThe org has not connected the upstream integration. Skip gracefully — do not throw. The org admin will reconnect on their schedule.
Proxy returns 403 fieldset_inactiveThe platform owner deactivated your fieldset (kill switch). Contact On Belay support.
Proxy returns 429 rate_limitedYou exceeded the per-token bucket (60 proxy calls / 60 s; 30 writes / 60 s; 120 reads / 60 s). Wait 60 seconds before retrying — the bucket refills every 60 s. @on-belay/sdk does not currently surface the Retry-After header on ProxyResult; a future patch will.
OnbelayTransportError with attempts = 2Platform retried once on a 502/503/504 and the second attempt also failed. Likely a brief platform outage. Catch this in your handler, log to Sentry, and let the next scheduled run pick up the work.
OnbelayTransportError with status = 500Platform-side bug, not transient. Not retried. Open a support ticket with the run id.
Inngest is retrying my webhook 3x for the same runIdYour handler returned 5xx or timed out (>30s). Implement idempotency on runId so retries are cheap, and offload long work to a background queue while returning 200 quickly.
Provisioning fails — "repo not found" or "branch does not exist"On Belay's deploy account (GitHub user on-belay) cannot reach your repo, or the repo is empty. Add on-belay as a collaborator with write access (repo → Settings → Collaborators), push at least one commit to the default branch, then ask On Belay to retry provisioning.
Old ONBELAY_PROXY_URL ending in /api/sdk/proxyThis is the 1.0.0 value. The 2.0.0 SDK appends paths internally. Update Railway to https://app.onbelay.ai and redeploy.

9. Versioning + breaking changes from 1.0.0

Upgrading from 2.0.0 or later? No code changes.

2.1.0 (typed integration helpers) and 2.2.0 (logAction) are purely additive over 2.0.0 — no exports were removed and no existing signature changed. Upgrading from any 2.x release to 2.2.0 is a no-code npm install @on-belay/sdk@2.2.0. The migration steps below apply only to services still on 1.0.0.

@on-belay/sdk@2.0.0 is a complete rewrite. The 1.0.0 SDK depended on platform internals and could not be installed cleanly outside the On Belay repo. 2.0.0 is HTTP-only, framework-agnostic, and ships with exactly one runtime dependency (jose).

Migration checklist

  1. npm install @on-belay/sdk@2.2.0
  2. Delete imports of createScheduler, createOrgRunner, createPortfolioRunner, defineFieldset, getNeonCacheClient, and anything from cache.ts.
  3. Replace your scheduler/runner setup with createOnbelayWebhookHandler from §3.
  4. Update executeProxyCall calls — the signature changed. New positional arguments: (orgId, integrationSlug, operationKey, path, options?). The fieldsetSlug argument is gone — the SDK reads it from OnbelayConfig.
  5. Drop any idempotencyKey reads on recordPublish results — not in 2.0.0. Dedupe on runId instead.
  6. Stop reading the token from the webhook payload — that field is gone. Use process.env.ONBELAY_FIELDSET_TOKEN.
  7. Update Railway env var ONBELAY_PROXY_URL to base URL only: https://app.onbelay.ai. (The platform team will run a migration script for existing services on cutover day.)
  8. Add a runId dedupe in your handler. Recommended.

Versioning policy

  • Major bump for any removed export, removed field, or signature change.
  • Minor bump for new optional fields on WebhookPayload, new exports, or new optional arguments.
  • Patch bump for bug fixes that do not change behavior.

10. Support

Questions? Contact us