Your events
Everything that happens to a job is an event in one numbered feed. A buyer key reads its own share of it, by list, stream or a signed watch; the owner reads everything, and the working never leaves the record.
A job's record is every fact about it, in order: ordered, a step committed, an ask opened, delivered, charged. Your events are that record, read by audience. Each audience receives what is theirs to know, and nothing more. You never declare an event: services.dev names each one for what happened, such as job.ordered or ask.done, and emits it from the steps your Service runs.
Every event lands in one feed per brand, numbered by seq. Read it after any seq you've seen:
$ npx services.dev events list after=0 limit=2
{
"object": "list",
"data": [
{
"id": "evt_5gX1X2B5swzY61",
"object": "event",
"seq": 1,
"type": "job.ordered",
"created": "2026-10-25T14:02:11.000Z",
"job": "job_7Hq2",
"service": "translation",
"data": {
"by": "buyer",
"amount_usd": 49
},$ curl 'http://localhost:8787/events?after=0&limit=2'
← 200 · {
"object": "list",
"data": [
{
"id": "evt_5gX1X2B5swzY61",
"object": "event",
"seq": 1,
"type": "job.ordered",
"created": "2026-10-25T14:02:11.000Z",
"job": "job_7Hq2",
"service": "translation",
"data": {
"by": "buyer",
"amount_usd": 49
},→ tools/call list_events {"after":0,"limit":2} # at /mcp?toolset=sandbox, with a test key
← {
"object": "list",
"data": [
{
"id": "evt_5gX1X2B5swzY61",
"object": "event",
"seq": 1,
"type": "job.ordered",
"created": "2026-10-25T14:02:11.000Z",
"job": "job_7Hq2",
"service": "translation",
"data": {
"by": "buyer",
"amount_usd": 49
},await lingua.events.list({"after":0,"limit":2})
← {
"object": "list",
"data": [
{
"id": "evt_5gX1X2B5swzY61",
"object": "event",
"seq": 1,
"type": "job.ordered",
"created": "2026-10-25T14:02:11.000Z",
"job": "job_7Hq2",
"service": "translation",
"data": {
"by": "buyer",
"amount_usd": 49
},magic values: none
same list, and the same body on every surface that has the call, request ids asidehas_more says more events follow this page: read again after the last seq you got. next_seq is the feed's latest seq, where a reader that only wants what happens next starts.
The catalog decides, event by event, which audiences receive it:
magic values: none
buyer · live
reads GET /events, GET /events/stream and the watches a buyer key holds
never step.proposed, decision.made, step.refused, a review's findings, a member's identity, held advice, and the thread's words, which GET /jobs/:id/messages reads
gets 58: job.ordered, step.committed, step.skipped, job.paused, job.delivered, job.sent_back, job.accepted, job.canceled, job.ended, job.at_risk, job.late, policy.set, signal.raised, outcome.reported, charge.held, charge.collected, charge.earned, charge.released, charge.voided, charge.refunded, submission.sent, receipt.accepted, receipt.rejected, receipt.timed_out, ask.opened, item.done, ask.reminded, ask.done, ask.expired, ask.voided, ask.relinked, grant.given, grant.expired, grant.revoked, thread.posted, message.sent, consent.given, clock.opened, clock.noticed, clock.escalated, clock.ack_due, clock.ack_missed, clock.ran, clock.moved, clock.due, clock.satisfied, clock.rolled, clock.recurred, clock.acked, clock.chosen, clock.assigned, clock.outlived, subscription.created, subscription.renewed, subscription.minted, allowance.used, allowance.exceeded, subscription.canceled
owner · live
reads GET /owner/events: everything on the record
gets every one of the 87 events on the record
member · live
reads GET /tasks, their own feed, and push: offers as teasers, and their own Tasks
never a party's name before the sheet, and anything from a Task they haven't cleared
gets 19: job.paused, task.posted, task.offered, task.claimed, task.hold_expiring, task.extended, task.due_moved, task.raised, task.widened, task.declined, task.recused, task.contested, task.ruled, message.adopted, tier.changed, pay.accrued, pay.payable, succession.started, succession.accepted
public · live
reads a Pool's door: the demand board's aggregates
never any job-level fact
gets 1: board.updatedThe member's feed drives the delivery portal, and the public feed a Pool's door. Events lists every event, what it says and who receives it.
The same job, read by a buyer key and by you:
magic values: none
$ curl 'http://localhost:8787/events?job=job_7Hq2' # a buyer key
1 job.ordered
2 charge.held
5 step.committed
9 step.committed
10 job.delivered
11 charge.collected
$ curl 'http://localhost:8787/owner/events?job=job_7Hq2' # the owner
1 job.ordered
2 charge.held
3 decision.made
4 step.proposed
5 step.committed
6 decision.made
7 review.done
8 step.proposed
9 step.committedA proposal before the gate commits it, each AI decision with its model pin and input hashes, and a review's findings are the working. They stay on the record, where replay and the evidence bundle read them, and in your owner console. A buyer's job carries none of it either: the conformance suite checks every job, list and event a key can read for leaks. The record explains what each fact holds.
The stream is the same feed over server-sent events, from a seq or from the Last-Event-ID header your client sends when it reconnects:
magic values: none
$ curl -N 'http://localhost:8787/events/stream?after=0'
← 200 · content-type: text/event-stream; charset=utf-8
retry: 3000
id: 1
event: job.ordered
data: {"id":"evt_5gX1X2B5swzY61","object":"event","seq":1,"type":"job.ordered","created":"2026-10-25T14:02:11.000Z","job":"job_7Hq2","service":"translation","data":{"by":"buyer","amount_usd":49},"request":{"id":"req_WppKI6","idempotency_key":"lease-fr-CA"},"livemode":false,"simulated":true}
id: 2
event: charge.held
data: {"id":"evt_88cLb4PC0MfpT4","object":"event","seq":2,"type":"charge.held","created":"2026-10-25T14:02:11.000Z","job":"job_7Hq2","service":"translation","data":{"amount_usd":49},"request":{"id":"req_WppKI6","idempotency_key":"lease-fr-CA"},"livemode":false,"simulated":true}
…A watch delivers events to a URL your server holds. Its whsec_ secret is shown once, when you make it:
$ npx services.dev watches create https://hooks.example.test/lingua events=job.delivered
{
"id": "wh_1rKV5VS8V3",
"object": "watch",
"url": "https://hooks.example.test/lingua",
"events": [
"job.delivered"
],
"status": "enabled",
"created": "2026-10-25T14:02:11.000Z",$ curl -X POST http://localhost:8787/watches -H 'content-type: application/json' -d '{"url":"https://hooks.example.test/lingua","events":["job.delivered"]}'
← 200 · {
"id": "wh_1rKV5VS8V3",
"object": "watch",
"url": "https://hooks.example.test/lingua",
"events": [
"job.delivered"
],
"status": "enabled",
"created": "2026-10-25T14:02:11.000Z",# no tool: never: a watch delivers to a URL your server holds, which an agent's session doesn't: it reads list_events after a seq insteadawait lingua.watches.create({"url":"https://hooks.example.test/lingua","events":["job.delivered"]})
← {
"id": "wh_1rKV5VS8V3",
"object": "watch",
"url": "https://hooks.example.test/lingua",
"events": [
"job.delivered"
],
"status": "enabled",
"created": "2026-10-25T14:02:11.000Z",magic values: watch.url = https://hooks.example.test/lingua
same watch, wh_1rKV5VS8V3, and the same body on every surface that has the call, request ids asideA watch is never an MCP tool, because an agent's session holds no URL to deliver to. An agent reads list_events after a seq instead.
Each delivery carries a services-signature header: a timestamp and an HMAC-SHA256 of the timestamp and the body, keyed with the secret. A failed delivery is retried with backoff for up to three days, and watches replay sends a watch everything after a seq again. On your own machine, services.dev listen makes a watch for a local URL, and the receiver checks each delivery with the SDK's webhooks.verify():
magic values: client.email = signs@example.test
$ npx services.dev listen --forward-to http://localhost:4242/hook --events job.ordered,ask.opened,receipt.accepted,job.delivered
Ready! Forwarding to http://localhost:4242/hook as wh_1rKV5VS8V3. Its signing secret is whsec_88PiGzGqWulKhuHAUGldLH9GSl5J8tTF (^C to quit)
# the receiver at http://localhost:4242/hook, checking each delivery with webhooks.verify(body, header, 'whsec_88PiGzGqWulKhuHAUGldLH9GSl5J8tTF')
job.ordered [evt_5gX1X2B5swzY61] seq 1
services-signature: t=1792936931,v1=833af4d63e571d303c476dbc60e03a836d4227bcfc4fd5da0efcf430d33aa7b4
verified
ask.opened [evt_RpMfNTkcGvEOR4] seq 6
services-signature: t=1792936931,v1=c1ca4269f41dc5d56686625f4acdb3558274e7736d56c2ce5d7caf9fd5306a22
verified
receipt.accepted [evt_0NJwixDHOuv1kX] seq 15
services-signature: t=1792936931,v1=da74c709784e4e583c68d48607c8ee9ccbd0acdc36d57c3228c6779ff7e8ad26
verified
job.delivered [evt_xdJmAitpoYURo6] seq 19
services-signature: t=1792936931,v1=1baa3b792d459db5a8dd9b4000def0cf0e0d2c8360e258d130bd49940b4727e7
verifiedListening locally sets this up step by step. webhooks.verify() returns the catalog's typed union, so a handler switches on type with every case named: Safe to depend on.
The header is services-signature: t=<unix seconds>,v1=<hex>, and v1 is the HMAC-SHA256 of <t>.<raw body>, keyed with the watch's whsec_ secret. Hash the bytes exactly as they arrived, before any JSON parser touches them, and refuse a t more than 300 seconds from now:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody: string, header: string, secret: string, now = Date.now()): boolean {
const { t, v1 } = Object.fromEntries(header.split(',').map(part => part.split('=')))
const want = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
const got = Buffer.from(v1 ?? '', 'hex')
return Math.abs(now / 1000 - Number(t)) <= 300 && want.length === got.length && timingSafeEqual(want, got)
}# verify(rawBody, header, secret), from scripts/fixtures/events/verify.ts, on the delivery above
the delivery as it arrived true
one byte of the body changed false
ten minutes late falseAnything but a 2xx, or no answer in 10 seconds, is a failure. The delivery is tried again on this schedule, one at a time and in order, then gives up:
# when each retry of a failed delivery goes: BACKOFF, after the attempt before it
retry 1 after 5s 5s after the first attempt
retry 2 after 30s 35s after the first attempt
retry 3 after 2m 3m after the first attempt
retry 4 after 10m 13m after the first attempt
retry 5 after 1h 1.2h after the first attempt
retry 6 after 6h 7.2h after the first attempt
retry 7 after 24h 31.2h after the first attempt
retry 8 after 36h 67.2h after the first attempt
then gave_up: the delivery stops, and watches replay sends it againA watch counts its deliveries as delivered, failing and gave_up.
openapi.json emits every event a buyer receives as an OpenAPI 3.1 webhook, so an integrator's generator types the deliveries too:
magic values: none
$ curl http://localhost:8787/openapi.json
openapi 3.1.0
paths 87
webhooks 58: job.ordered, step.committed, step.skipped, job.paused, job.delivered, job.sent_back, job.accepted, job.canceled, job.ended, job.at_risk, job.late, policy.set, signal.raised, outcome.reported, charge.held, charge.collected, charge.earned, charge.released, charge.voided, charge.refunded, submission.sent, receipt.accepted, receipt.rejected, receipt.timed_out, ask.opened, item.done, ask.reminded, ask.done, ask.expired, ask.voided, ask.relinked, grant.given, grant.expired, grant.revoked, thread.posted, message.sent, consent.given, clock.opened, clock.noticed, clock.escalated, clock.ack_due, clock.ack_missed, clock.ran, clock.moved, clock.due, clock.satisfied, clock.rolled, clock.recurred, clock.acked, clock.chosen, clock.assigned, clock.outlived, subscription.created, subscription.renewed, subscription.minted, allowance.used, allowance.exceeded, subscription.canceledPOST /events sends an event the other way: a job's inbound signal by name, or, on a test key, a synthetic event of the catalog to try a handler. Signals covers what a Service does with one.