Skip to content

Triggers

A trigger binds the public URL POST /v1/hooks/{id} to one agent. Every delivery from GitHub, Sentry, PagerDuty or your own sender that carries a valid HMAC-SHA256 signature starts a run. The signature is the only credential, so senders never hold a PAL token, and a delivery sent twice starts one run. This guide is for anyone wiring an external system to an agent.

Webhook triggersA signed public URL per agent: the signature is the only credential, a duplicate delivery starts one run. Pick a scenario.
SenderGitHub · Sentry · anyPOST /v1/hooks/idpublic URLVerify HMACprovider formatRate limitper triggerDedupedelivery id + hashSigning secretstored encryptedRunsame path as /v1/runsDeliveriesagent_trigger_deliveries
Swipe sideways to pan.

1. A signed POST arrives

The sender posts the event body with an HMAC-SHA256 signature to the agent's public URL.

  • Outside PAL
  • Control plane
  • Data stores
  1. Look up the trigger. By id. Unknown, disabled or not a UUID: 404. Nothing else runs yet.
  2. Verify the signature. HMAC-SHA256 over the raw body in the provider’s format, constant-time. Bad or missing: 401.
  3. Rate limit. Per trigger, spent only by senders that passed the signature check. Over: 429.
  4. Deduplicate. Agent deleted: 404. Then on the provider delivery id and a hash of the signed bytes. Seen before: 200 duplicate, no run.
  5. Create the run. Same code path as POST /v1/runs: spawn limit, budget check, routing. Success: 202.

POST /v1/agents/{name}/triggers takes provider (generic, github, sentry or pagerduty) and optionally secret and inputTemplate. The agent must already exist. It needs the agents:write scope when scope enforcement is on.

Terminal window
curl -X POST http://localhost:8080/v1/agents/my-agent/triggers \
-H "Authorization: Bearer $PAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider":"github"}'
# 201 Created
# {
# "id": "6f0c1c6e-...",
# "agentName": "my-agent",
# "provider": "github",
# "enabled": true,
# "hookPath": "/v1/hooks/6f0c1c6e-...",
# "createdAt": "2026-10-27T12:00:00Z",
# "secret": "whsec_..."
# }
# list, get, disable, delete
curl http://localhost:8080/v1/agents/my-agent/triggers -H "Authorization: Bearer $PAL_API_KEY"
curl http://localhost:8080/v1/triggers/$ID -H "Authorization: Bearer $PAL_API_KEY"
curl -X PATCH http://localhost:8080/v1/triggers/$ID -H "Authorization: Bearer $PAL_API_KEY" -d '{"enabled":false}'
curl -X DELETE http://localhost:8080/v1/triggers/$ID -H "Authorization: Bearer $PAL_API_KEY" # 204

pal hooks is also reachable as pal hook and pal triggers. The SDKs are not published to npm or PyPI; install them from the repo path (SDKs). In the dashboard, open an agent and use Manage on its webhook triggers card (/agents/{name}/triggers). New trigger picks a provider, takes an optional secret and input template, then shows the payload URL and secret in a copy dialog. Each row has Enable/Disable, Replace secret and Delete.

The public URL is the API base URL plus the trigger’s hookPath, for example http://localhost:8080/v1/hooks/6f0c1c6e-.... The sender must be able to reach it, so a local control plane needs a tunnel.

  • Omit secret and PAL Runtime generates one (whsec_ plus 64 hex characters), returned in the create response only. List and get never return it, and the owner data export leaves it out.
  • A secret you supply must be at least 16 characters and is not echoed back.
  • To change it, PATCH /v1/triggers/{id} with a new secret (same minimum). The old secret stops working immediately; there is no overlap window.
Provider Header Signed bytes Delivery id (dedup key)
github X-Hub-Signature-256: sha256=<hex> raw body X-GitHub-Delivery
sentry Sentry-Hook-Signature: <hex> raw body Request-ID
pagerduty X-PagerDuty-Signature: v1=<hex>[,v1=<hex>] raw body event.id in the JSON body
generic X-Pal-Signature: t=<unix>,v1=<hex>[,v1=<hex>] <t>.<raw body> X-Pal-Delivery (optional)

All four are HMAC-SHA256 with the trigger secret, hex encoded.

GitHub. Create a github trigger and let PAL Runtime generate the secret. In the repository (or organization), go to Settings, Webhooks, Add webhook. Paste the URL as the payload URL, set content type to application/json, paste the secret into Secret, and choose the events to send. GitHub sends a ping when you save; it is a signed delivery like any other and starts a run. Templates see the event name as event (from X-GitHub-Event).

Sentry. Sentry generates its own client secret, so you cannot choose it. Create a sentry trigger first (you need its URL for the Sentry integration), then give PAL Runtime Sentry’s secret. The event is read from Sentry-Hook-Resource.

Terminal window
printf '%s' "<sentry client secret>" | pal hooks update <trigger-id> --secret-stdin

PagerDuty. PagerDuty shows its signing secret after you create the webhook subscription, so the order is the same: create the pagerduty trigger, use its URL as the delivery URL, then replace the trigger’s secret with the one PagerDuty displays. During a secret rotation PagerDuty sends several v1= signatures in one header, and any match passes. The delivery id and event come from event.id and event.event_type in the body.

Generic HMAC. For anything else, sign <unix-timestamp>.<body> with HMAC-SHA256 and send X-Pal-Signature: t=<unix>,v1=<hex>. The timestamp must be within five minutes of server time, in either direction. Send X-Pal-Delivery as a stable id per event, and optionally X-Pal-Event.

Terminal window
SECRET=whsec_... # from the create response
HOOK=http://localhost:8080/v1/hooks/<trigger-id>
BODY='{"service":"checkout","status":"down"}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
curl -i -X POST "$HOOK" \
-H "X-Pal-Signature: t=$T,v1=$SIG" \
-H "X-Pal-Delivery: incident-42" \
-d "$BODY"
# HTTP/1.1 202 Accepted
# {"runId":"...","deliveryId":"incident-42"}

Run it again with the same X-Pal-Delivery and you get the 200 duplicate response below.

Every error body is {"error": "..."}. The signature checks never say which part failed.

Status Body When
202 {"runId":"...","deliveryId":"..."} Signature verified, run created.
200 {"duplicate":true,"deliveryId":"...","runId":"..."} Delivery already seen. No second run. runId is omitted if the first delivery has no run recorded yet.
401 {"error":"invalid signature"} Missing or wrong signature, malformed header, or a generic timestamp outside the five-minute window.
404 {"error":"hook not found"} Id is not a UUID, no such trigger, or trigger disabled: identical body, before any signature check. A trigger whose agent was deleted returns 404 only after a valid signature (an unsigned request gets 401).
413 {"error":"payload too large"} Body over 1 MiB (1,048,576 bytes). Checked before the trigger lookup.
429 {"error":"rate limited"} This trigger’s delivery rate was exceeded (after the signature passed).
429 {"error":"rate limited: too many runs, slow down"} The tenant’s run-spawn limit (shared with POST /v1/runs) was hit.
402 {"error":"agent budget limit reached: REASON","reason":"REASON"} The agent has a hard-fail budget that is already reached.
400 {"error":"could not read body"} The request body could not be read.
500 {"error":"run creation failed"} or {"error":"internal error"} Run creation failed for any reason other than 402/429 (including a budget check that was unavailable), or a server error such as an unreadable stored secret. Detail is logged, not returned.

When run creation fails (402, 429, 500), the delivery id is released, so the provider’s own retry can succeed once the budget resets or the limit clears.

Every delivery is checked against two keys, each unique per trigger: the provider’s delivery id (see the table above; for generic without X-Pal-Delivery, and for any provider that sends no id, a hash of the signed bytes) and a SHA-256 of the signed bytes. A match on either returns 200 {"duplicate":true} and starts no run.

The second key exists because GitHub, Sentry and PagerDuty sign only the body, not their delivery id headers, so someone holding a captured request could replay it with a fresh id.

With no template, the run input is:

{
"provider": "github",
"event": "pull_request",
"deliveryId": "b1c2...",
"payload": { ...the parsed JSON body... }
}

If the body is not valid JSON, payload is the raw body as a string. event is the provider’s event name, or an empty string where the format has none. The run is recorded with trigger kind webhook.

An inputTemplate (a JSON object, set on create or update; null clears it) replaces that shape. Inside it, a string that is exactly {{path}} becomes the typed value at that dotted path in {payload, event, provider, deliveryId, triggerId}, or null if absent. A {{path}} inside a longer string is interpolated as text (empty if absent; non-strings are JSON-encoded). Nothing else is evaluated.

Terminal window
pal hooks create my-agent --provider github \
--template '{"pr":"{{payload.number}}","title":"{{payload.pull_request.title}}","note":"PR {{payload.number}} opened"}'
  • Body size: 1 MiB. Larger gets 413.
  • Rate: per trigger, 60 deliveries per minute with a burst of 10 by default. Set PAL_HOOK_RATE_PER_MIN and PAL_HOOK_BURST on the control plane to change them. Buckets are in memory per replica, so with several replicas the effective limit is higher.
  • Delivery id: longer than 200 characters is replaced by its SHA-256 for storage.
  • Spend: a delivery goes through the same handler as POST /v1/runs, so a reached hard-fail budget returns 402 and starts no run. Set a budget on any agent you expose this way.
  • No event filter. Every verified delivery, including a GitHub ping, starts a run.
  • No overlap window when rotating a secret.
  • No retention sweep. Delivery records are not pruned.
  • Replay protection differs by provider: only generic signs a timestamp (five-minute window). The others rely on the duplicate check.

The route takes no token, and the trigger’s tenant comes from its row, never from the request. The secret is stored AES-256-GCM encrypted when PAL_CREDENTIAL_KEY is set (plaintext otherwise), and is never logged. Trigger CRUD runs under row-level security, so another tenant’s trigger returns 404.

Other ways to start a run: POST /v1/runs, a schedule, a channel message (Set up PAL), or an A2A call (Marketplace).

Next: Scheduling.