---
title: "Your events"
url: https://services.dev/docs/api/events
captured: [webhook-verify.txt]
---
# Your events

URL: https://services.dev/docs/api/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.

## One feed, numbered

Every event lands in one feed per brand, numbered by `seq`. Read it after any seq you've seen:

**CLI**

```txt
$ 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
      },
      "request": {
        "id": "req_WppKI6",
        "idempotency_key": null
      },
      "livemode": false,
      "simulated": true
    },
    {
      "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": null
      },
      "livemode": false,
      "simulated": true
    }
  ],
  "has_more": true,
  "url": "/events",
  "next_seq": 11
}
```

**API**

```txt
$ 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
      },
      "request": {
        "id": "req_WppKI6",
        "idempotency_key": null
      },
      "livemode": false,
      "simulated": true
    },
    {
      "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": null
      },
      "livemode": false,
      "simulated": true
    }
  ],
  "has_more": true,
  "url": "/events",
  "next_seq": 11
}
```

**MCP**

```txt
→ 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
      },
      "request": {
        "id": "req_WppKI6",
        "idempotency_key": null
      },
      "livemode": false,
      "simulated": true
    },
    {
      "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": null
      },
      "livemode": false,
      "simulated": true
    }
  ],
  "has_more": true,
  "url": "/events",
  "next_seq": 11
}
```

**SDK**

```txt
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
      },
      "request": {
        "id": "req_WppKI6",
        "idempotency_key": null
      },
      "livemode": false,
      "simulated": true
    },
    {
      "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": null
      },
      "livemode": false,
      "simulated": true
    }
  ],
  "has_more": true,
  "url": "/events",
  "next_seq": 11
}
```

```txt
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.

## Who receives what

The catalog decides, event by event, which audiences receive it:

```txt
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](https://services.dev/docs/reference/events.md) lists every event, what it says and who receives it.

## What never leaves the record

The same job, read by a buyer key and by you:

```txt
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
 10  job.delivered
 11  charge.collected

only on the owner's feed: decision.made, step.proposed, review.done
```

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](https://services.dev/docs/service/record.md) explains what each fact holds.

## Pushed as it happens

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:

```txt
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}

…
```

## Watches: signed deliveries to your URL

A watch delivers events to a URL your server holds. Its `whsec_` secret is shown once, when you make it:

**CLI**

```txt
$ 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",
  "livemode": false,
  "simulated": true,
  "deliveries": {
    "delivered": 0,
    "failing": 0,
    "gave_up": 0
  },
  "secret": "whsec_88PiGzGqWulKhuHAUGldLH9GSl5J8tTF"
}
```

**API**

```txt
$ 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",
  "livemode": false,
  "simulated": true,
  "deliveries": {
    "delivered": 0,
    "failing": 0,
    "gave_up": 0
  },
  "secret": "whsec_88PiGzGqWulKhuHAUGldLH9GSl5J8tTF"
}
```

**MCP**

```txt
# 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
```

**SDK**

```txt
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",
  "livemode": false,
  "simulated": true,
  "deliveries": {
    "delivered": 0,
    "failing": 0,
    "gave_up": 0
  },
  "secret": "whsec_88PiGzGqWulKhuHAUGldLH9GSl5J8tTF"
}
```

```txt
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()`:

```txt
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](https://services.dev/docs/cli/listen.md) 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](https://services.dev/docs/sdk/types.md).

## Verify without the SDK

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:

```ts title="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)
}
```

```txt title="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
```

## When a delivery fails

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:

```txt title="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`.

## In OpenAPI

`openapi.json` emits every event a buyer receives as an OpenAPI 3.1 webhook, so an integrator's generator types the deliveries too:

```txt
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
```

## Events in

`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](https://services.dev/docs/service/signals.md) covers what a Service does with one.

---

- Up: [Your API](https://services.dev/docs/api.md)
- Previous: [Conventions](https://services.dev/docs/api/conventions.md)
- Next: [Machine registers](https://services.dev/docs/api/machine.md)
