Docs
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.
Last updated 15 October 2026
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.
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.
curl -fsS -X POST "https://app.attention.run/api/v1/hooks/…" -H 'content-type: application/json' -d '{ "user": "demo" }'
{"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, runs a flow on a target with params, under a policy:
{
"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.
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.firedanddrift.createdevents, and email.