Advanced

BetterSpace Developer Portal

A public developer platform for BetterSpace — API docs, SDKs, webhooks, and API-key management for third-party integrations.

Co-Founder & CTO
2024 – present
Solo build
2024
  • Next.js
  • TypeScript
  • OpenAPI
  • Supabase
  • Tailwind
  • MDX

Problem

BetterSpace grew into a platform other systems needed to talk to — HR tools, corporate wellness dashboards, and partner integrations all wanted programmatic access to bookings, assessments, and webhooks. Ad-hoc "email me the API shape" support does not scale, and an undocumented API is effectively a private one. The platform needed a real developer experience: discoverable docs, copy-pasteable examples, authenticated API keys, and a webhook catalog partners could self-serve against.

Target user: third-party developers and partner engineering teams integrating with BetterSpace, plus internal engineers who need a single source of API truth.

Constraints:

  • Docs must never drift from the real API — a lying spec is worse than none.
  • Partners must self-serve API keys and understand webhook signatures without a support ticket.
  • It has to be fast, searchable, and feel like first-class documentation (think Stripe, not a dumped Swagger page).

Business goal: make BetterSpace integrable so partners can build on it without hand-holding, turning the platform into an ecosystem.

Architecture

The developer portal is a Next.js (App Router) site backed by the same Supabase platform as the core product, with an OpenAPI-driven documentation layer.

  • OpenAPI spec as the source of truth. The REST surface (~40 endpoints) is described in an OpenAPI document; the portal renders reference docs from it so the docs cannot drift from the contract.
  • MDX guides sit alongside the reference — narrative "getting started", auth, and webhook walkthroughs authored in MDX with runnable examples.
  • API-key management is issued and scoped through Supabase with RLS, so a partner key only ever sees its own data.
  • Webhook catalog documents all 30 signed event types, their payloads, and the HMAC-SHA256 verification recipe.
  • Static generation + edge caching keeps docs pages sub-50ms on cache hits.

Engineering Decisions

OpenAPI as the contract, docs generated from it. The single most important call: reference documentation is derived from the OpenAPI spec, not hand-written. When the API changes, the spec changes, and the docs follow. This kills the classic failure mode where docs silently rot. Tradeoff: keeping the spec rigorous is real work, but it is work that pays for itself every release.

MDX for guides, generated pages for reference. Narrative content (auth flows, quickstarts) is human-written MDX where prose matters; endpoint reference is generated where consistency matters. Using both plays each to its strength.

Reuse the core Supabase platform for keys and scoping. Rather than a separate identity system, API keys are issued through the same Supabase project with the same RLS model that secures the product. A partner key is just another RLS principal. Tradeoff: careful policy design, but zero duplicated auth surface.

Static-first for speed. Docs are read far more than they change, so the portal is statically generated and edge-cached — documentation should feel instant.

Source Code Walkthrough

Webhook signature verification (the recipe partners copy)

The portal documents — and the platform enforces — HMAC-SHA256 verification. This is the exact snippet partners lift into their own handlers.

// Verify a BetterSpace webhook before trusting its payload.
// Why: 30 event types drive partner automation; an unverified event could
// trigger a spoofed workflow. Always verify on the RAW body, before parsing.
import { createHmac, timingSafeEqual } from "node:crypto";
 
export function verifyBetterSpaceWebhook(
  rawBody: string,
  signatureHeader: string,
  secret: string,
): boolean {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  // Length check guards timingSafeEqual, which throws on length mismatch.
  return a.length === b.length && timingSafeEqual(a, b);
}

Why this way: verifying the raw body before JSON parsing means a tampered payload never reaches business logic; timingSafeEqual prevents signature discovery via response timing. Improvements: publish official SDKs so partners do not reimplement this at all.

Rendering reference docs from OpenAPI

// docs/reference/[operationId]/page.tsx
// Why: reference pages are generated from the OpenAPI spec so they cannot drift
// from the real contract. One static page per operation.
import { getOpenApiSpec, operations } from "@/lib/openapi";
 
export function generateStaticParams() {
  return operations().map((op) => ({ operationId: op.operationId }));
}
 
export default async function OperationPage({ params }) {
  const { operationId } = await params;
  const op = getOpenApiSpec().operationById(operationId); // method, path, params, schemas
  return <ReferenceView operation={op} />;
}

Performance considerations: every operation is a static page (sub-50ms cached). Improvements: generate typed client snippets per language directly from the schema.

Issuing a scoped API key onto the platform's RLS model

A partner key is not a bespoke credential — it is an RLS principal. Issuance stores only a hash of the key and binds it to explicit scopes, so the same policies that secure the product secure the API.

// api/keys/issue.ts
// Why: reusing the product's RLS model means a partner key can *only* ever see
// its own data, enforced by Postgres, not by remembering to filter in app code.
// We store a hash; the plaintext key is shown once and never persisted.
import { randomBytes, createHash } from "node:crypto";
 
export async function issueApiKey(partnerId: string, scopes: Scope[]) {
  const plaintext = `bsk_live_${randomBytes(24).toString("base64url")}`;
  const keyHash = createHash("sha256").update(plaintext).digest("hex");
 
  await supabaseAdmin.from("api_keys").insert({
    partner_id: partnerId,
    key_hash: keyHash, // never the plaintext
    scopes, // e.g. ["bookings:read", "webhooks:manage"]
    created_at: new Date().toISOString(),
  });
 
  // Returned exactly once — the partner must store it now or rotate later.
  return { apiKey: plaintext, scopes };
}

Why this way: storing only the SHA-256 hash means a database leak does not expose usable keys; the plaintext is surfaced a single time. Scopes are an explicit array checked by RLS policies, so widening access is a policy change, not an app-code change. Improvements: key rotation with an overlap window and per-key request analytics.

Failing the build when the spec is malformed

Generating docs from the spec is only trustworthy if the spec itself is valid. The loader validates and indexes operations at build time, so a broken spec fails next build instead of shipping empty reference pages.

// lib/openapi.ts
// Why: the whole "docs can't drift" guarantee assumes the spec is well-formed.
// Validate once at module load; a duplicate operationId or missing schema is a
// build failure, not a silently blank page in production.
import { parse } from "@/lib/openapi-validate";
 
const spec = parse(rawSpecJson); // throws on invalid OpenAPI
 
const byId = new Map<string, Operation>();
for (const op of spec.allOperations()) {
  if (!op.operationId) {
    throw new Error(`Operation ${op.method} ${op.path} is missing operationId`);
  }
  if (byId.has(op.operationId)) {
    throw new Error(`Duplicate operationId: ${op.operationId}`);
  }
  byId.set(op.operationId, op);
}
 
export const operations = () => [...byId.values()];
export const getOpenApiSpec = () => ({ operationById: (id: string) => byId.get(id) });

Performance considerations: validation and indexing happen once at build, not per request, so the runtime cost of a reference page is a map lookup. Improvements: diff the spec against the previous release in CI and surface a changelog of added/removed/changed operations automatically.

Technical Challenges

  • Spec/doc drift. Solved structurally by generating reference docs from the OpenAPI spec rather than maintaining them by hand.
  • Self-serve API keys with correct scoping. API keys map onto the platform's RLS model so a partner can only ever read its own data — no bespoke auth path.
  • Webhook trust. Documenting and enforcing HMAC-SHA256 verification so partner integrations are secure by default.
  • Documentation performance. Static generation + edge caching to keep the portal instant, because slow docs do not get read.

A tradeoff worth being honest about: generating reference docs from the spec moves the failure mode, it does not remove it. Hand-written docs drift from the API; generated docs instead drift from reality if the spec drifts from the implementation. The spec is the source of truth for the docs, but nothing in the docs pipeline forces the running service to match the spec. That gap is why the spec is also used as a contract test against the live API (below) — otherwise I would have simply relocated the lie from the docs to the spec. Naming this explicitly matters, because "docs are generated" is often oversold as "docs are correct," and those are different claims.

The second real challenge is API-key scoping being a data-modelling problem, not a middleware problem. It is tempting to check scopes in a request middleware, but that only protects the paths you remember to wrap. Binding scopes to RLS policies means an un-wrapped or newly-added endpoint is still constrained by the partner's row-level access — the guarantee is enforced one layer below the application, where it cannot be forgotten.

Code Quality & Testing

Type safety. strict TypeScript throughout, and the OpenAPI types are the spine: request/response shapes for the reference views are derived from the spec, so a page cannot render a field the contract does not declare. Scope is a literal union, not string, so an invalid scope on a key never type-checks. Supabase-generated row types back the api_keys table access, keeping the issuance code and the schema in lockstep.

Testing strategy — the spec is a contract test, not just a doc source. The single most important test asserts the running API conforms to the spec, which is what makes "docs can't drift" a real guarantee rather than a hopeful one. Each documented operation is exercised against the live service and its response is validated against the spec's schema:

// contract/openapi.contract.test.ts
// Why: generating docs from the spec only prevents drift if the SERVICE also
// matches the spec. This closes that gap — a response that violates the schema
// fails CI, so the spec (and therefore the docs) cannot silently lie.
import { operations, validateAgainstSchema } from "@/lib/openapi";
 
describe("live API conforms to the OpenAPI contract", () => {
  for (const op of operations()) {
    test(`${op.method} ${op.path} matches its documented schema`, async () => {
      const res = await callWithFixture(op);
      const result = validateAgainstSchema(op, res.status, res.body);
      expect(result.errors).toEqual([]); // any drift is a hard failure
    });
  }
});

Webhook signature verification tests. The verification recipe is the code partners copy, so it is tested for the failure modes that matter: a tampered body fails, a valid signature passes, and a length-mismatched signature is rejected before timingSafeEqual can throw. This proves the snippet in the docs is the snippet that is safe.

// webhooks/verify.test.ts
test("rejects a tampered body", () => {
  const body = JSON.stringify({ event: "booking.created" });
  const sig = sign(body, secret);
  expect(verifyBetterSpaceWebhook(body + "x", sig, secret)).toBe(false);
});
 
test("accepts a genuine signature and is constant-time-safe on length mismatch", () => {
  const body = JSON.stringify({ event: "booking.created" });
  expect(verifyBetterSpaceWebhook(body, sign(body, secret), secret)).toBe(true);
  expect(verifyBetterSpaceWebhook(body, "deadbeef", secret)).toBe(false); // no throw
});

API-key RLS scoping tests. As with the rest of the platform, scoping is verified at the database layer: authenticate as partner A's key and assert that partner B's rows are invisible, and that an endpoint outside the key's scopes is rejected by policy rather than by app code. This is what guarantees a key issued by issueApiKey is genuinely confined.

-- rls/partner_isolation.test.sql
set local request.jwt.claims = '{"partner_id":"partner-A","scopes":["bookings:read"]}';
select is_empty(
  $$ select * from bookings where partner_id = 'partner-B' $$,
  'partner A cannot see partner B bookings'
);

Error handling & observability. Key lookups distinguish "unknown key" from "key lacks scope" with distinct status codes, without revealing whether a given key exists. The contract suite doubles as observability: when it fails in CI it names the exact operation and the schema path that diverged. Docs performance (edge cache hit rate, cold-render p95) and per-key usage are the monitored signals.

Module boundaries. Three clean seams: the OpenAPI loader (validate + index, build-time only), the presentation layer (static reference + MDX guides), and the platform layer (Supabase-issued keys on RLS). Webhook verification is a single pure function with no dependencies, which is precisely why partners can lift it verbatim.

CI enforcement. tsc --noEmit, ESLint, the OpenAPI contract suite, the webhook verification tests, and the RLS scoping suite all gate merges. The spec is linted for validity (the build-time loader throws on duplicate operationIds or missing schemas), so a malformed spec fails the build rather than producing blank reference pages.

Benchmarks

The portal documents ~40 REST endpoints and 30 signed webhook event types. Statically generated pages serve in ~45ms on an edge cache hit and ~380ms on a cold render. See the charts above.

40endpoints
30events
Docs page load (static, cached) (ms)

Lessons Learned

  • What succeeded: OpenAPI-as-source-of-truth. Docs stopped drifting the day they became generated artifacts.
  • What failed first: early hand-written endpoint docs went stale within a release; regenerating from the spec fixed it permanently.
  • What I would redesign: ship official typed SDKs from the start so partners never hand-roll signature verification or request code.
  • Remaining tech debt: per-language client snippet generation is manual.
  • Future improvements: an in-browser API playground with a partner's own key and live responses.

Media

  • Recording of the reference docs and the webhook catalog.
  • Screenshots of API-key issuance and the getting-started guide.
  • Diagram of the OpenAPI-spec → generated-reference + MDX-guides pipeline.
  • Developer portal: developer.betterspace.care
  • The portal itself is the documentation — OpenAPI reference, MDX guides, and the webhook event catalog.

Build Logs

  • Requirements & planning. Framed the goal as developer experience: discoverable, accurate, self-serve. That made OpenAPI-as-truth the anchor.
  • Architecture. Next.js App Router, OpenAPI-generated reference, MDX guides, Supabase-issued API keys, static-first hosting.
  • Implementation. Built the spec, the reference renderer, the webhook catalog with the verification recipe, and API-key issuance on RLS.
  • Testing. Verified generated docs against the live contract; tested key scoping and webhook signature verification.
  • Deployment. Statically generated, edge-cached at developer.betterspace.care.
  • Monitoring. Docs performance and API-key usage.
  • Retrospective. Generating docs from the contract is the decision that keeps the portal trustworthy; SDKs are the next step.