Skip to content

Start a Headless SaaS

A live API your Customers keep records in, with plans and seats paid to your Stripe, written as one file of Nouns, moves and plans.

You need Node 20 and a GitHub account. The first deploy answers at once on pipeline.apis.dev; your own domain is one line.

  1. Start the folder

    Terminal
    $ npx apis.dev init headless-saas
    ✓ wrote definition.ts: a Headless SaaS
      next: npx apis.dev dev
  2. Write the file

    definition.ts
    23 lines
    import { define } from 'apis.dev'
    
    /** A pipeline tracker your Customers log their deals in, each in its own space. */
    export default define({
      name: 'pipeline', domain: 'pipeline.apis.dev',
      Company: {                                    // Noun: your Customer writes it
        name: 'string', domain: 'url!#',
        deals: '<-Deal.company',                    // a Relationship
      },
      Deal: {
        title: 'string', amount: 'decimal(12,2)',
        company: '->Company.deals',
        stage: {                                    // Verbs: the lifecycle
          open: [], qualify: 'opened', win: 'qualified',  // []: from no state, so it creates the Deal
          lose: ['opened', 'qualified'],
        },
      },
      offer: {                                      // Offer: plans, and a price past them
        Free: { month: 0, seats: 1, includes: { 'Deal.open': 25 } },
        Team: { month: 49, seats: 5, seat: 9, includes: { 'Deal.open': 1000 } },
        'Deal.open': 0.001,
      },
    })

    With no from, your Customer writes the field: create, get, list, update, delete and restore. stage adds a Verb per move. Free stops at what it includes; Team draws the price past it.

  3. Try it locally

    Terminal
    $ npx apis.dev dev
    ✓ every Interface on http://localhost:8787, against your Test twin
      reloads on every save
  4. Publish it

    Terminal
    $ npx apis.dev deploy
    ✓ signed in with GitHub
    ✓ definition.ts: pipeline on pipeline.apis.dev
    ✓ Stripe connected: each Customer pays into your own account
    ✓ 2 Nouns, 15 Verbs, 2 plans, 1 price
        Company  create, get, list, update, delete, restore
        Deal     open, get, list, update, delete, restore, qualify, win, lose
        Free            0 a month, 1 seat, 25 Deal.open included
        Team            49 a month, 5 seats, 1,000 Deal.open included
        Deal.open       0.001 a call, past a plan
    ✓ published v1 to https://pipeline.apis.dev: 11 Interfaces
        REST              /companies, /deals
        CLI               npx pipeline
        MCP               /mcp
        SDK               npm i pipeline, pip install pipeline
        Docs              /docs
        OpenAPI           /openapi.json
        llms.txt          /llms.txt
        agents.json       /.well-known/agents.json
        Pricing           /pricing
        Landing site      /
        Developer portal  /dashboard
    ✓ Test key: pipeline_test_PUzMCRBrEcNUxGDRDwFcRU42AR8CCc21e958

    Deploy counts the Verbs from the file and prints each one by name. Each Customer who signs up gets a space of their own, and every record they write stays in it.

  5. Add a Company

    POST pipeline.apis.dev/companies
    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 create answers 201 with the record, where it lives, and the Action it ran as.

  6. Open a Deal

    POST pipeline.apis.dev/deals
    Request
    curl https://pipeline.apis.dev/deals \
      -H "Authorization: Bearer $PIPELINE_KEY" \
      --json '{"title":"Pilot","amount":"5000.00","company":"company_1"}'
    Response
    HTTP/1.1 201 Created
    Location: /deals/deal_1
    X-Action-Id: act_5
    X-Cost: price=0, currency="USD"
    X-Request-Id: req_019e21b5e33a04d5e91e
    
    {
      "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"
    }

    Opening the Deal wrote Deal.opened. Free includes it, so its price is 0.

  7. Get the webhook

    POST pipeline.apis.dev/watches
    Request
    curl https://pipeline.apis.dev/watches \
      -H "Authorization: Bearer $PIPELINE_KEY" \
      --json '{
        "events": ["Deal.opened"],
        "url": "https://hooks.acme.com/hooks/pipeline"
      }'
    Response
    HTTP/1.1 201 Created
    Location: /watches/watch_1
    X-Action-Id: act_4
    X-Cost: price=0, currency="USD"
    X-Request-Id: req_8db1e5ea781c9709a731
    
    {
      "id": "watch_1",
      "events": ["Deal.opened"],
      "url": "https://hooks.acme.com/hooks/pipeline",
      "secret": "whsec_7Kq2vN4xR7mW1pT9",
      "createdAt": "2026-10-24T09:08:00Z",
      "updatedAt": "2026-10-24T09:08:00Z"
    }
    Delivery
    POST /hooks/pipeline HTTP/1.1
    Host: hooks.acme.com
    X-Webhook-Attempt: 1
    X-Webhook-Signature: t=1792833000,v1=2c067386fcd9006ece95389e2fde364074ad34388decdfc0a31f3edce14d0252
    
    {
      "id": "evt_2",
      "type": "Deal.opened",
      "at": "2026-10-24T09:10:00Z",
      "seq": 2,
      "data": {
        "id": "deal_1",
        "title": "Pilot",
        "amount": "5000.00",
        "company": "company_1",
        "stage": "opened"
      }
    }
    Receive it
    22 lines
    import { createHmac, timingSafeEqual } from 'node:crypto'
    
    // verify(request, secret): true when the delivery was signed with your secret in the last 5 minutes
    export async function verify(request: Request, secret: string) {
      const header = request.headers.get('X-Webhook-Signature') ?? ''
      const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')))
      if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
      const want = createHmac('sha256', secret).update(`${t}.${await request.text()}`).digest()
      const got = Buffer.from(v1 ?? '', 'hex')
      return got.length === want.length && timingSafeEqual(got, want)
    }
    
    // The receiver: verify a copy, then read the Event once by its id.
    const seen = new Set<string>()
    export default {
      async fetch(req: Request) {
        if (!(await verify(req.clone(), process.env.PIPELINE_WEBHOOK_SECRET!))) return new Response(null, { status: 401 })
        const event = await req.json()
        if (!seen.has(event.id)) seen.add(event.id)  // a retry carries the same id
        return new Response(null, { status: 204 })
      },
    }

    Your Customer makes a Watch, and each Deal.opened goes out signed, retried until their URL answers. npx apis.dev events listen --forward localhost:3000/hooks tests the receiver first.

  8. Win it too early

    POST pipeline.apis.dev/deals/deal_1/win
    Request
    curl -X POST https://pipeline.apis.dev/deals/deal_1/win \
      -H "Authorization: Bearer $PIPELINE_KEY"
    Response
    HTTP/1.1 409 Conflict
    Content-Type: application/problem+json
    X-Cost: price=0, currency="USD"
    X-Request-Id: req_66b1b3cad16f500d936f
    
    {
      "type": "https://pipeline.apis.dev/docs/errors#wrong_state",
      "title": "Conflict",
      "status": 409,
      "detail": "A Deal in opened cannot win; win moves from qualified.",
      "code": "wrong_state",
      "instance": "req_66b1b3cad16f500d936f",
      "allowed": ["qualify", "lose"]
    }

    A Deal moves only along its lifecycle: win needs qualified first, and the refusal lists the moves it allows.

  9. Qualify it, then win it

    Request
    curl -X POST https://pipeline.apis.dev/deals/deal_1/qualify \
      -H "Authorization: Bearer $PIPELINE_KEY"
    Response
    HTTP/1.1 200 OK
    X-Action-Id: act_6
    X-Cost: price=0, currency="USD"
    X-Request-Id: req_b3219e966dabdd964790
    
    {
      "id": "deal_1",
      "title": "Pilot",
      "amount": "5000.00",
      "company": "company_1",
      "stage": "qualified",
      "createdAt": "2026-10-24T09:10:00Z",
      "updatedAt": "2026-10-24T09:20:00Z"
    }

    Winning wrote Deal.won.

  10. Kick off when a Deal is won

definition.ts
12 lines
import { define, workflow } from 'apis.dev'
  Deal: {
    …
    kickoff: workflow(async ($, deal) => {
      const { plan } = await $.generative('Draft the kickoff plan for {company.name}', deal,
        { returns: { plan: 'string' } })
      await $.human('Approve the plan', { plan }, { role: 'manager' })
      const at = await $.step('stamp it', () => new Date().toISOString())
      return { plan, at }
    }),
  },
  on: { 'Deal.won': 'Deal.kickoff' },
Request
curl https://pipeline.apis.dev/actions/act_8 \
  -H "Authorization: Bearer $PIPELINE_KEY"
Response
HTTP/1.1 200 OK
X-Cost: price=0, currency="USD"
X-Request-Id: req_1154ca02bc01e341ac7b

{
  "id": "act_8",
  "verb": "Deal.kickoff",
  "status": "pending",
  "reason": "AwaitingWorker",
  "poll": "/actions/act_8"
}

Deal.won starts the kickoff once. It waits on a manager, who completes the step with their own Key, and the run ends with its output. See Workflow.

  1. Outgrow the Free plan
POST pipeline.apis.dev/deals
Request
curl https://pipeline.apis.dev/deals \
  -H "Authorization: Bearer $PIPELINE_KEY" \
  --json '{"title":"Renewal","amount":"1200.00","company":"company_1"}'
Response
HTTP/1.1 402 Payment Required
Content-Type: application/problem+json
X-Cost: price=0, currency="USD"
X-Request-Id: req_02f4f9b0fd2d3af756ba

{
  "type": "https://pipeline.apis.dev/docs/errors#plan_limit",
  "title": "Payment Required",
  "status": 402,
  "detail": "Free includes 25 Deal.open a month; this is the 26th.",
  "code": "plan_limit",
  "instance": "req_02f4f9b0fd2d3af756ba",
  "offers": [
    {
      "kind": "plan",
      "name": "Team",
      "price": 49,
      "checkout": "https://pipeline.apis.dev/checkout/team"
    }
  ]
}

The call past what Free includes answers 402 with Team in its offers; after Checkout, the same call goes through. On Team, a teammate past its seats answers 402 for one more seat.

Result

Build: you wrote 18 lines: two Nouns, a Relationship, a lifecycle and an Offer, then 8 more for a Workflow on Deal.won. Launch: 15 Verbs answer on pipeline.apis.dev, a wrong move answers 409, and each change goes out on a signed webhook. Monetize: Free includes 25 Deal.open a month, and the call past them answers 402 with Team on Stripe Checkout. You wrote no CRUD, no state checks, no Events and no webhook code.

definition.ts
31 lines
import { define, workflow } from 'apis.dev'

/** A pipeline tracker your Customers log their deals in, each in its own space. */
export default define({
  name: 'pipeline', domain: 'pipeline.apis.dev',
  Company: {                                    // Noun: your Customer writes it
    name: 'string', domain: 'url!#',
    deals: '<-Deal.company',                    // a Relationship
  },
  Deal: {
    title: 'string', amount: 'decimal(12,2)',
    company: '->Company.deals',
    stage: {                                    // Verbs: the lifecycle
      open: [], qualify: 'opened', win: 'qualified',  // []: from no state, so it creates the Deal
      lose: ['opened', 'qualified'],
    },
    kickoff: workflow(async ($, deal) => {
      const { plan } = await $.generative('Draft the kickoff plan for {company.name}', deal,
        { returns: { plan: 'string' } })
      await $.human('Approve the plan', { plan }, { role: 'manager' })
      const at = await $.step('stamp it', () => new Date().toISOString())
      return { plan, at }
    }),
  },
  on: { 'Deal.won': 'Deal.kickoff' },
  offer: {                                      // Offer: plans, and a price past them
    Free: { month: 0, seats: 1, includes: { 'Deal.open': 25 } },
    Team: { month: 49, seats: 5, seat: 9, includes: { 'Deal.open': 1000 } },
    'Deal.open': 0.001,
  },
})

Next steps