Skip to content

Headless SaaS

Sell a home for your Customers' records, priced by plan and seat.

Deal: its lifecycle
Free
0 a month · 1 seat · 25 Deal.open included
Team
49 a month · 5 seats, 9 each past them · 1,000 Deal.open included
Deal.open
0.001 a call past a plan

Records

Nouns and their Relationships, CRUD and lifecycles, Roles and seats, and Workflows on their Events: CRUD is the floor. With no from, your Customer writes the field, and the Dataset API file becomes a Headless SaaS when its sources and every from go.

definition.ts
20 lines
import { define, secret } from 'apis.dev'
import { define } from 'apis.dev'

/** Any company by its domain, with its headcount and sector. */
export default define({
  name: 'companyfacts', domain: 'companyfacts.apis.dev',
  sources: {
    firms: { origin: 'https://firms.example.com', key: secret('FIRMS_KEY') },
  },
  Company: {
    domain:    { type: 'url!#',   from: 'firms', map: 'website' },
    domain:    'url!#',
    employees: { type: 'number?', from: 'firms', map: 'headcount' },
    employees: 'number?',
    sector:    { type: 'string?', from: 'firms', map: 'industry' },
    sector:    'string?',
  },
  offer: { 'Company.get': 0.002, 'Company.search': 0.005 },
  offer: { Free: { month: 0, seats: 1 } },
})

Create, get, list, update, delete and restore answer on each Noun, in each Customer's own space. A record answers at its id and at its !# key, so /companies/acme.com is company_1. A delete keeps the record, and POST /companies/company_1/restore brings it back.

Request
curl https://pipeline.apis.dev/companies \
  -H "Authorization: Bearer $PIPELINE_KEY" \
  --json '{"name":"Acme","domain":"acme.com"}'
Response
HTTP/1.1 201 Created
Location: /companies/company_1
X-Action-Id: act_1
X-Cost: price=0, currency="USD"
X-Request-Id: req_df59a11235e22d4bc20f

{
  "id": "company_1",
  "name": "Acme",
  "domain": "acme.com",
  "createdAt": "2026-10-24T09:00:00Z",
  "updatedAt": "2026-10-24T09:00:00Z"
}

A Relationship reads from either side: the Deals of a Company are one call.

GET pipeline.apis.dev/companies/company_1/deals
Request
curl https://pipeline.apis.dev/companies/company_1/deals \
  -H "Authorization: Bearer $PIPELINE_KEY"
Response
HTTP/1.1 200 OK
X-Cost: price=0, currency="USD"
X-Request-Id: req_88eb811626931380df5a

{
  "items": [
    {
      "id": "deal_1",
      "title": "Pilot",
      "amount": "5000.00",
      "company": "company_1",
      "stage": "opened",
      "createdAt": "2026-10-24T09:10:00Z",
      "updatedAt": "2026-10-24T09:10:00Z"
    }
  ],
  "total": 1,
  "next": null
}

The lifecycle

You write the moves, never the states: each state is its move's past form, and each move writes that Event. A move may name the Role that makes it, and a member without it gets 403.

definition.ts · stage
3 lines
      open: [], qualify: 'opened', win: 'qualified',
      open: [], qualify: 'opened',
      win: { in: 'qualified', role: 'manager' },

A move the lifecycle does not allow answers 409, with the moves it does allow, as the quickstart's early win shows.

Integrations

Each Customer can keep their records in their own CRM. Your Company is then served from it through their Integration: the paths, Events, SDK types and price stay the same.

definition.ts
7 lines
  sources: { crm: { system: 'crm', scope: 'customer' } },
  Company: {
    name: 'string', domain: 'url!#',
    name:   { type: 'string', from: 'crm', map: 'name' },
    domain: { type: 'url!#',  from: 'crm', map: 'website' },
    deals: '<-Deal.company',
  },

A change in their CRM arrives as Company.updated, Observed, on the change feed and on every Watch that picks it.

Request
curl https://pipeline.apis.dev/events?type=Company.updated&after=evt_3 \
  -H "Authorization: Bearer $PIPELINE_KEY"
Response
HTTP/1.1 200 OK
X-Cost: price=0, currency="USD"
X-Request-Id: req_9385689ed087052f4167

{
  "items": [
    {
      "id": "evt_4",
      "type": "Company.updated",
      "at": "2026-10-24T11:00:00Z",
      "seq": 4,
      "via": "crm",
      "provenance": "Observed",
      "data": { "id": "company_1", "name": "Acme Corporation" }
    }
  ],
  "next": null
}

Plans and seats

A plan is a price a month for its seats, with the calls it includes. A plan at month 0 stops at them and its refusal offers the next plan; a paid plan draws the price past them from credits. A seat past the plan's own is billed at seat, and keep: true keeps current subscribers on a plan's old price.

/pricing
15 lines
{
  "currency": "USD",
  "rates": [
    { "name": "Free",
      "price": 0, "unit": "month",
      "seats": 1,
      "includes": { "Deal.open": 25 } },
    { "name": "Team",
      "price": 49, "unit": "month",
      "seats": 5, "seat": 9,
      "includes": { "Deal.open": 1000 } },
    { "name": "Deal.open",
      "price": 0.001, "unit": "call" }
  ]
}
Headless SaaS: its five blocks
Nounthe shape you promiseVerbwhat can be done to itSourcewhere its data comes fromTriggerwhat reacts to a changeOfferthe price
your Customers' records and their Relationshipscreate, get, list, update and delete, and a lifecycleeach Customer's own CRM, as an Integrationa Workflow on Deal.wonplans with seats that include calls, and a price past them

Start a Headless SaaS →