Integrations
Webhooks
Signed inbound triggers and outbound event delivery.
AgentDock has webhooks in both directions. Inbound triggers let another system start work on a project with a signed POST, for example a CI failure that starts a debugging skill. Outbound webhooks push AgentDock's events to a URL you choose, such as n8n, a Slack relay or your own bot.
Both are admin-only and live under Admin → Integrations. Both need APP_ENCRYPTION_KEY, because their secrets are stored encrypted; without it, creating one fails with 409 encryption_key_missing.
Inbound triggers
A trigger belongs to one project and does exactly one of two things:
- Run a skill: a headless skill run with the skill, args, runtime profile, model and output (Report or Pull request) you set on the trigger.
- Start the next orchestrator round: the same as
orchestrator nextfrom the Fleet page.
Stopping the orchestrator or a slot cannot be triggered from outside.
Create a trigger
Go to Admin → Integrations → Triggers → New trigger. Pick the project (fixed once created), the action, and for a skill its Args, Profile, Model and Output. AgentDock shows the secret once, together with the URL:
https://agentdock.example.com/api/hooks/<publicId><publicId> is a random 24-character id, not a database id. An unknown or disabled id answers 404 with an empty body, so the endpoint does not reveal which triggers exist.
Sign a request
Every request carries three headers:
| Header | Value |
|---|---|
X-AgentDock-Timestamp | Unix time in seconds |
X-AgentDock-Delivery | A unique id you choose, at most 64 characters |
X-AgentDock-Signature | sha256=<hex>, where hex is HMAC-SHA256(secret, "<timestamp>.<delivery>.<raw body>") |
The HMAC key is the secret exactly as shown (its base64url text as UTF-8 bytes). The body must be application/json, at most 256 KB, not compressed. This is the example the trigger page gives you:
SECRET='<trigger secret>'
URL='https://agentdock.example.com/api/hooks/<publicId>'
BODY='{"example":"value"}'
TS=$(date +%s)
DELIVERY=$(uuidgen)
SIG=$(printf "%s.%s.%s" "$TS" "$DELIVERY" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST "$URL" \
-H "Content-Type: application/json" \
-H "X-AgentDock-Timestamp: $TS" \
-H "X-AgentDock-Delivery: $DELIVERY" \
-H "X-AgentDock-Signature: sha256=$SIG" \
--data "$BODY"The signature is checked in constant time over the raw bytes, before the body is parsed.
Replay window and rate limit
- A timestamp more than 5 minutes from the server's clock is refused with
401. - Delivery ids are remembered for 24 hours per trigger. Sending the same id again answers
409 replayed. - Each trigger has a token bucket of 30 accepted deliveries per hour. Beyond that,
429, and nothing is recorded, so you can retry with the same delivery id later. - One live run per trigger. A delivery while the previous run is still going is recorded as
skipped(previous_still_running). - Budgets apply: if the project's budget is set to stop and is exceeded, the firing is skipped.
| Response | Meaning |
|---|---|
202 | Verified and recorded. The body is { deliveryId, status }; the run starts after the response, so the caller never waits for an agent |
401 | Missing or wrong signature, or timestamp out of window (no detail given) |
404 | Unknown or disabled trigger |
409 | Delivery id already used |
413 / 415 | Body over 256 KB / compressed body |
422 | Payload refused by the placeholder rules below (invalid_payload) |
429 | Rate limit reached |
A trigger fires on behalf of the admin who created it. If that person is no longer an active admin, the delivery is accepted, the firing fails with creator_not_authorized, and the trigger disables itself.
Payload placeholders
The payload never reaches a shell. The only way a value from it reaches a skill is a placeholder in the trigger's Args:
Investigate the failed pipeline {{payload.pipeline.id}} on {{payload.branch}}The rules are strict:
- A placeholder is exactly
{{payload.<path>}}, where the path is 1 to 10 dotted segments of[A-Za-z0-9_-]. A numeric segment indexes an array. No expressions, filters or defaults; any other{{or}}is refused. - Every path you use must be listed in the trigger's Allowed paths, one per line (for example
pipeline.id). - At fire time each value must be a string, number or boolean, at most 500 characters once stringified, and must match the trigger's Value pattern. The default pattern is
^[\w .,:/#@+-]*$: no backticks,$, parentheses, quotes,;,|,&, angle brackets or newlines. - The rendered args must fit the skill run's 4096-byte limit.
Any violation answers 422 and nothing fires. The rendered args are passed to the runner as data, never interpolated into a command line.
Open a trigger and use Dry run with a sample Payload (JSON) to see the same verdict a real delivery would get (Would fire or Would be refused (422)) without firing anything. Signed requests are listed in the trigger's delivery log for 30 days.
Rotate a trigger secret
Rotate secret generates a new secret and shows it once. The previous secret keeps verifying for 24 hours, so you have a day to update the caller.
Outbound webhooks
Go to Admin → Integrations → Webhooks → New webhook. Give it a URL, pick the events, and optionally limit it to some projects (empty means all). AgentDock shows the secret once.
Event catalogue
The list is closed: an event reaches a receiver only if it is here.
| Event | When |
|---|---|
orchestrator.started | A project's orchestrator started |
orchestrator.stopped | A project's orchestrator stopped |
slot.dispatched | The orchestrator handed an issue to a worker session |
slot.checkpoint | A worker reported progress: plan ready, done, pull request open, blocked |
slot.stopped | A worker session ended |
pr.opened | A worker opened a pull request |
pr.checks_changed | A pull request's checks passed, failed or restarted |
pr.merged | A pull request was merged |
pr.closed | A pull request was closed without merging |
issue.blocked | A worker stopped on an issue it cannot finish alone |
person.needed | An agent is waiting for a person to answer |
pane.prompt | A session shows a prompt only a person can answer |
pane.quota_hit | A session hit its model provider's usage limit |
schedule.failed | A scheduled run failed |
schedule.disabled | A schedule turned itself off, for example after repeated failures |
webhook.test | Sent only by Send test, to check the receiver |
llm.request, tool.call and pane text have no entry, so they can never be sent.
Payload
Every delivery is one event in a fixed envelope:
{
"id": "evt_48213",
"type": "slot.checkpoint",
"ts": "2026-10-13T09:41:07.000Z",
"project": { "id": "<project id>", "repo": "acme/shop" },
"slot": "i42",
"issue": 42,
"data": {
"checkpoint": "pr_open",
"summary": "Opened the pull request; all checks pass locally.",
"pr": { "number": 57, "url": "https://github.com/acme/shop/pull/57" }
}
}data is built per event type from an allowlist of keys, never copied from the raw event. Summaries and free text are cut to 1000 characters, other strings to 500, and file system paths are never included. id is stable per event; a test delivery uses test_<delivery id> and project: null.
Headers and signature
| Header | Value |
|---|---|
X-AgentDock-Event | The envelope's type |
X-AgentDock-Delivery | The delivery id |
X-AgentDock-Signature | t=<unix seconds>,v1=<hex>, where hex is HMAC-SHA256(secret, "<t>.<raw body>") |
The key is the secret exactly as shown on create or rotate (its base64url text as UTF-8 bytes). Verify against the raw body, before parsing it:
import { createHmac, timingSafeEqual } from 'node:crypto';
export const verify = (header, rawBody, secret, toleranceSec = 300) => {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
};Rotate secret on a webhook switches signing to the new secret immediately, so update the receiver right after.
Retries
Each attempt is a POST with a 10-second timeout. Redirects are not followed (a 3xx counts as a failure). Any 2xx is success. Otherwise AgentDock retries after 30 s, doubling each time with ±20 % jitter, for at most 8 attempts (about two hours), then marks the delivery failed.
Circuit breaker
After 10 consecutive failed attempts, the webhook's circuit opens and the page shows Delivery paused (receiver failing). Deliveries stay pending and are not attempted for 15 minutes. Then one delivery is tried (Trying the receiver again): success closes the circuit, failure reopens it for another 15 minutes. Resume deliveries closes it by hand.
Delivery log
The webhook's sheet lists every delivery with its event, status, attempts, next attempt and the receiver's response code and body (up to 2 KB). You can Redeliver a delivery, which gives a failed one one more attempt, and Send test a webhook.test event. Delivery rows are kept for 30 days.
WEBHOOKS_WORKER_ENABLED=false turns the outbound dispatcher and worker off on an instance. Inbound triggers still answer.
SSRF guard
Because the API sits on your network, outbound URLs are checked before every attempt:
- Only
https://URLs, unless the host is on the allowlist. - The host is resolved at send time and every resolved address is checked. Loopback, link-local (including
169.254.169.254), private ranges (10/8,172.16/12,192.168/16), CGNAT100.64/10(Tailscale), ULAfc00::/7, unspecified, multicast, reserved, benchmarking, NAT64, 6to4 and IPv4-mapped forms are refused withblocked_address. - The connection goes to the address that was checked, with no second lookup.
To reach something on your own network, such as a local n8n, add its host name or a CIDR under Hosts and CIDRs on the Webhooks page (one per line, for example n8n.lan or 192.168.1.0/24). Changes to that allowlist are audited.