Schedules and triggers

The running jobjob_7Hq2 · working · $49.00 held

On a subscription, on.schedule mints jobs on dates and periods, derived from the minted Service's own clocks, and on.event mints one when an event arrives, each routed to the Service it names. A gap, a late start, a client who changes and a client who leaves each mint by a stated rule.

on answers one question: what work happens on its own once someone subscribes? Subscriptions owns the sbn_… object and its charges; this page owns what it mints.

Two lines on a plan beside the four-line Translation, from Every month:

grow/subscription.ts
  on: { schedule: { monthly: { at: 'every month', order: translation } }, event: { 'page.changed': translation } },
  price: { usd: 149, every: 'month', includes: { translation: 4 } },

On the Service

KeyWrittenLeft outDoes
onTriggersnoneWhat starts jobs on a subscription: dates in schedule, events in event, each minting this Service or the one it names. Needs price.every.

on, written out

KeyWrittenLeft outDoes
schedule{ <name>: Schedule }noneDates, by name: { check: 'every monday' }, or { annual: { order: annualReturn, each: 'tax_year', at: '1mo after end' } }.
eventstring[] | ReadonlynoneEvents, by name, from your other Services' jobs on the subject, or from the buyer's systems through POST /events. A list mints this Service; a map routes each event to the Service it names.

Each schedule, written out

KeyWrittenLeft outDoes
orderAnyServicethis ServiceThe Service each date mints.
atstring | string[]requiredWhen: a date below, or with each, a span before one of the minted Service's clocks or after its period ends.
each<period of the minted Service>noneOne mint per period, its period set on the order: 'tax_year', 'quarter'.
when(sub) => booleanalwaysMints only when it holds, read at each date from the subscription as it stands.
input(on, sub) => fieldsthe subscription's input, as the minted Service takes itThe order's fields. on is the civil date the occurrence was owed, never the time it fired.
catch_up'all' | 'latest' | 'open''all'What a gap mints, and with 'open', what a late start mints.

On the price

KeyWrittenLeft outDoes
price.everyEverynoneA subscription, sbn_…: Subscriptions. on needs it.

One plan routes its dates and events to several small Services, each under 40 lines. Every job it mints shares the subscription's subject and policy, and names it as from.

captured/subscription-on.txt · magic values: none
$ cat services/plan.ts
import { Service } from 'services.dev'
import annualReturn from './annual-return.ts'
import estimate from './estimate.ts'
import noticeResponse from './notice-response.ts'
import question from './question.ts'

// The plan a business buys: its year routed to four small Services, each with its allowance. The
// schedule mints the return and the estimates on their dates; a question or a notice mints its Service when it comes.
export default Service({
  name: 'Tax Plan',
  input: { ein: 'text: such as 12-3456789', tax_year: 'text: such as 2026' },
  output: { letter: 'text' },

The order is a subscription, with what it mints next:

captured/subscription-on.txt · magic values: none
$ npx services.dev plan ein=12-3456789 tax_year=2026 --max 2400
sbn_7Hq2 · plan · active · cycle 1 · 2026-10-25 → 2027-10-25 · $2,400.00 per year
  allowances  annual-return 0 of 1 · estimate 0 of 4 · question 0 of 2 · notice-response 0 of 1
  schedule    annual → annual-return · next 2027-02-01
  schedule    estimate → estimate · next 2026-12-05
  event       question.asked → question
  event       notice.received → notice-response

Each date mints its Service, and AI delivers it, with people only where its file puts them:

captured/subscription-on.txt · magic values: none
$ npx services.dev tick 2027-04-06T00:00:00Z
tc_7Hq2 · 2026-10-25 14:02:11 → 2027-04-06 00:00:00 · 28 fired
  2026-12-05 00:00:00  sbn_7Hq2  schedule estimate → job_7Hq2 estimate · $0.00 · allowance 1 of 4
  2026-12-05 00:00:00  job_7Hq2  draft → review
  2026-12-05 00:00:00  job_7Hq2  delivered · collected $0.00
  2026-12-12 00:00:00  job_7Hq2  accepted · earned $0.00
  2027-02-01 00:00:00  sbn_7Hq2  schedule annual → job_7Hq3 annual-return · period 2026 · $0.00 · allowance 1 of 1

An event sent to POST /events mints the Service it routes to, on the subject its data names:

captured/subscription-on.txt · 200 · subscription.minted · event question.asked → job_7Hv5 question · $0.00 · magic values: none
→ POST /events {"type":"question.asked","id":"question-2","data":{"ein":"12-3456789","question":"Can I deduct the van?"}}
← 200 · subscription.minted · event question.asked → job_7Hv5 question · $0.00
WrittenMints on
'every day'each day
'every monday', or any day of the weekthat day, each week
'every week'each Monday
'every month'the 1st of each month
'every quarter'1 January, 1 April, 1 July and 1 October
'every year'1 January
'Feb 1'that date each year; 'Feb 29' only in a leap year
'2027-02-01'that date, once
['Apr 5', 'Jun 5', 'Sep 5', 'Dec 5']each of them
{ each: 'quarter', at: '10d before pay' }each period of the minted Service, ten civil days before that job's own pay clock, rolled: 5 April for a 15 April date
{ each: 'tax_year', at: '1mo after end' }each period, a month after it ends, counted from the day after its last day: 1 February for a calendar year, 1 August for a June year end

Each date fires at the calendar's rings_at. With each, the date comes from the minted Service's own clock and period, so the Tax Plan lists no date the return already knows, and plan prints its source: estimate → next 2027-04-05 (10d before pay 2027-04-15). input gets the civil date owed, never the time fired, so a mint 40 minutes late still orders the right year.

catch_up says what a schedule mints for dates it missed:

catch_upMints
'all', left outevery occurrence owed since the subscription started, each its own period, oldest first; period_taken keeps each once
'latest'only the newest owed occurrence
'open'as 'all', and at subscribe, each earlier period whose clocks are still open, oldest first, inside the allowance

Beta Labs, whose year ends 30 June, buys the Tax Plan on 10 August 2027, its extension already filed. plan previews the late start:

captured/tax-cases.txt · magic values: owner.email = signs@example.test, metadata.sandbox.outside = {"reject":{"file.US-NY":["ein_mismatch"]}}
$ npx services.dev plan --at 2027-08-10 ein=98-7654321 "entity=C corp" jurisdictions=US,US-CA year_end=06-30 extended_to=2028-04-15
tax-plan for 98-7654321 · tax year ends 06-30 · from 2027-08-10
  annual    would mint now: period 2027 (2026-07-01 → 2027-06-30), its file date still open · catch_up 'open'
  annual    next 2028-08-01 (1mo after end 2028-06-30) · period 2028

The order mints the open return at once, its file date extended, its date to pay unmoved:

captured/tax-cases.txt · magic values: owner.email = signs@example.test, metadata.sandbox.outside = {"reject":{"file.US-NY":["ein_mismatch"]}}
$ npx services.dev tax-plan "business=Beta Labs <books@beta.example>" "owner=Lin Okafor <lin@beta.example>" ein=98-7654321 "entity=C corp" jurisdictions=US,US-CA books=https://books.example/beta year_end=06-30 extended_to=2028-04-15 --max 2400 --test-clock tc_7Hq5
sbn_7Hr1 · tax-plan · active · cycle 1 · 2027-08-10 → 2028-08-10 · $2,400.00 per year
  minted    2027-08-10  catch_up open → job_4Ty1 annual-return · period 2027 · $0.00 · allowance 1 of 1
  clocks    job_4Ty1 file · due 2028-04-15, the client's extension (extended_to) · US, US-CA
            job_4Ty1 pay · due 2027-10-15 · hard · about $210 a month late

An event that mints needs your own id for it. The same id sent again answers the first result, replayed: true, and mints nothing more:

captured/subscription-event-replay.txt · 200 · subscription.minted · event notice.received → job_7Hq2 notice-response · $0.00 · trg_z2iVyv18fm · minted · replayed: true · magic values: none
→ POST /events {"type":"notice.received","id":"CP2000-0042","data":{"ein":"12-3456789","notice":"CP2000"}}
← 200 · subscription.minted · event notice.received → job_7Hq2 notice-response · $0.00 · trg_z2iVyv18fm · minted · replayed: true
When the clientThe mints
uses an allowance$0 and counted; past it, offered at the job's own price: Subscriptions
updates the subscriptionthe next mint takes the new input; jobs already minted keep theirs
cancelsstop when the paid cycle ends; jobs in flight finish, and the subject's open dates are handed back

Acme registers in Texas in March 2027, so its 2027 return files five jurisdictions and its 2026 return stays at four:

captured/tax-cases.txt · magic values: owner.email = signs@example.test, metadata.sandbox.outside = {"reject":{"file.US-NY":["ein_mismatch"]}}
$ npx services.dev subscriptions update sbn_7Hq2 jurisdictions=US,US-CA,US-NY,US-NJ,US-TX --test-clock tc_7Hq4
sbn_7Hq2 · tax-plan · active · updated 2027-03-08 · jurisdictions + US-TX
  next mint   annual 2028-02-01 · period 2027 · US, US-CA, US-NY, US-NJ, US-TX · Texas due 05-15, its own extension on 05-164
  keeps       job_4Tx3 · period 2026 · US, US-CA, US-NY, US-NJ, as minted 2027-02-01
WhenThe runtimeEvent
a cycle endsrenews, resets every allowance and posts the cycle's charge, before that instant's schedules mint; Subscriptions has the charge, a declined one and a cancelsubscription.renewed
a schedule's date comesmints its Service on the subscription's subject, inside an allowance at $0subscription.minted, allowance.used
a mint passes an allowanceoffers the job at its own price, 402 OFFER, with the order in nextallowance.exceeded
a mint names a period the subject holdsrecords the mint as refused, and charges nothingsubscription.minted
a subscription starts after a schedule's datewith catch_up: 'open', mints each earlier period whose clocks are still open, oldest first; otherwise waits for the next datesubscription.minted
a date comes while a renewal is unpaidmints the job and holds it, never releasing it until the renewal collectssubscription.minted
the subscription is updatedthe next mint takes the new input; jobs already minted keep theirssubscription.updated
a cancel's cycle endsminting stops; jobs in flight finish, and the subject's open dates are handed back to the clientsubscription.canceled, clock.outlived

The tick runs renewals first and schedules second, so a job minted on a renewal day counts in the new cycle, and money last, so its charge collects in the same instant.

AppShowsActs
Customer portaleach subscription: its cycle, each allowance used of included, what one more costs, and every date it mints on nextcancel at the end of the cycle
Owner consoleevery subscription, its cycle's charge on the ledger, and each job it minted with its fromcancel one, at the end of its cycle

The sbn_ object, on four surfaces: Subscriptions.

A refused mint charges nothing. Schedules, catch_up included, and events inside an allowance run live on Launch; an event routed past the subscription, such as apis.tax's fact.restated, on Team. Every kind of time, by plan: Keeping time.

OpAPICLIMCPSDKSiteDocsApp
service.orderPOST /jobs, POST /:service<service> k=v …order_<service><service>.order()/<service>: the order form10 error pagescustomer portal
event.inboundPOST /eventsevents send <type>send_eventevents.send()4 error pagescustomer portal
subscription.listGET /subscriptions, GET /owner/subscriptionssubscriptions listlist_subscriptionssubscriptions.list()1 error pagecustomer portal, owner console
subscription.getGET /subscriptions/:id, GET /owner/subscriptions/:idsubscriptions get <subscription>get_subscriptionsubscriptions.get()1 error pagecustomer portal, owner console
subscription.cancelPOST /subscriptions/:id/cancel, POST /owner/subscriptions/:id/cancelsubscriptions cancel <subscription>cancel_subscriptionsubscriptions.cancel()1 error pagecustomer portal, owner console

Objects: sbn_. Events: subscription.created, subscription.renewed, subscription.minted, subscription.updated, allowance.used, allowance.exceeded, subscription.canceled.

Operations has every column of every row.

CodeCategoryHTTPWhy
event_id_requiredinvalid_request422An event that mints a job on a subscription is one occurrence: it needs your id for it.
input_invalidinvalid_request422The order doesn't match the Service's input.
period_takenconflict409A job of this Service already holds the subject's period: one live or ended job per period.
When the file won't loadThrown byWhy
subscription_unpricedService()Service Glossary: on mints jobs on a subscription, and the price has no every. Add every: 'month', 'quarter' or 'year' to the price: one charge per cycle.
every_unknownService()Service Glossary: price.every is 'fortnight'. A subscription is charged every day, week, month, quarter, year.

The lines that use it in the example brands, as their files write them, and what check read aloud for each file.

legal · services/docket-watch.ts · 10 lines
  on: { schedule: { check: 'every monday' }, event: { 'office_action.issued': trademarkResponse } },
  price: { usd: 12, every: 'month' },
npx services.dev check · captured/legal-walk.txt
services/docket-watch.ts · 10 lines · $12 per month, charged at the start of each month.

The legal example runs every one of its files, captured.

tax · services/tax-plan.ts · 18 lines
  on: {
    schedule: {
      annual: { order: annualReturn, each: 'tax_year', at: '1mo after end', catch_up: 'open' },
      estimate: { order: estimate, each: 'quarter', at: '10d before pay', when: sub => sub.input.entity === 'C corp' },
    },
    event: { 'question.asked': question, 'notice.received': noticeResponse, 'fact.restated': amendedReturn },
  },
  price: { usd: 2_400, every: 'year', includes: { 'annual-return': 1, 'information-returns': 25, estimate: 4, question: 12, 'notice-response': 2 } },
npx services.dev check · captured/tax-check.txt
services/tax-plan.ts · 18 lines · $2,400 per year, charged at the start of each year, 1 annual-return, 25 information-returns payees, 4 estimate, 12 question and 2 notice-response included each year.

The agent's order buys a plan, and a client who leaves cancels one.

A year of the Tax Plan, as it minted:

captured/tax-years.txt · magic values: owner.email = signs@example.test, metadata.sandbox.asks = {"organizer.books":"after 160d","organizer.assets":"waive","organizer #2":"after 2d","w9":"after 19d"}, metadata.sandbox.outside = {"reject":{"file.US-NY":["ein_mismatch"]}}, metadata.sandbox.events = { notice.received: { at: 2027-11-14T15:00:00Z, data: { notice, reply_by, tax_years } } }
$ npx services.dev tick 1y --test-clock tc_7Hq4 --market normal
  2027-02-01 14:00:00  sbn_7Hq2  schedule annual → job_4Tx3 annual-return · period 2026 · $0.00 · allowance 1 of 1
  2027-04-05 13:00:00  sbn_7Hq2  schedule estimate → job_4Tx4 estimate · period 2027-Q1 · $0.00 · allowance 1 of 4
  2027-06-05 13:00:00  sbn_7Hq2  schedule estimate → job_4Tx5 estimate · period 2027-Q2 · $0.00 · allowance 2 of 4
  2027-09-05 13:00:00  sbn_7Hq2  schedule estimate → job_4Tx6 estimate · period 2027-Q3 · $0.00 · allowance 3 of 4

The plan and A year, and the next run it whole.