Schedules and triggers
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:
on: { schedule: { monthly: { at: 'every month', order: translation } }, event: { 'page.changed': translation } },
price: { usd: 149, every: 'month', includes: { translation: 4 } },On the Service
| Key | Written | Left out | Does |
|---|---|---|---|
on | Triggers | none | What 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
| Key | Written | Left out | Does |
|---|---|---|---|
schedule | { <name>: Schedule } | none | Dates, by name: { check: 'every monday' }, or { annual: { order: annualReturn, each: 'tax_year', at: '1mo after end' } }. |
event | string[] | Readonly | none | Events, 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
| Key | Written | Left out | Does |
|---|---|---|---|
order | AnyService | this Service | The Service each date mints. |
at | string | string[] | required | When: 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> | none | One mint per period, its period set on the order: 'tax_year', 'quarter'. |
when | (sub) => boolean | always | Mints only when it holds, read at each date from the subscription as it stands. |
input | (on, sub) => fields | the subscription's input, as the minted Service takes it | The 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
| Key | Written | Left out | Does |
|---|---|---|---|
price.every | Every | none | A 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.
$ 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:
$ 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-responseEach date mints its Service, and AI delivers it, with people only where its file puts them:
$ 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:
→ 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| Written | Mints on |
|---|---|
'every day' | each day |
'every monday', or any day of the week | that 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_up | Mints |
|---|---|
'all', left out | every 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:
$ 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:
$ 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:
→ 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 client | The mints |
|---|---|
| uses an allowance | $0 and counted; past it, offered at the job's own price: Subscriptions |
| updates the subscription | the next mint takes the new input; jobs already minted keep theirs |
| cancels | stop 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:
$ 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| When | The runtime | Event |
|---|---|---|
| a cycle ends | renews, resets every allowance and posts the cycle's charge, before that instant's schedules mint; Subscriptions has the charge, a declined one and a cancel | subscription.renewed |
| a schedule's date comes | mints its Service on the subscription's subject, inside an allowance at $0 | subscription.minted, allowance.used |
| a mint passes an allowance | offers the job at its own price, 402 OFFER, with the order in next | allowance.exceeded |
| a mint names a period the subject holds | records the mint as refused, and charges nothing | subscription.minted |
| a subscription starts after a schedule's date | with catch_up: 'open', mints each earlier period whose clocks are still open, oldest first; otherwise waits for the next date | subscription.minted |
| a date comes while a renewal is unpaid | mints the job and holds it, never releasing it until the renewal collects | subscription.minted |
| the subscription is updated | the next mint takes the new input; jobs already minted keep theirs | subscription.updated |
| a cancel's cycle ends | minting stops; jobs in flight finish, and the subject's open dates are handed back to the client | subscription.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.
| App | Shows | Acts |
|---|---|---|
| Customer portal | each subscription: its cycle, each allowance used of included, what one more costs, and every date it mints on next | cancel at the end of the cycle |
| Owner console | every subscription, its cycle's charge on the ledger, and each job it minted with its from | cancel 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.
| Op | API | CLI | MCP | SDK | Site | Docs | App |
|---|---|---|---|---|---|---|---|
service.order | POST /jobs, POST /:service | <service> k=v … | order_<service> | <service>.order() | /<service>: the order form | 10 error pages | customer portal |
event.inbound | POST /events | events send <type> | send_event | events.send() | 4 error pages | customer portal | |
subscription.list | GET /subscriptions, GET /owner/subscriptions | subscriptions list | list_subscriptions | subscriptions.list() | 1 error page | customer portal, owner console | |
subscription.get | GET /subscriptions/:id, GET /owner/subscriptions/:id | subscriptions get <subscription> | get_subscription | subscriptions.get() | 1 error page | customer portal, owner console | |
subscription.cancel | POST /subscriptions/:id/cancel, POST /owner/subscriptions/:id/cancel | subscriptions cancel <subscription> | cancel_subscription | subscriptions.cancel() | 1 error page | customer 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.
| Code | Category | HTTP | Why |
|---|---|---|---|
event_id_required | invalid_request | 422 | An event that mints a job on a subscription is one occurrence: it needs your id for it. |
input_invalid | invalid_request | 422 | The order doesn't match the Service's input. |
period_taken | conflict | 409 | A job of this Service already holds the subject's period: one live or ended job per period. |
| When the file won't load | Thrown by | Why |
|---|---|---|
subscription_unpriced | Service() | 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_unknown | Service() | 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.
on: { schedule: { check: 'every monday' }, event: { 'office_action.issued': trademarkResponse } },
price: { usd: 12, every: 'month' },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.
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 } },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:
$ 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.