---
title: "Headless SaaS"
url: /patterns/headless-saas
---

# Headless SaaS

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

```mermaid title="Deal: its lifecycle"
stateDiagram-v2
  [*] --> opened: open
  opened --> qualified: qualify
  qualified --> won: win
  opened --> lost: lose
  qualified --> lost: lose
```

- **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](/workflow.md) on their Events: CRUD is the floor. With no `from`, your Customer writes the field, and the [Dataset API](/patterns/dataset-api.md) file becomes a Headless SaaS when its `sources` and every `from` go.

```ts title="definition.ts"
import { define, secret } from 'apis.dev'  // [!code --]
import { define } from 'apis.dev'  // [!code ++]

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

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.

**create**

```sh title="Request"
curl https://pipeline.apis.dev/companies \
  -H "Authorization: Bearer $PIPELINE_KEY" \
  --json '{"name":"Acme","domain":"acme.com"}'
```

```http title="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"
}
```

**get**

```sh title="Request"
curl https://pipeline.apis.dev/companies/company_1 \
  -H "Authorization: Bearer $PIPELINE_KEY"
```

```http title="Response"
HTTP/1.1 200 OK
X-Cost: price=0, currency="USD"
X-Request-Id: req_2dc8be23d78d02570ee3

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

**get by key**

```sh title="Request"
curl https://pipeline.apis.dev/companies/acme.com \
  -H "Authorization: Bearer $PIPELINE_KEY"
```

```http title="Response"
HTTP/1.1 200 OK
X-Cost: price=0, currency="USD"
X-Request-Id: req_abaab07988e66cd6576a

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

**list**

```sh title="Request"
curl https://pipeline.apis.dev/companies?domain=acme.com&limit=50 \
  -H "Authorization: Bearer $PIPELINE_KEY"
```

```http title="Response"
HTTP/1.1 200 OK
X-Cost: price=0, currency="USD"
X-Request-Id: req_ea1dd29332afc974c134

{
  "items": [
    {
      "id": "company_1",
      "name": "Acme",
      "domain": "acme.com",
      "createdAt": "2026-10-24T09:00:00Z",
      "updatedAt": "2026-10-24T09:00:00Z"
    }
  ],
  "total": 1,
  "next": null
}
```

**update**

```sh title="Request"
curl -X PATCH https://pipeline.apis.dev/companies/company_1 \
  -H "Authorization: Bearer $PIPELINE_KEY" \
  --json '{"name":"Acme Inc."}'
```

```http title="Response"
HTTP/1.1 200 OK
X-Action-Id: act_2
X-Cost: price=0, currency="USD"
X-Request-Id: req_47bafc37faaf0d937892

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

**delete**

```sh title="Request"
curl -X DELETE https://pipeline.apis.dev/companies/company_1 \
  -H "Authorization: Bearer $PIPELINE_KEY"
```

```http title="Response"
HTTP/1.1 200 OK
X-Action-Id: act_3
X-Cost: price=0, currency="USD"
X-Request-Id: req_e0627ba7efa530eb73ac

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

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

**curl**

```sh title="Request"
curl https://pipeline.apis.dev/companies/company_1/deals \
  -H "Authorization: Bearer $PIPELINE_KEY"
```

**TypeScript**

```ts title="TypeScript"
import { Pipeline } from 'pipeline'

const api = new Pipeline({ key: process.env.PIPELINE_KEY })
const { data, meta } = await api.listDeals('company_1')
// data: { 'items': [{ 'id': 'deal_1', 'title': 'Pilot', … }
// meta.cost: { price: 0, currency: 'USD' }
```

**Python**

```python title="Python"
import os
from pipeline import Pipeline

api = Pipeline(key=os.environ["PIPELINE_KEY"])
data, meta = api.list_deals("company_1")
# data: { "items": [{ "id": "deal_1", "title": "Pilot", … }
# meta.cost: { "price": 0, "currency": "USD" }
```

**CLI**

```sh title="Terminal"
$ npx pipeline login --key $PIPELINE_KEY
$ npx pipeline deals list company_1
{ "items": [{ "id": "deal_1", "title": "Pilot", … }
price 0 USD · req_88eb811626931380df5a
```

**MCP**

```sh title="Request"
curl https://pipeline.apis.dev/mcp \
  -H "Authorization: Bearer $PIPELINE_KEY" \
  --json '{
  "jsonrpc": "2.0", "id": 1, "method": "tools/call",
  "params": { "name": "listDeals", "arguments": { "id": "company_1" } }
}'
```

```http title="Response"
HTTP/1.1 200 OK
X-Cost: price=0, currency="USD"
X-Request-Id: req_55a7c051d846f85cffb3

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [ … ],
    "structuredContent": { "items": [ … ] },
    "_meta": {
      "cost": { "price": 0, "currency": "USD" },
      "requestId": "req_55a7c051d846f85cffb3"
    }
  }
}
```


```http title="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](/reference.md#role) that makes it, and a member without it gets 403.

```ts title="definition.ts · stage"
      open: [], qualify: 'opened', win: 'qualified',  // [!code --]
      open: [], qualify: 'opened',  // [!code ++]
      win: { in: 'qualified', role: 'manager' },  // [!code ++]
```

A move the lifecycle does not allow answers 409, with the moves it does allow, as the quickstart's [early win](/quickstart/headless-saas.md#win-it-too-early) shows.

## Integrations
Each Customer can keep their records in their own CRM. Your Company is then served from it through their [Integration](/reference.md#integration): the paths, Events, SDK types and price stay the same.

```ts title="definition.ts"
  sources: { crm: { system: 'crm', scope: 'customer' } },  // [!code ++]
  Company: {
    name: 'string', domain: 'url!#',  // [!code --]
    name:   { type: 'string', from: 'crm', map: 'name' },  // [!code ++]
    domain: { type: 'url!#',  from: 'crm', map: 'website' },  // [!code ++]
    deals: '<-Deal.company',
  },
```

A change in their CRM arrives as `Company.updated`, Observed, on the change feed and on every [Watch](/reference.md#events) that picks it.

```sh title="Request"
curl https://pipeline.apis.dev/events?type=Company.updated&after=evt_3 \
  -H "Authorization: Bearer $PIPELINE_KEY"
```

```http title="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.

```json title="/pricing"
{
  "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" }
  ]
}
```

| Pattern | Noun: the shape you promise | Verb: what can be done to it | Source: where its data comes from | Trigger: what reacts to a change | Offer: the price |
| --- | --- | --- | --- | --- | --- |
| [Headless SaaS](/patterns/headless-saas.md): Sell a home for your Customers' records, priced by plan and seat. Your Customer writes it. | your Customers' records and their Relationships | create, get, list, update and delete, and a lifecycle | each Customer's own CRM, as an Integration | a Workflow on Deal.won | plans with seats that include calls, and a price past them |

[Start a Headless SaaS](/quickstart/headless-saas.md) →