# Webhooks

> Start attention.run runs from other systems with a webhook trigger, and get a signed request when a run ends: payload, headers, retries and signatures.

Webhooks go both ways. A **webhook trigger** gives a flow a URL: a `POST` to it starts a run, with params from the request's JSON. An **outgoing webhook** is a URL of yours that the hub calls, signed, whenever a run ends.

## Start a run from a webhook

Make the trigger on the flow's page in the web app (**Add a trigger**, then Webhook), or through the API. The hub makes a secret token for it and shows the URL once; it keeps only a hash of the token, so a lost URL is replaced by rotating it.

```sh
export ATTN_HUB=https://app.attention.run
export ATTN_TOKEN=attn_api_…
curl -fsS -X POST "$ATTN_HUB/api/v1/triggers" \
  -H "authorization: Bearer $ATTN_TOKEN" -H 'content-type: application/json' \
  -d '{
    "name": "sign in from a webhook",
    "on": { "webhook": {} },
    "run": { "flow": "flw_…", "target": "tgt_…", "params": { "user": "{{event.body.user}}" } }
  }'
```

The answer is the trigger, with `webhookUrl`: the path to call on the hub, `/api/v1/hooks/<token>`. Calling it needs no API token (the token in the URL is the secret). The JSON body is the event: `{{event.body.user}}` above becomes the body's `user`, typed by the flow's param.

```sh
curl -fsS -X POST "https://app.attention.run/api/v1/hooks/…" -H 'content-type: application/json' -d '{ "user": "demo" }'
```

```text
{"runId":"run_…"}
```

- **202** `{ "runId" }`: the run started (or was queued for a free runner). `{ "skipped": "…" }`: the trigger's policy didn't start one, such as a run it started still going (`overlap: skip`), or a disabled trigger.
- **400** when a param needs a field the body doesn't have; **404** for an unknown token (nothing more is said); **429** past 60 calls a minute per token, with `Retry-After`.

## Other triggers

The same model runs flows on a schedule, when a target comes online, or after another flow's run ends. A trigger is `on` an event, `run`s a flow on a target with params, under a `policy`:

```json
{
  "name": "nightly sign-in",
  "on": { "schedule": { "cron": "0 3 * * *", "timezone": "Europe/Berlin" } },
  "run": { "flow": "flw_…", "target": "tgt_…", "params": { "user": "demo" } },
  "policy": { "overlap": "skip", "cooldownMs": 0, "retries": 1 }
}
```

| `on` | Fires when | Params can use |
|---|---|---|
| `{ "schedule": { "cron", "timezone"? } }` | A 5-field cron time comes, in the time zone (default UTC). Times missed while the hub was down aren't made up | `{{event.firedAt}}` |
| `{ "webhook": {} }` | Its URL is called | `{{event.body.…}}` |
| `{ "targetOnline": { "target" } }` | An SSH target comes online | `{{event.target.id}}`, `{{event.target.name}}` |
| `{ "runFinished": { "flow", "status"? } }` | A run of that flow ends (in one of `status`) | `{{event.run.id}}`, `{{event.run.status}}`, `{{event.run.outputs.…}}` |

`run.target` may be `"event"` for the last two: the target that came online, or the finished run's. `policy.overlap` is `skip` (the default: not while a run it started is queued or running), `queue` (one waits) or `parallel`; `retries` (0 to 5) starts a failed run again. Chains of `runFinished` triggers stop at five runs deep. **Fire now** on a trigger runs it once with a test event.

## When a run ends: outgoing webhooks

Add a URL under **Settings → Webhooks** in the web app, or with `POST /api/v1/webhooks`. A webhook subscribes to `run.finished` (every run's end) and `run.failed` (runs that ended `failed` or `error`), optionally only for some flows and targets, in one of two formats: `json`, the event below, or `slack`, a message for a Slack incoming webhook. The hub makes a signing secret (`whsec_…`) and shows it once.

Each delivery is a `POST` with the JSON body and these headers:

| Header | What it holds |
|---|---|
| `X-Attn-Signature` | `sha256=` and the hex HMAC-SHA256 of the raw body, keyed with the secret |
| `X-Attn-Timestamp` | When this attempt was sent, in Unix seconds |
| `X-Attn-Event` | `run.finished` or `run.failed` |
| `X-Attn-Event-Id` | The event's id: the same for every retry, so you can drop duplicates |
| `X-Attn-Delivery` | This delivery's id (`whd_…`) |

The body is `{ id, type, createdAt, sentAt, test?, data: { run }, url? }`: `data.run` is the run as it ended, with its `status`, `error`, `outputs` and `message`, and `url` its page in the web app when the hub knows the app's address.

A receiver should check the signature on the raw bytes before parsing them, refuse a `sentAt` more than five minutes from its clock, remember the event ids it has seen in that window, and answer 2xx quickly. This one does all four, and tries itself out against a local hub (a hosted hub calls only public addresses, and this receiver listens on your machine): it registers its own address as a webhook, asks the hub for a test event (**Send test** in the web app), checks what arrives, and removes the webhook.

```ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { createClient } from '@attn/sdk'

const hub = createClient({ url: 'https://app.attention.run', token: process.env.ATTN_TOKEN })

/** Whether `header` (X-Attn-Signature) is the HMAC-SHA256 of the raw body under the secret. */
function signedBy(secret: string, raw: Buffer, header: string | undefined): boolean {
  const expected = Buffer.from(`sha256=${createHmac('sha256', secret).update(raw).digest('hex')}`)
  const got = Buffer.from(header ?? '')
  return got.length === expected.length && timingSafeEqual(got, expected)
}

let secret = ''
const seen = new Set<string>()
let done = () => {}
const received = new Promise<void>((resolve) => {
  done = resolve
})

const server = createServer((req, res) => {
  const chunks: Buffer[] = []
  req.on('data', (c: Buffer) => chunks.push(c))
  req.on('end', () => {
    const raw = Buffer.concat(chunks)
    if (!signedBy(secret, raw, req.headers['x-attn-signature'] as string | undefined)) {
      res.writeHead(401).end()
      return
    }
    const event = JSON.parse(raw.toString('utf8'))
    if (Math.abs(Date.now() - Date.parse(event.sentAt)) > 5 * 60_000 || seen.has(event.id)) {
      res.writeHead(400).end()
      return
    }
    seen.add(event.id)
    res.writeHead(204).end()
    console.log(event.type, event.data.run.status, event.test ? '(a test event)' : '')
    done()
  })
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const { port } = server.address() as AddressInfo

const hook = await hub.api.createWebhook({
  body: { name: 'docs receiver', url: `http://127.0.0.1:${port}/attn`, events: ['run.finished'] },
})
secret = hook.secret
await hub.api.testWebhook({ params: { id: hook.id }, body: {} })
await received
await hub.api.deleteWebhook({ params: { id: hook.id } })
server.close()
```

- **Retries:** a timeout, a failed connection, a 5xx or a 429 is tried again after 10 s, 1 min, 5 min, 30 min and 2 h, then marked failed. Any other 4xx, or a redirect, fails at once.
- **Deliveries** are listed with each webhook in the web app, with the status, attempts and the start of each answer; **Redeliver** sends one again with the same event id.
- A hosted hub calls only public addresses. Not built yet: the `watcher.fired` and `drift.created` events, and email.

---

Canonical: https://attention.run/docs/webhooks · Updated 2026-10-15
