Skip to content

API

How api.sb works, traced through one data business.

The StartupBuilder API serves every Startup at api.sb as plain HTTPS and JSON. Every route is compiled from a Verb, so learning one route teaches them all.

One namespace per Startup

A Startup's namespace is its Domain. openhours.dev sells verified opening hours for four million storefronts to developers and the coding agents that work for them, at $0.002 per lookup. Every record it owns lives under https://api.sb/openhours.dev.

The namespace holds the platform's Nouns, such as Offers and Orders, and the Startup's own. openhours.dev declares a Storefront Noun, so /openhours.dev/storefronts serves its data. https://api.sb/openhours.dev/openapi.json describes both, and its Customers generate clients from it.

Routes are compiled from Verbs

Nobody writes a route by hand. Each Noun gets a collection named by its plural, and each Verb gets a POST route named by itself:

CallRouteName
List storefrontsGET /openhours.dev/storefrontsStorefront.list
Read one OfferGET /openhours.dev/offers/offer_1Offer.get
Place an OrderPOST /openhours.dev/orders/placeOrder.place
Subscribe to an OfferPOST /openhours.dev/offers/offer_1/subscribeOffer.subscribe

A Verb that acts on an existing record takes its id in the path. A Verb that brings a record into being sits on the collection. GET on any Verb route previews it and stores nothing.

The operation name is the contract. Offer.subscribe is the OpenAPI operationId, the SDK method, the CLI command, the MCP call, and the rate-limit key. Adding a Verb to a Noun adds all five, its Event, and its docs row in one write, so no Interface lags another.

Keys

Every request carries a key as a bearer token. The key names the namespace, and the Permissions of its Role decide which Verbs it may run. Limits bound how far each one goes.

Terminal
curl "https://api.sb/openhours.dev/storefronts?city=Lisbon&limit=20" \
  -H "Authorization: Bearer $STARTUP_BUILDER_API_KEY"

A key that starts sk_test_ reaches the test environment, where no money moves and no message leaves. A sk_live_ key reaches live. Code moves from one to the other unchanged, as authentication and environments explain.

Reads return an envelope

Every response is one JSON object with the same keys. Reading an Offer returns the record under its Noun's name, plus links to read and Verbs to run:

JSON
{
  "api": "https://api.sb/openhours.dev",
  "$type": "Offer",
  "$id": "https://api.sb/openhours.dev/offers/offer_1",
  "offer": {
    "$id": "https://api.sb/openhours.dev/offers/offer_1",
    "$type": "Offer",
    "price": { "amount": 0.002, "currency": "USD" },
    "interval": "month",
    "terms": "Verified opening hours for any storefront, billed per lookup."
  },
  "links": {
    "self": "https://api.sb/openhours.dev/offers/offer_1",
    "up": "https://api.sb/openhours.dev/offers"
  },
  "actions": { "subscribe": "https://api.sb/openhours.dev/offers/offer_1/subscribe" }
}

actions lists only the Verbs this caller may run on this record in its current state. A client that follows actions never calls a Verb the record refuses, and an agent never needs the state rules in its prompt. The envelope covers lists, cursors, and every key.

Writes return an Action

Every write is an Action: a durable record of one Verb, its input, its status, and who ran it for whom. The response carries the Action beside the record it changed:

Terminal
curl -X POST https://api.sb/openhours.dev/offers/offer_1/subscribe \
  -H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
  -H "Idempotency-Key: signup-91c2" \
  -H "Content-Type: application/json" \
  -d '{"quantity":1}'
JSON
{
  "api": "https://api.sb/openhours.dev",
  "action": {
    "$id": "https://api.sb/openhours.dev/actions/action_1",
    "operation": "Offer.subscribe",
    "status": "done"
  },
  "subscription": {
    "$id": "https://api.sb/openhours.dev/subscriptions/subscription_1",
    "$type": "Subscription",
    "status": "active"
  },
  "links": { "self": "https://api.sb/openhours.dev/actions/action_1" }
}

The Mode decides when the caller hears back. try runs and stores nothing, send returns a pending Action at once, and do waits for the finish. A pending Action names its reason, such as awaitingInput or awaitingTime, so the caller knows whether to wait or act.

An Action over a Limit stays pending until its Approval is decided. A retry with the same Idempotency-Key returns the first Action and runs nothing. Modes covers each case.

Errors name the fix

Every error is an RFC 9457 problem whose type is a page on this site. Its fix and next fields carry the remedy, so an agent recovers without a search. A test subscription to an Offer that is still a draft is refused:

JSON
{
  "type": "https://startupbuilder.dev/code/api/errors/refused",
  "title": "Refused",
  "status": 422,
  "detail": "Offer offer_2 is a draft, so nobody can subscribe to it.",
  "fix": "Publish the Offer, then subscribe again.",
  "next": "https://api.sb/openhours.dev/offers/offer_2/publish",
  "instance": "https://api.sb/openhours.dev/actions/action_2"
}

A refused Action keeps its record forever. A retry runs a new Action linked to the old one, and history is never rewritten. Errors and testing lists every problem type and how to reproduce it in test.

Events

Every write emits one Event, named by its Noun and the past tense of its Verb: Offer.subscribed, Order.paid. A Watch delivers the Events a selector matches to a URL:

Terminal
curl -X POST https://api.sb/openhours.dev/watches \
  -H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"selector":"Offer.subscribed","url":"https://openhours.dev/hooks/signups"}'

Each delivery is a signed POST, retried until acknowledged and replayable from any point. Webhooks shows how to verify the signature.

The SDK makes the same calls as typed TypeScript.