Headlessly
Billing

Product

What you sell -- software, services, addons, and bundles backed by Stripe Products.

Schema

import { Noun } from 'digital-objects'

export const Product = Noun('Product', {
  name: 'string!',
  slug: 'string##',
  description: 'string',
  tagline: 'string',
  type: 'Software | Service | Addon | Bundle',
  icon: 'string',
  image: 'string',
  features: 'string',
  highlights: 'string',
  status: 'Draft | Active | Archived',
  visibility: 'Public | Private | Hidden',
  featured: 'boolean',
  plans: '<- Plan.product[]',
  stripeProductId: 'string##',
})

Fields

FieldTypeRequiredDescription
namestringYesProduct display name
slugstringNoURL-safe identifier (unique, indexed)
descriptionstringNoFull product description
taglinestringNoShort marketing tagline
typeenumNoProduct category: Software, Service, Addon, or Bundle
iconstringNoIcon identifier or URL
imagestringNoProduct image URL
featuresstringNoFeature list (serialized)
highlightsstringNoKey selling points (serialized)
statusenumNoLifecycle state: Draft, Active, or Archived
visibilityenumNoAccess control: Public, Private, or Hidden
featuredbooleanNoWhether to highlight in catalogs and pricing pages
stripeProductIdstringNoStripe Product ID (unique, indexed)

Relationships

FieldDirectionTargetDescription
plans<-Plan.product[]All pricing plans for this product

Verbs

VerbEventDescription
createCreatedCreate a new product (also creates in Stripe)
updateUpdatedUpdate product fields (syncs to Stripe)
deleteDeletedSoft-delete the product

Verb Lifecycle

Every verb follows the full conjugation pattern -- execute, before hook, after hook:

import { Product } from '@headlessly/billing'

// Execute
await Product.create({
  name: 'Headlessly Pro',
  slug: 'pro',
  type: 'Software',
  status: 'Active',
  visibility: 'Public',
  tagline: 'Everything you need to run your startup',
})

// Before hook -- runs before creation
Product.creating(product => {
  console.log(`About to create product ${product.name}`)
})

// After hook -- runs after Stripe product is created
Product.created(product => {
  console.log(`Product ${product.name} live with Stripe ID ${product.stripeProductId}`)
})

Status State Machine

  create()
(none) ──────→ Draft

       update()  │

              Active

       update()  │

             Archived

Valid transitions:

FromVerbTo
--createDraft
DraftupdateActive
ActiveupdateArchived
ArchivedupdateActive

Archived products are hidden from new subscriptions but remain visible on existing ones. Products are never hard-deleted -- they are archived to preserve billing history.

Product Types

TypeDescription
SoftwareSaaS product with recurring billing
ServiceProfessional services, consulting, implementation
AddonFeature or capacity addon to a base product
BundlePackage of multiple products at a combined price

Cross-Domain Patterns

Products connect to Content for marketing pages and to Analytics for revenue tracking:

import { Product } from '@headlessly/billing'

// When a product goes active, create a landing page
Product.updated((product, $) => {
  if (product.status === 'Active' && product.visibility === 'Public') {
    await $.Content.create({
      title: product.name,
      slug: `products/${product.slug}`,
      type: 'Page',
      status: 'Published',
    })
  }
})

Query Examples

SDK

import { Product } from '@headlessly/billing'

// Find all active public products
const products = await Product.find({ status: 'Active', visibility: 'Public' })

// Get a specific product with its plans
const product = await Product.get('product_k7TmPvQx', { include: ['plans'] })

// Find featured products
const featured = await Product.find({ featured: true, status: 'Active' })

// Find by slug
const pro = await Product.find({ slug: 'pro' })

MCP

headless.ly/mcp#search
{ "type": "Product", "filter": { "status": "Active", "visibility": "Public" } }
headless.ly/mcp#fetch
{ "type": "Product", "id": "product_k7TmPvQx", "include": ["plans"] }
headless.ly/mcp#do
const products = await $.Product.find({ status: 'Active' })

REST

GET https://headless.ly/~acme/products?status=Active&visibility=Public
GET https://headless.ly/~acme/products/product_k7TmPvQx
POST https://headless.ly/~acme/products
Content-Type: application/json

{ "name": "Headlessly Pro", "type": "Software", "status": "Active" }

Event-Driven Patterns

React to product lifecycle events:

import { Product } from '@headlessly/billing'

// Track product launches
Product.created((product, $) => {
  $.Event.create({
    type: 'billing.product_created',
    data: { name: product.name, type: product.type },
  })
})

// When a product is archived, notify active subscribers
Product.updated((product, $) => {
  if (product.status === 'Archived') {
    const plans = await $.Plan.find({ product: product.$id })
    for (const plan of plans) {
      const subs = await $.Subscription.find({ plan: plan.$id, status: 'Active' })
      for (const sub of subs) {
        await $.Message.create({
          type: 'Email',
          to: sub.customer,
          subject: `${product.name} is being retired`,
        })
      }
    }
  }
})

On this page