npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@on-belay/sdk

v2.2.1

Published

On Belay fieldset SDK — HTTP-only contract between the platform and external fieldsets.

Readme

@on-belay/sdk

Build a fieldset that runs on the On Belay governance and integration platform.

npm

@on-belay/sdk is the HTTP-only contract between the On Belay platform and an external fieldset (a Node service you write and host). The platform calls your service over a signed webhook on a schedule; you call the platform back through /api/sdk/* to reach connected integrations on behalf of enrolled orgs. No platform internals are imported. No customer credentials live in your code. Node 20+, one runtime dependency (jose).


Contents

  1. Install
  2. What is a fieldset?
  3. Architecture
  4. Prerequisites
  5. Quickstart — Hello World
  6. The webhook handler
  7. SDK API reference
  8. Webhook payload contract
  9. Embedded UI / dashboard tokens
  10. Migration from 1.0.0
  11. Troubleshooting
  12. Versioning + stability
  13. Links
  14. License

1. Install

npm install @on-belay/sdk@^2.2.0

Minimal usage — a complete signed-webhook receiver in five lines:

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

export const handler = createOnbelayWebhookHandler({
  secret: process.env.ONBELAY_WEBHOOK_SECRET!,
  fieldsetSlug: "my-fieldset",
  onTrigger: async ({ payload }) => { /* your work here */ },
})

Wrap that handler in any framework that exposes the raw request body (Next.js Route Handlers, Express, Fastify, Hono — see §6).


2. 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. The On Belay platform manages org enrollment, billing, admin configuration, credential storage and refresh for 60+ integrations (Shopify, HubSpot, Notion, Slack, NetSuite, …), signed webhook delivery on a daily schedule plus owner-triggered manual runs, audit logging, and rate limiting. Your service does the actual work: read the webhook, call connected integrations through the platform proxy, persist run state in your per-enrollment Neon branch, and record publish events for billing.

There are two protocols and two secrets:

  • Inbound (platform → you): HMAC-SHA256 signed with ONBELAY_WEBHOOK_SECRET. Verified by validateWebhookSignature (or the wrapper, createOnbelayWebhookHandler).
  • Outbound (you → platform): Authorization: Bearer ONBELAY_FIELDSET_TOKEN on every /api/sdk/* call. The token is read from your env var. It is never sent in the webhook payload.

3. Architecture

┌──────────────────────────────────┐         ┌─────────────────────────────┐
│  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 0 8 * * * UTC       │
│                                  │         │                             │
│  POST /api/sdk/*                 │────────▶│  proxy + token middleware   │
│   Authorization: Bearer          │  HTTPS  │   - decrypt org creds       │
│     ONBELAY_FIELDSET_TOKEN       │ Bearer  │   - sign upstream calls     │
│                                  │         │   - audit log + FieldsetRun │
│  Per-enrollment Neon branch      │         │                             │
│   NEON_CONNECTION_STRING         │         └────────────┬────────────────┘
└──────────────────────────────────┘                      │
                                                          ▼ HTTPS
                                            Shopify / HubSpot / Notion / …

Two arrows. Two secrets. The platform talks to upstream APIs on your behalf so credentials never reach your service.


4. Prerequisites

  • Node 20 or newer (fetch, AbortController, TextEncoder are built-in).
  • An On Belay account.
  • A fieldset registration: apply at https://app.onbelay.ai/developer/apply. On approval the platform creates a Railway service from your repo and injects the env vars below.
  • Environment variables (the platform injects all of these on provisioning; do not bake them into your repo):

| Variable | Purpose | |---|---| | ONBELAY_PROXY_URL | Base URL for /api/sdk/* calls. Always https://app.onbelay.ai — base URL only; the SDK appends paths internally. | | ONBELAY_FIELDSET_TOKEN | Bearer token attached to every platform call. The only place token plaintext lives outside the platform DB. | | ONBELAY_WEBHOOK_SECRET | HMAC-SHA256 secret for verifying inbound webhook signatures. | | ONBELAY_DASHBOARD_SECRET | HS256 secret for embedded-dashboard JWT validation (only required if you ship an embedded UI). |

Optional: NEON_CONNECTION_STRING (per-enrollment, injected when an org has a Neon branch), SENTRY_DSN, your own ANTHROPIC_API_KEY. The platform never shares its Anthropic key with external fieldsets.


5. Quickstart — Hello World

A fully working sample fieldset lives at packages/fieldset-hello-world/. It is the canonical reference fieldset — start there. The summary:

# 1. Clone the scaffold (or fork the Hello World fieldset).
git clone https://github.com/Junto-Systems/onbelay-hello-world-fieldset my-fieldset
cd my-fieldset

# 2. Set your slug.
sed -i '' 's/hello-world/my-fieldset/g' fieldset.manifest.ts package.json src/lib/onbelay-client.ts

# 3. Install + boot locally.
npm install
cp env.example .env.local
# Edit .env.local: ONBELAY_FIELDSET_SLUG, ONBELAY_WEBHOOK_SECRET (any string locally),
# ONBELAY_FIELDSET_TOKEN (any string locally), ONBELAY_PROXY_URL=https://app.onbelay.ai.
npm run dev

# 4. Sign and send a test webhook against your local server (no ngrok needed).
ONBELAY_WEBHOOK_SECRET="<same as .env.local>" \
ONBELAY_FIELDSET_SLUG="my-fieldset" \
ORG_ID="clk_local_test" \
  npm run sign:test-payload
# Copy the printed curl command and run it. Expected response:
#   {"ok":true}

# 5. Push to GitHub, then apply at https://app.onbelay.ai/developer/apply
#    with your repo URL and slug. The platform owner approves, provisions a
#    Railway service, injects env vars, and deploys.

# 6. Verify the live deploy.
curl https://<your-service>.up.railway.app/api/health
# → { "ok": true, "fieldset": "<slug>", ... }

# 7. Trigger a run from the platform UI:
#    /owner/fieldsets/<your-fieldset-id> → "Trigger run".
#    Within 5 minutes you should see a FieldsetRun row marked completed,
#    an AuditLog row with action="external_fieldset_proxy",
#    and an incremented PublishCounter row.

The scaffold's README covers the project layout, local-dev signing, Railway deploy, per-enrollment migration files, and the idempotency pattern in detail. The Hello World README shows the end-to-end verification queries (FieldsetRun, PublishCounter, AuditLog).


6. The webhook handler

createOnbelayWebhookHandler is the recommended entry point. It is framework-agnostic — it takes the raw body string + a headers map and returns { status, body, headers } you can translate into a framework response.

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

const handler = createOnbelayWebhookHandler({
  secret: process.env.ONBELAY_WEBHOOK_SECRET!,
  fieldsetSlug: "my-fieldset",
  maxAgeSeconds: 300, // optional. default 300.
  onTrigger: async ({ payload, rawBody }) => {
    // payload is the parsed WebhookPayload (see §8)
    // rawBody is the exact bytes used for HMAC verification
    // Throw → handler returns 500 → platform retries (Inngest, up to 3 attempts).
    // Return → handler returns 200.
  },
})

Validation behavior

The handler runs the spec §6 checks in order. Any failure returns the listed status without invoking onTrigger:

| Step | Outcome | HTTP | Body | |---|---|---|---| | Missing X-Onbelay-Signature header | 401 | {"error":"missing_signature"} | | HMAC mismatch | 401 | {"error":"invalid_signature"} | | X-Onbelay-Timestamp older than maxAgeSeconds (default 300) or >30s in the future | 401 | {"error":"invalid_signature"} | | Body is not JSON | 400 | {"error":"invalid_json"} | | payload.fieldsetSlugoptions.fieldsetSlug | 401 | {"error":"slug_mismatch"} | | onTrigger throws | 500 | {"error":"handler_failed"} | | onTrigger returns | 200 | {"ok":true} |

Retry semantics

Inngest treats your response as follows:

  • 2xx — success. FieldsetRun is marked completed.
  • 4xx — permanent failure. No retry. FieldsetRun is marked failed.
  • 5xx or timeout (>30s) — transient. Up to 3 retries with exponential backoff.

runId (see §8) is stable across retries within the same (enrollment, calendar day, triggerType). Dedupe on runId in your handler so a retry is cheap.

Next.js Route Handler example

// app/api/onbelay-webhook/route.ts
import { NextResponse } from "next/server"
import { createOnbelayWebhookHandler } from "@on-belay/sdk"
import { handleTrigger } from "@/lib/work"

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

export async function POST(request: Request): Promise<Response> {
  const rawBody = await request.text()
  const headers: Record<string, string | undefined> = {}
  request.headers.forEach((v, k) => { headers[k.toLowerCase()] = v })
  const result = await handler(rawBody, headers)
  return new NextResponse(result.body, { status: result.status, headers: result.headers })
}

export const dynamic = "force-dynamic"
export const runtime = "nodejs"

Express example

import express from "express"
import { createOnbelayWebhookHandler } from "@on-belay/sdk"

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

const app = express()
app.post(
  "/api/onbelay-webhook",
  express.raw({ type: "application/json" }), // CRITICAL: must verify against raw bytes
  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)
  },
)

Always pass the raw request bytes to the handler. Re-stringifying a parsed JSON object will not produce a byte-identical payload, and the HMAC will fail.


7. SDK API reference

Every HTTP-bound function shares one network contract:

  • Authorization: Bearer ${ONBELAY_FIELDSET_TOKEN} on every call.
  • Per-request timeout: 30 seconds (AbortController).
  • Retry: ONE retry, fixed 250 ms delay, only on transport failure (network error / AbortError) or HTTP 502/503/504. After the retry also fails, OnbelayTransportError is thrown.
  • Other 5xx (500, 505+): thrown immediately as OnbelayTransportError with attempts === 1. Not retried.
  • 4xx: never a transport failure. executeProxyCall returns a typed ProxyResult envelope; every other HTTP-bound function throws OnbelayProtocolError carrying { status, url, code }.

All /api/sdk/* success bodies are wrapped on the wire as { "data": T }; errors are { "error": { "code", "message" } }. The SDK unwraps the envelope for you — the return types below are the unwrapped T. If you bypass the SDK with curl, expect the wrapped wire shape.

Typed integration helpers

Added in 2.1.0. The package ships 75 integration modules — auto-generated typed wrappers over executeProxyCall, one per connected integration (shopify, hubspot, klaviyo, linear, …). 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.

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. There are 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 is a Shopify integration helper (not a top-level SDK function) — it 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: {},
  },
})

A typed helper is exactly as permitted as the raw executeProxyCall it wraps. It does not bypass proxy permission gates — 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.

OnbelayConfig

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
}

Every HTTP-bound function accepts an optional OnbelayConfig as the trailing argument. Defaults are read from process.env on every call — long-lived services pick up rotated tokens automatically without restart.

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"

The only sanctioned way for a fieldset to call a third-party API. Dispatched as POST /api/sdk/proxy with a JSON body of { orgId, integrationSlug, operationKey, path, method, body, queryParams }. The platform resolves credentials, refreshes tokens if needed, signs the upstream call, audits it, and proxies through.

The result envelope never throws on protocol errors — branch on ok / blocked instead of wrapping the call in a try/catch. Throws only on transport failure (OnbelayTransportError).

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

const result = await executeProxyCall<{ products: Array<{ id: number; title: string }> }>(
  orgId,
  "shopify",
  "shopify.products.list",
  "/admin/api/2024-01/products.json?limit=1",
  { method: "GET" },
  { fieldsetSlug: "my-fieldset" },
)

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

console.log(result.data.products[0]?.title)

The org must have the integration connected; your fieldset's requiredOperations must include operationKey; the org must be enrolled in your fieldset. Each of those gates returns a different ProxyErrorCode.

getOrgContext

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

interface OrgContext {
  orgId: string
  orgName: string
  connectedIntegrations: ConnectedIntegration[]
}

interface ConnectedIntegration {
  slug: string
  status: "active" | "error" | "pending"
  /** Server-allowlisted public config. shopify → { shopDomain }, others → {}. */
  extraConfig: Record<string, string | null>
}

GET /api/sdk/orgs/:orgId/context. Returns the calling fieldset's view of one enrolled org. The integration list is filtered to only integrations referenced in your requiredOperations. Encrypted credential columns (apiKeyEnc, apiSecretEnc) are never returned. extraConfig is server-allowlisted per integration.

Throws OnbelayProtocolError on org_not_enrolled, org_not_found, invalid_token, rate_limit_exceeded. Throws OnbelayTransportError on transport failure.

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

getFieldsetConfig

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

GET /api/sdk/orgs/:orgId/config?fieldset=<slug>. Returns the contents of OrgFieldset.config.fieldset (or {} if absent). The admin namespace — keys set by org admins through the platform UI — is server-protected and never returned.

interface MyConfig { lastSyncedAt?: string; greeting?: string }
const cfg = await getFieldsetConfig<MyConfig>(orgId, { fieldsetSlug: "my-fieldset" })

Throws OnbelayProtocolError on org_not_enrolled, fieldset_mismatch, invalid_token.

setFieldsetConfig

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

PATCH /api/sdk/orgs/:orgId/config. Server-side shallow merge into OrgFieldset.config.fieldset inside a SELECT FOR UPDATE → merge → UPDATE transaction (concurrent writes do not lose updates). The patch body is the merge — there is no envelope.

Forbidden: a top-level admin key in patch throws OnbelayProtocolError with code: "forbidden_namespace" (and the platform server enforces the same constraint independently).

Size caps:

  • SDK rejects patches > 32 KB (serialized) before they leave the process.
  • Server rejects merged config.fieldset > 64 KB.
await setFieldsetConfig(orgId, {
  lastSyncedAt: new Date().toISOString(),
  lastRunId: payload.runId,
}, { fieldsetSlug: "my-fieldset" })

Throws OnbelayProtocolError on invalid_patch, forbidden_namespace, config_too_large, payload_too_large, org_not_enrolled, fieldset_mismatch.

recordPublish

function recordPublish(
  orgId: string,
  contentType: string,
  metadata?: Record<string, unknown>,
  config?: OnbelayConfig,
): Promise<PublishResult>

interface PublishResult {
  count: number          // new running count after this publish
  freeAllowance: number  // 10 by default
  billable: boolean      // true once count > freeAllowance
}

POST /api/sdk/orgs/:orgId/billing/publish. Atomically increments the org's PublishCounter for (orgId, fieldsetId, contentType). Call at writeback time, not generation time — billing is incurred when content goes live for the org.

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

No idempotencyKey. The atomic upsert on (orgId, fieldsetId, contentType) is the only concurrency guarantee. Strict per-key idempotency requires a BillingIdempotency table that is deferred to a future release. Until then, dedupe on payload.runId in your handler before calling recordPublish so retries don't double-bill.

isEnrolled

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

GET /api/sdk/orgs/:orgId/enrolled?fieldset=<slug>. Returns true only when the calling fieldset has an active enrollment for orgId. Returns false for unknown orgs — the platform must not leak existence (no 404).

if (!(await isEnrolled(orgId, { fieldsetSlug: "my-fieldset" }))) return

getEnrolledOrgs

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

GET /api/sdk/enrollments. Returns the orgIds of every org with an active enrollment in the calling fieldset. The fieldsetId is derived server-side from the token — you cannot list another fieldset's enrollments.

const orgs = await getEnrolledOrgs({ fieldsetSlug: "my-fieldset" })
// → ["clk_abc123", "clk_def456"]

logAction

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

Added in 2.2.0. 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 audit-only — 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).

Throws OnbelayProtocolError on a 4xx (e.g. invalid_action when action fails the pattern or length check). Throws OnbelayTransportError on transport failure or a platform 5xx after the single retry.

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

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 — no HTTP. Constant-time HMAC-SHA256 compare via node:crypto.timingSafeEqual. When timestamp is supplied (the X-Onbelay-Timestamp header value), additionally rejects payloads older than maxAgeSeconds (default 300) or more than 30 seconds in the future. Never throws — returns false on any mismatch, malformed input, or thrown error.

You normally don't call this directly; createOnbelayWebhookHandler wraps it. Call it directly only when integrating with a framework the wrapper doesn't fit.

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

const ok = validateWebhookSignature(
  rawBody,
  req.headers["x-onbelay-signature"] as string,
  process.env.ONBELAY_WEBHOOK_SECRET!,
  req.headers["x-onbelay-timestamp"] as string,
  { maxAgeSeconds: 300 },
)
if (!ok) return res.status(401).json({ error: "invalid_signature" })

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>

Browser- and Node-compatible HS256 JWT verification (uses jose). Validates the dashboard context token issued when an org user opens your embedded UI inside the platform. Returns the decoded payload on success or null on any failure (expired, bad signature, malformed, non-string fields). Never throws.

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

const ctx = await validateDashboardToken(token, process.env.ONBELAY_DASHBOARD_SECRET!)
if (!ctx) return new Response("invalid_token", { status: 401 })
// ctx.orgId, ctx.userId, ctx.fieldsetSlug

createOnbelayWebhookHandler

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

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

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

Framework-agnostic factory. Returned function takes the raw body string + a header map (case-insensitive lookup) and returns { status, body, headers }. Validates HMAC, replay window, JSON shape, and fieldsetSlug match in that order. See §6 for the response matrix and full examples.

The constructor throws synchronously on missing secret, missing fieldsetSlug, or a non-function onTrigger. Build the handler once at module scope.

OnbelayClient

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<PublishResult>
  logAction(
    orgId: string,
    action: string,
    metadata?: Record<string, unknown>,
  ): Promise<{ ok: boolean }>
  isEnrolled(orgId: string): Promise<boolean>
  getEnrolledOrgs(): Promise<string[]>

  shopify(orgId: string, integrationSlug?: "shopify" | "shopify_temp"): ShopifySubClient
  // + one accessor per integration (hubspot, klaviyo, linear, …)
}

Convenience class that holds an OnbelayConfig and exposes the eight HTTP-bound functions as instance methods, plus one bound sub-client accessor per integration (see Typed integration helpers). Pure ergonomic wrapper — every method delegates to the equivalent free function or sub-client factory with the bound config. Constructor throws if fieldsetSlug is missing.

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

export const onbelay = new OnbelayClient({ fieldsetSlug: "my-fieldset" })

// Now per-call config is implicit.
await onbelay.recordPublish(orgId, "report")

Use it when one fieldset slug serves the whole process. The Hello World fieldset uses this pattern in src/lib/onbelay-client.ts.

Errors

OnbelayTransportError

class OnbelayTransportError extends Error {
  readonly status: number   // last observed HTTP status; 0 if no response
  readonly url: string      // the URL the SDK was hitting
  readonly attempts: number // 1 (non-retryable failure) or 2 (retry also failed)
}

Thrown by every HTTP-bound function on transport failure (network error, AbortError, or HTTP 502/503/504 after one retry). Other 5xx (500, 505+) throw immediately with attempts === 1. Catch in your handler and let the next scheduled run pick up the work — do not let it propagate past your webhook response.

OnbelayProtocolError

class OnbelayProtocolError extends Error {
  readonly status: number
  readonly url: string
  readonly code: string  // e.g. "org_not_enrolled", "fieldset_mismatch"
}

Thrown by HTTP-bound functions that return raw values (getOrgContext, getFieldsetConfig, setFieldsetConfig, recordPublish, logAction, isEnrolled, getEnrolledOrgs) when the platform returns a 4xx. executeProxyCall does NOT throw this — it returns the typed ProxyResult envelope so you can branch without a try/catch.

Public types

The full type surface re-exported from the package root:

| Type | Purpose | |---|---| | OnbelayConfig | Per-call config bag (proxyUrl, token, fieldsetSlug, fetch). | | ProxyResult<T> | Return envelope for executeProxyCall. | | ProxyErrorCode | The 9 platform-blocked error codes. | | OrgContext, ConnectedIntegration | getOrgContext return shape. | | EnrolledOrg | Forward-compatible richer enrollment shape (the function returns string[]; this is exposed for typed downstream usage). | | ContentType | Free-form string alias used by recordPublish. | | PublishResult | recordPublish return shape. | | LogActionResult | logAction return shape ({ ok: boolean }). | | ShopifySlug | "shopify" \| "shopify_temp" — accepted by the Shopify integration helpers. | | WebhookPayload | Inbound webhook body. See §8. | | WebhookHandlerOptions, WebhookHandlerContext, WebhookResult | createOnbelayWebhookHandler shapes. | | WebhookVerifyOptions | Options for validateWebhookSignature / handler. | | DashboardTokenPayload | Decoded JWT payload used by validateDashboardToken. |


8. Webhook payload contract

Every inbound webhook the platform sends has the following shape. Required fields are always present; optional fields are described below.

interface WebhookPayload {
  orgId: string
  fieldsetSlug: string
  triggerType:
    | "scheduled"
    | "manual"
    | "user_triggered"
    | "enrollment_changed"
    | "unenrollment"
  timestamp: string                 // ISO 8601, equal to X-Onbelay-Timestamp
  runId: string                     // stable per (enrollment, calendar day, triggerType)
  neonConnectionString?: string     // present only when org has a Neon branch
  config?: Record<string, unknown>  // present only when fieldset namespace is non-empty
  actor?: { userId: string; email: string }  // present only when triggerType === "manual"
}

Headers:

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

| Field | Required | Notes | |---|---|---| | orgId | yes | The enrolled org this run targets. | | fieldsetSlug | yes | Your fieldset slug. The handler verifies this matches options.fieldsetSlug. | | triggerType | yes | scheduled (daily 8am UTC) or manual (owner clicked "Trigger run"). The other three values are reserved for future platform features; the SDK type accepts them but the platform does not currently emit them. | | timestamp | yes | ISO 8601. Used together with maxAgeSeconds for replay protection. | | runId | yes | Deterministic: ofs_<orgFieldsetId>-<YYYYMMDD>-<triggerType>. Inngest retries on non-200 reuse the same runId — dedupe on this and you trivially survive retries. | | neonConnectionString | optional | Present only when this org has a Neon branch provisioned. Read/write on your branch only. | | config | optional | Present only when the fieldset namespace of OrgFieldset.config is non-empty. Contains only your namespace, never the admin namespace. | | actor | optional | Present only when triggerType === "manual". Identifies the platform owner who triggered the run. |

The token is not in the payload. It lives in process.env.ONBELAY_FIELDSET_TOKEN — single source of truth. The SDK reads the env var by default on every call so a rotated token is picked up without restart.

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.

Idempotency pattern

Inngest retries on non-200 responses reuse the same runId. The recommended pattern, persisted in your per-enrollment Neon branch:

CREATE TABLE IF NOT EXISTS my_fieldset_runs (
  run_id TEXT NOT NULL UNIQUE,
  org_id TEXT NOT NULL,
  status TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
const inserted = await pool.query(
  `INSERT INTO my_fieldset_runs (run_id, org_id, status)
   VALUES ($1, $2, 'pending')
   ON CONFLICT (run_id) DO NOTHING
   RETURNING run_id`,
  [payload.runId, payload.orgId],
)
if (inserted.rowCount === 0) return // already processed by an earlier delivery
// ... do work ...

The full webhook contract is specified in qa-brightline-dod-external-fieldset-sdk-v2.md §5.6 and the §8 payload reference of the developer docs.


9. Embedded UI / dashboard tokens

If you ship an embedded UI, the platform renders it as an iframe at /dashboard/fieldsets/[slug] with sandbox="allow-scripts allow-forms". The handshake:

  1. Iframe loads. It posts { type: "onbelay:ready" } to https://app.onbelay.ai.
  2. Platform issues a 15-minute HS256 JWT and posts { type: "onbelay:context", token, orgId, userId } back to the iframe.
  3. Iframe sends token to its own backend, which calls validateDashboardToken(token, ONBELAY_DASHBOARD_SECRET) and then performs proxy calls scoped to the validated orgId.
  4. On visibilitychange, the platform re-issues a fresh token.
// Inside your iframe (browser):
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 it in the browser,
  // and never bundle ONBELAY_FIELDSET_TOKEN or ONBELAY_DASHBOARD_SECRET.
  const res = await fetch("/api/dashboard-data", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ token: event.data.token }),
  })
  // ...render
})

window.parent.postMessage({ type: "onbelay:ready" }, "https://app.onbelay.ai")
// Your backend (Express/Next.js/Hono — same shape):
import { validateDashboardToken, executeProxyCall } from "@on-belay/sdk"

const ctx = await validateDashboardToken(token, process.env.ONBELAY_DASHBOARD_SECRET!)
if (!ctx) return new Response(JSON.stringify({ error: "invalid_token" }), { status: 401 })

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

Never call the proxy from the browser. ONBELAY_FIELDSET_TOKEN and ONBELAY_DASHBOARD_SECRET are server-side only.

The platform's CSP must include your Railway domain in frame-src for the iframe to render. This is configured by the platform when your fieldset is provisioned — if you see a blank iframe, contact On Belay support.

A worked example lives in packages/fieldset-hello-world/src/app/dashboard/page.tsx and src/app/api/dashboard-data/route.ts.


10. Migration 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/[email protected]. The steps below apply only to services still on 1.0.0.

@on-belay/[email protected] is a complete rewrite. The 1.0.0 SDK depended on platform internals and could not be installed cleanly outside the On Belay monorepo. 2.0.0 is HTTP-only, framework-agnostic, and has exactly one runtime dependency (jose).

Removed exports

| Removed in 2.0.0 | Replacement | |---|---| | createScheduler | The platform schedules dispatch now. Your service exposes a webhook only. | | createOrgRunner | The webhook handler IS the runner. Use createOnbelayWebhookHandler. | | createPortfolioRunner | Portfolio fieldsets are out of scope for 2.0.0. | | defineFieldset | The Fieldset row in the platform DB is the manifest source of truth, populated from your application form. | | getNeonCacheClient | NEON_CONNECTION_STRING arrives in the env (and the webhook payload). Use pg, postgres, or any client directly. | | Anything from cache.ts | The cache helpers imported platform internals and are removed entirely. |

These exports do not exist in 2.0.0 — importing them fails at install or compile time.

Other breaking changes

  • recordPublish no longer accepts idempotencyKey (Decision B; coming back in a future release with a BillingIdempotency table). Dedupe on payload.runId in your handler instead.
  • executeProxyCall signature changed. New positional arguments: (orgId, integrationSlug, operationKey, path, options?, config?). The fieldsetSlug argument is gone — the SDK reads it from OnbelayConfig.
  • The fieldset token is no longer included in the webhook payload. Read it from process.env.ONBELAY_FIELDSET_TOKEN. The SDK does this by default.
  • ONBELAY_PROXY_URL is base URL onlyhttps://app.onbelay.ai. The SDK appends paths internally. The 1.0.0 value ended in /api/sdk/proxy; the platform team runs a backfill script for existing services on cutover.

Migration checklist

  1. npm install @on-belay/[email protected]
  2. Delete imports of the removed exports listed above.
  3. Replace your scheduler/runner setup with createOnbelayWebhookHandler from §6.
  4. Update executeProxyCall call sites for the new positional signature.
  5. Drop any reads of idempotencyKey from recordPublish results.
  6. Stop reading the token from the webhook payload — that field is gone.
  7. Update Railway env var ONBELAY_PROXY_URL to https://app.onbelay.ai (base URL only).
  8. Add a runId dedupe step in your handler. Strongly recommended.

11. Troubleshooting

| Symptom | Cause + fix | |---|---| | Webhook returns 401 invalid_signature | HMAC mismatch. Most common cause: re-stringifying parsed JSON before validating. Capture the raw request body before parsing. In Express use express.raw({ type: "application/json" }); in Next.js Route Handlers use await req.text() first. Second cause: ONBELAY_WEBHOOK_SECRET was rotated and your service was not redeployed. | | Webhook returns 401 with timestamp out of window | The X-Onbelay-Timestamp header is older than maxAgeSeconds (default 300) or more than 30 seconds in the future. Replay-protection is firing. If your server clock is skewed, fix NTP. If you need a longer window for a legitimate reason, raise maxAgeSeconds on the handler — but understand you are weakening replay defense. | | Webhook returns 401 slug_mismatch | options.fieldsetSlug in your handler does not equal payload.fieldsetSlug. Either your env var (ONBELAY_FIELDSET_SLUG) drifted from the platform's registered slug, or the platform routed a payload for a different fieldset to your URL. Confirm Fieldset.slug in the platform DB. | | Token returns 401 invalid_token / token_revoked / token_expired | The ONBELAY_FIELDSET_TOKEN is invalid, was revoked, or has passed its expiry. Coordinate a token rotation with the platform owner — see the runbook in /developer/docs#token-rotation. | | Proxy returns 403 operation_not_permitted | The operationKey you passed is not in your fieldset's requiredOperations. Edit your registration to include it (or pick one that's already declared). | | 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 org_not_enrolled | This org is not enrolled in your fieldset, or enrollment was paused. Check OrgFieldset.status = "active". | | 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. (The SDK does not 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. Catch 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 3× for the same runId | Your handler returned 5xx or timed out (>30 s). Implement idempotency on runId so retries are cheap, and offload long work to a background queue while returning 200 quickly. | | ONBELAY_PROXY_URL ends 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. The platform's backfill script (scripts/backfill-onbelay-proxy-url.ts) handles existing services on cutover. |


12. Versioning + stability

Follows semver. The version line is the contract:

  • Major bump for any removed export, removed field on a public type, or signature change. Includes any change that requires customer code to be edited.
  • Minor bump for new optional fields on WebhookPayload, new exports, or new optional arguments on existing functions.
  • Patch bump for bug fixes that do not change observable behavior.

Stability guarantees in 2.x

  • Every export listed in §7 has a stable signature for the lifetime of the 2.x line.
  • The webhook payload may grow (new optional fields). Handlers must tolerate unknown fields.
  • The wire envelope ({ data: T } / { error: { code, message } }) is stable.
  • The transport contract (30 s timeout, one retry on 502/503/504, fixed 250 ms delay) is stable.
  • New error codes may be added to ProxyErrorCode as new minor releases. Keep your switch exhaustive checks tolerant of unknown codes.

Deprecation policy

A function or field is marked deprecated in a minor release with a // @deprecated JSDoc tag and a migration note in the changelog. It is removed in the next major release. Removed primitives are documented in the "Removed in X.0.0" callout block of the developer docs page.


13. Links


14. License

MIT. See LICENSE at the repository root.