Developer Platform
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.
Contents
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:
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.
┌──────────────────────────────────┐ ┌─────────────────────────────┐
│ 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.
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.
Before you apply, two repo prerequisites — On Belay deploys your fieldset through a dedicated GitHub account, so it needs access:
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.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.
npm install @on-belay/sdk@2.2.0The package has exactly one runtime dependency: jose. No peer deps. Node 20+ only.
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)git push origin main
# Railway auto-deploys. Verify:
curl https://<your-service>.up.railway.app/api/health
# → 200 OKFrom the On Belay owner dashboard at /owner/fieldsets/[id], click Trigger run next to your enrolled org. Within 5 minutes you should see:
FieldsetRun row with status = "completed"AuditLog row with action = "external_fieldset_proxy"PublishCounter rowEvery 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
}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.productsAdded 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.
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)
}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() })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}`)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"]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) // → trueAdded in 2.2.0. Also available as an OnbelayClient method — client.logAction(orgId, action, metadata?).
interface WebhookVerifyOptions { maxAgeSeconds?: number }
function validateWebhookSignature(
rawBody: string | Buffer,
signatureHeader: string,
secret: string,
timestamp?: string,
options?: WebhookVerifyOptions,
): booleanPure 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.
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.
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:
| Outcome | HTTP | Body |
|---|---|---|
| Invalid signature or stale/future timestamp | 401 | {"error":"invalid_signature"} |
| fieldsetSlug mismatch | 401 | {"error":"slug_mismatch"} |
onTrigger | 500 | {"error":"handler_failed"} |
| Success | 200 | {"ok":true} |
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()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.
502, 503, 504).500 and 505–599 throw immediately as OnbelayTransportError with attempts = 1.OnbelayProtocolError (functions that return raw values).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 export | Replacement in 2.0.0 |
|---|---|
createScheduler | Platform now schedules dispatch. Your service exposes a webhook only. |
createOrgRunner | The webhook handler IS the runner. Use createOnbelayWebhookHandler. |
createPortfolioRunner | Portfolio fieldsets are out of scope for the 2.x line. |
defineFieldset | The Fieldset row in the platform DB is the manifest source of truth, populated from your application form. |
getNeonCacheClient | Your NEON_CONNECTION_STRING arrives decrypted in the webhook payload and env var. Use pg, postgres, or any client directly. |
Anything from cache.ts | The cache helpers imported platform internals and are removed entirely. |
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).
Content-Type: application/json
X-Onbelay-Signature: sha256=<hex HMAC-SHA256 of raw body>
X-Onbelay-Timestamp: <ISO 8601, equal to payload.timestamp>{
"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 | Notes |
|---|---|
orgId | The enrolled org this run targets. |
fieldsetSlug | Your fieldset slug. The handler verifies this matches what you registered. |
triggerType | scheduled (daily 8am UTC) or manual (owner clicked Trigger run). Other values reserved for future use; the SDK type accepts them. |
timestamp | ISO 8601. Used together with maxAgeSeconds for replay protection. |
runId | Stable per (enrollment, calendar day, triggerType). Inngest retries reuse the same runId — dedupe on this and you trivially survive retries. |
neonConnectionString | Present only when this org has a Neon branch provisioned. Full read/write on your branch only. |
config | Present only when the fieldset namespace of OrgFieldset.config is non-empty. Contains only your namespace — not admin keys. |
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.
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.
| Your response | Platform interprets as |
|---|---|
2xx | Success. FieldsetRun marked completed. |
4xx | Permanent failure. NO retry. FieldsetRun marked failed. |
5xxor timeout (>30s) | Transient. Inngest retries with exponential backoff up to 3 attempts. |
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".
{ type: "onbelay:ready" } to https://app.onbelay.ai.{ type: "onbelay:context", token, orgId, userId } back to the iframe.token to its OWN backend, which calls validateDashboardToken with ONBELAY_DASHBOARD_SECRET, then performs proxy calls scoped to the validated orgId.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.
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.
| Variable | Description |
|---|---|
ONBELAY_FIELDSET_TOKEN | Bearer token for every /api/sdk/* call. Never logged, never sent in webhook payloads. Single source of truth. |
ONBELAY_WEBHOOK_SECRET | HMAC-SHA256 secret for verifying inbound webhooks. Used with X-Onbelay-Signature. |
ONBELAY_DASHBOARD_SECRET | HS256 JWT signing secret for dashboard context tokens. Used in your iframe backend. |
ONBELAY_PROXY_URL | Base 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_STRING | Per-enrollment Postgres connection string. Injected when an org enrolls. Read/write on your branch only. |
ONBELAY_LANGSMITH_API_KEY | Platform monitoring key. Always injected. Do not consume in your code. |
SENTRY_DSN | Platform-shared Sentry project. Catches uncaught exceptions in your service. |
| Variable | Description |
|---|---|
ANTHROPIC_API_KEY | Your Anthropic key. By policy, On Belay's key is never shared with external fieldsets. |
LANGSMITH_API_KEY | Your own LangSmith key for your own trace visibility. |
LANGSMITH_PROJECT | Your LangSmith project name. |
LANGCHAIN_TRACING_V2 | true to enable LangSmith trace collection. |
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.
-- Capture the fieldset id and Railway service id
SELECT id, slug, "railwayServiceId"
FROM "Fieldset"
WHERE slug = '<your slug>';NEW_TOKEN="fst_$(openssl rand -hex 32)"
NEW_TOKEN_HASH=$(echo -n "$NEW_TOKEN" | shasum -a 256 | awk '{print $1}')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")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;railway variables --service "$RAILWAY_SERVICE_ID" \
--set "ONBELAY_FIELDSET_TOKEN=$NEW_TOKEN"
railway redeploy --service "$RAILWAY_SERVICE_ID"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.
| Symptom | Cause + fix |
|---|---|
Webhook returns 401 invalid_signature | HMAC 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_mismatch | options.fieldsetSlug in your handler does not match what you registered. Update one of them. |
Proxy returns 403 operation_not_permitted | The 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_enrolled | This org is not enrolled in your fieldset, or enrollment was paused. Check OrgFieldset.status = "active". |
Proxy returns 403 integration_not_connected | The org has not connected the upstream integration. Skip gracefully — do not throw. The org admin will reconnect on their schedule. |
Proxy returns 403 fieldset_inactive | The platform owner deactivated your fieldset (kill switch). Contact On Belay support. |
Proxy returns 429 rate_limited | You 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 = 2 | Platform 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 = 500 | Platform-side bug, not transient. Not retried. Open a support ticket with the run id. |
Inngest is retrying my webhook 3x for the same runId | Your 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/proxy | This is the 1.0.0 value. The 2.0.0 SDK appends paths internally. Update Railway to https://app.onbelay.ai and redeploy. |
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).
npm install @on-belay/sdk@2.2.0createScheduler, createOrgRunner, createPortfolioRunner, defineFieldset, getNeonCacheClient, and anything from cache.ts.createOnbelayWebhookHandler from §3.executeProxyCall calls — the signature changed. New positional arguments: (orgId, integrationSlug, operationKey, path, options?). The fieldsetSlug argument is gone — the SDK reads it from OnbelayConfig.idempotencyKey reads on recordPublish results — not in 2.0.0. Dedupe on runId instead.process.env.ONBELAY_FIELDSET_TOKEN.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.)runId dedupe in your handler. Recommended.WebhookPayload, new exports, or new optional arguments.Ready to build?
Apply to build a fieldset →Questions? Contact us