Your events

The running jobjob_7Hq2 · delivered · document.fr-CA.pdf · $49.00 charged

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:

Output
$ 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
      },
Output
$ 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
      },
Output
→ 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
      },
Output
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
      },
Output
magic values: none
same list, and the same body on every surface that has the call, request ids aside

has_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:

Output
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.updated

The 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:

Output
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.committed

A 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:

Output
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:

Output
$ 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",
Output
$ 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 instead
Output
await 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",
Output
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 aside

A 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():

Output
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
  verified

Listening 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:

scripts/fixtures/events/verify.ts
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)
}
captured/webhook-verify.txt · magic values: none
# 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              false

Anything 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:

captured/webhook-verify.txt · magic values: none
# 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 again

A 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:

Output
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.canceled

POST /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.