Skip to content

Webhooks ​

@ahpd/webhook gives a program outside the host a URL to call. A call to an endpoint either talks to a bot or fires an automation, and each endpoint has its own secret.

The endpoint record ​

An endpoint is a record in the webhooks collection. The daemon's record store keeps each one as a file a person writes by hand, <configDir>/webhooks/<id>.md.

FieldWhat it is
idThe slug, and the last segment of the endpoint's URL
botThe bot a call talks to, as bot://<slug>. An endpoint names bot or automation, not both
automationtrue on an endpoint whose calls fire the webhook trigger
secretThe name of the vault secret that the caller sends as its bearer token, such as host:ci-hook
replyHow a call to a bot is answered: callback, sync or none. The default is callback
callbackPrefixWhat every callback URL must start with: an http or https URL whose path ends in /. A callback endpoint needs one
syncTimeoutSecondsHow long a sync call waits, over the plugin option of the same name
md
---
id: ci
bot: bot://motion
secret: host:ci-hook
callbackPrefix: https://ci.example.com/hooks/
---

A record that fails the check is no endpoint. A call to it answers 404, and the plugin reports the problem.

The call ​

An endpoint's URL is /plugins/webhook/<id> on the daemon's port. A call is a POST with the endpoint's secret as a bearer token:

bash
curl -X POST http://127.0.0.1:37537/plugins/webhook/ci \
  -H "Authorization: Bearer $CI_HOOK_TOKEN" \
  -H 'content-type: application/json' \
  -d '{ "thread": "pr-42", "text": "Review this pull request.", "callback": "https://ci.example.com/hooks/pr-42" }'

Put the token in the vault under the name that secret gives, with ahpd vault set host:ci-hook.

AnswerWhen
401The call has no bearer token, the token is wrong, or the vault does not hold the secret. The last case is also a problem in the daemon's log
404No endpoint has that id, or the path has more than one segment after the prefix
405The method is not POST

The plugin compares the token in constant time, and reads no body before the token passes.

A public port ​

The webhooks can be on a public address while AHP and the API stay on loopback. The daemon key routes gives every plugin route a listener of its own, which serves nothing else:

json
{
  "host": "127.0.0.1",
  "routes": { "port": 8443, "host": "0.0.0.0" },
  "origins": ["https://hooks.example.com"]
}

The routes listener checks the Host header like the daemon's port does. A caller that reaches it by a public name needs that name in origins. With routes.port set, /plugins/* answers 404 on the daemon's port. See HOST.md.

A call to a bot ​

A call to a bot endpoint sends its text as a turn in the plugin's own chat with the bot. That is one chat for the endpoint: the caller names a thread, and every thread talks in it. The chat is the one a read of the bot answers the plugin, and the first call starts it.

A turn in that chat runs no model. It ends in the call that starts it, with no response part, so a call is answered with no text until the bot's own work can post into its chat.

The call is one of two shapes:

  • A JSON body { "thread": "...", "text": "...", "callback": "..." }.
  • Any other body, which is the text as it came. The thread and callback come from the query: /plugins/webhook/ci?thread=pr-42.

A JSON body that has no string text is the second shape, so the whole body is the text.

The plugin keeps each thread in the webhook-threads collection, as the endpoint, the thread id, the session and the chat. Every thread of an endpoint holds the same session and chat, which is what a session started again under a new URI rewrites.

The reply ​

replyWhat the call answers
callback202 with { thread, turn } once the turn is sent. At the turn's end, the plugin posts { thread, turn, status, text, error } to the call's callback URL
sync200 with { thread, turn, status, text, error } at the turn's end, or 502 when the turn failed. When the turn runs longer than the timeout, 202 with status: "running", and the turn goes on
none202 with { thread, turn } once the turn is sent

text is the turn's markdown response parts, joined: what the bot said in it. A turn in a bot-chat has none until the bot posts into its chat, so text is empty. status is complete, cancelled or error. A turn that failed has status: "error" and the message of its error in error, which no other answer holds. A sync call answers a failed turn with 502, so a caller that reads only the HTTP status sees the failure. A cancelled turn answers 200.

A callback URL must start with the endpoint's callbackPrefix. The plugin compares the URL as new URL writes it, so https://ci.example.com.evil.test/ does not pass the prefix https://ci.example.com/. Without this check, any caller could make the host post to any address.

A callback that answers with a redirect is a failed callback. The plugin does not follow the redirect, and the daemon's log gets a problem line.

A sync call waits for syncTimeoutSeconds: the endpoint's field, or the plugin option, which is 60 by default.

json
{
  "plugins": [
    { "name": "@ahpd/webhook", "options": { "syncTimeoutSeconds": 30 }, "grants": ["bot:get", "bot:chat", "session:read", "session:create", "session:write"] }
  ]
}
AnswerWhen
400The call has no thread or no text, or its callback URL is missing or outside the prefix
409The plugin may not read the bot, the read answered no chat for it, or the host refused the turn. The answer's message is the host's reason. A turn that the host does not echo in 10 seconds is not refused: the call goes on, and the daemon's log gets a problem line. The first two are also a problem in the daemon's log, and the chat refusal names the grants bot:chat and session:create

The grants ​

On a host with a users directory, the plugin acts as plugin:webhook, with the grants of its entry:

GrantWhat it is for
bot:getRead the bot
bot:chatBe answered a chat by it, which the read starts
session:readRead the session's state, which names the chat, and the turn's text
session:createStart a session for an owner, which a read of the bot does
session:writeSend the turn

The chat is the plugin's own, started as the owner plugin:webhook, so session:create is asked of nobody here: it is in the list because those two are the grants the plugin's problem line prints when a read is answered no chat.

The plugin opens its connection when the daemon listens, and hears each turn's end from the host.

A call to an automation ​

A call to an automation endpoint fires the webhook trigger type, which the plugin offers. The type has one event, call, and its form asks for one endpoint. An automation runs on a call when its trigger's config names the endpoint the call came to.

md
---
id: deploys
automation: true
secret: host:deploys-hook
---
markdown
---
title: Check a deploy
enabled: true
session:
  provider: claude
triggers:
  - id: t1
    kind: event
    type: webhook
    title: A deploy finished
    events:
      - id: call
    config:
      endpoint: deploys
---

The deploy of {{event.repo}} at {{event.ref}} finished. Check that the service is up.

The body is a JSON object, and its fields are the data of the fire. The plugin sets endpoint in the data to the endpoint id, over any body field of that name, so a caller reaches only the automations of the endpoint it called. Any other field that the trigger's config names must have the same value in the body when the body holds it. A config field that the body does not hold does not stop the run.

A run's message names a field as {{event.<field>}}. A string is filled as it is, any other value as JSON, and a missing field as nothing. See AUTOMATIONS.md for the other placeholders.

AnswerWhen
202The trigger fired. The body is { "endpoint": "<id>" }, because a fire has no answer
400The body is not JSON, or not a JSON object

The run's origin keeps the data of the fire, so the body must carry no secret. An automation call needs no grant.