Sending webhooks INTO Trimus¶
Two endpoints accept incoming webhooks without a bearer token. Use these when an external system needs to push something into Trimus on its own schedule — a GitHub PR opening, a Stripe charge firing, a cron tick from your scheduler.
POST /api/routine-triggers/public/{publicId}/fire # routine triggers
POST /api/companies/{companyId}/plugins/webhooks/{path} # plugin webhooks
Both are public and rate-limited per IP. Authentication is per-resource,
not via Authorization headers. This page focuses on the routine
trigger; plugin webhooks are covered briefly at the end.
Routine triggers¶
A routine is a saved invocation that fires an agent. A trigger
is how that routine gets fired: a schedule (cron) trigger or a
webhook trigger. This page covers the webhook trigger.
Source:
internal/handler/webhook.go
(the fire handler) and
internal/handler/routines.go
(CreateTrigger, RotateSecret).
Creating a webhook trigger¶
You create the trigger on an existing routine. Via the API:
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"kind":"webhook","signing_mode":"hmac","label":"github-pr-opened"}' \
"$BASE/api/routines/$ROUTINE_ID/triggers"
Response (secret is present only for signing_mode:"hmac", and
only on this create call — it is never returned again):
{
"id": "…",
"routine_id": "…",
"kind": "webhook",
"enabled": true,
"public_id": "9f1c…",
"signing_mode": "hmac",
"replay_window_sec": 300,
"secret": "<raw secret — shown once>"
}
kindmust be"webhook"(or"schedule"for cron — different shape). Over this JSON create endpoint,signing_modeaccepts only"none"(the default) or"hmac"— anything else returns 400."hmac_ts"(HMAC over a signed, freshness-checked timestamp — the replay-resistant option; see Firing it —hmac_ts) is currently settable only from the routine detail page's Webhook trigger UI. Note also thatPATCHcannot changesigning_modeat all — pick the mode at create time.public_idis a freshly-generated UUID. It becomes part of the public fire URL and is safe to log.- With
signing_mode:"hmac"or"hmac_ts", Trimus generates the secret, returns it once assecret, and stores it encrypted at rest. Creating a signed trigger requiresTRIMUS_ENCRYPTION_KEYto be configured on the server — otherwise the create returns 503 (the server refuses to persist a signing secret in plaintext).
The fire URL is:
You can also create triggers from the routine's detail page in the UI; the Webhook trigger type shows the fire URL and the one-time secret.
Firing it — signing_mode: "none"¶
The default. No signature header is required; any caller who knows the
public_id can fire it.
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"reason":"GitHub PR #123 opened"}' \
"https://trimus.example.com/api/routine-triggers/public/$PUBLIC_ID/fire"
# 200 {"status":"enqueued"}
Use none only when the URL is unguessable and you're comfortable
that anyone who learns the public_id can fire the routine. For anything
internet-facing, use hmac.
Trust model — the fire URL is a capability. With signing_mode: "none",
the public_id in the URL is the only thing gating a fire: it is a bearer
secret, like any "secret URL" webhook (GitHub, Stripe, …). Treat the URL like a
password — don't paste it into logs, tickets, or referrer-leaking pages. The
public_id is a random UUID, so guessing is impractical, but a leaked URL is
fireable by anyone who has it until you rotate it.
Rotating the fire URL. If a URL leaks (or you just want to cycle it),
regenerate the public_id — the old URL stops working immediately:
curl -s -X POST \
-H "Authorization: Bearer $TRIMUS_TOKEN" \
"https://trimus.example.com/api/routine-triggers/$TRIGGER_ID/rotate-url"
# 200 {... "public_id": "<new uuid>", ...}
In the UI, the Triggers list has a Regenerate URL button on each webhook
trigger (and flags unsigned ones with a warning). Rotating the URL leaves the
HMAC signing secret untouched; to cycle the secret instead, use rotate-secret
(or the Rotate secret button).
Firing it — signing_mode: "hmac"¶
When the trigger is hmac, the caller must send an X-Staple-Signature
header. The header is the hex-encoded HMAC-SHA256 of the raw request
body, keyed by the secret — not the secret itself. Trimus recomputes
the HMAC over the bytes it received and compares with a constant-time
check.
BODY='{"reason":"GitHub PR #123 opened"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -s -X POST \
-H 'Content-Type: application/json' \
-H "X-Staple-Signature: $SIG" \
-d "$BODY" \
"https://trimus.example.com/api/routine-triggers/public/$PUBLIC_ID/fire"
# 200 {"status":"enqueued"}
In code (Python):
import hashlib, hmac, requests
body = b'{"reason":"GitHub PR #123 opened"}'
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
requests.post(
f"{BASE}/api/routine-triggers/public/{PUBLIC_ID}/fire",
data=body, # send the EXACT bytes you signed
headers={"Content-Type": "application/json",
"X-Staple-Signature": sig},
)
Sign the exact bytes you transmit. If a serializer re-orders keys or changes whitespace between signing and sending, the HMAC won't match.
Firing it — signing_mode: "hmac_ts"¶
hmac_ts is hmac plus a signed, freshness-checked timestamp, so a
captured request can't be replayed once it ages out — closing the gap the
fixed-window replay dedup leaves open (a captured body is otherwise
re-fireable after the window expires). The mode is read from the trigger
server-side, so an attacker cannot downgrade to body-only by stripping
the timestamp.
The caller sends two headers:
X-Staple-Timestamp: the current Unix time in seconds.X-Staple-Signature: hex HMAC-SHA256 of"<timestamp>.<body>"(the timestamp, a literal., then the raw body bytes), keyed by the secret.
Trimus rejects the request (401) if the timestamp header is missing
(missing timestamp), unparseable (invalid timestamp), or more than
300 s from the server clock in either direction (timestamp out of
tolerance), and then verifies the signature over "<ts>.<body>".
BODY='{"reason":"GitHub PR #123 opened"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -s -X POST \
-H 'Content-Type: application/json' \
-H "X-Staple-Timestamp: $TS" \
-H "X-Staple-Signature: $SIG" \
-d "$BODY" \
"https://trimus.example.com/api/routine-triggers/public/$PUBLIC_ID/fire"
# 200 {"status":"enqueued"}
In Python:
import hashlib, hmac, time, requests
body = b'{"reason":"GitHub PR #123 opened"}'
ts = str(int(time.time()))
sig = hmac.new(SECRET.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
requests.post(
f"{BASE}/api/routine-triggers/public/{PUBLIC_ID}/fire",
data=body,
headers={"Content-Type": "application/json",
"X-Staple-Timestamp": ts,
"X-Staple-Signature": sig},
)
Prefer hmac_ts over hmac for internet-facing producers you control
(both ends of the signing scheme). Keep hmac when the producer can't
send a timestamp header.
What Trimus does on a fire¶
- Looks up the trigger by
public_id→ 404 if unknown. - 404 if the trigger is disabled.
- If
signing_mode == "hmac"or"hmac_ts": verifyX-Staple-Signature(decrypting the stored secret first). Missing header → 401missing signature; wrong HMAC → 401invalid signature. Inhmac_ts, first require a freshX-Staple-Timestamp(±300 s) and sign over"<ts>.<body>"(a stale/missing timestamp → 401). - Replay check: SHA-256 the body; if a run for this trigger within
the replay window (default 300 s, set via
replay_window_sec) already had the same body hash, reject with 409{"error":"replay_rejected"}. - Apply the routine's concurrency policy (
always_enqueue,coalesce_if_active,skip_if_active) and create the run. - Return 200
{"status":"<policy result>"}— e.g.enqueued,coalesced, orskipped.
The response is the policy outcome, not a run id. The body you sent is recorded on the resulting run as its payload, so the routine's prompt template can interpolate fields from it.
Replay protection in practice¶
Identical bodies fired within the replay window are rejected as replays. If your source legitimately needs to fire the same payload twice in quick succession (a retried delivery that should actually run again), add a unique field — a delivery ID, a timestamp, a nonce — so the body hash differs:
The replay window is a time-bounded dedup — after it expires, a
captured request becomes re-fireable. For producers where that matters,
use signing_mode: "hmac_ts": the signed X-Staple-Timestamp makes a
captured request un-replayable once it ages past the 300 s tolerance,
independent of the dedup window.
Rotating the secret¶
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
"$BASE/api/routine-triggers/$TRIGGER_ID/rotate-secret"
# returns the new secret once
The old secret stops working immediately. Roll your callers to the new
one. ($TRIGGER_ID is the trigger's id, not its public_id.)
Plugin webhooks¶
Plugins can register their own webhook paths. The callback URL is:
path is whatever the plugin's manifest declared. The delivery is routed
to the plugin as a deliver_webhook message (path, headers, body); the
plugin decides what to do and how to authenticate it. This route lives in
the member+ company group, so the caller needs a member+ bearer token
to reach it — plugin webhooks are an internal extension point, not a
public ingress like routine triggers. See
writing-a-plugin.md and the Webhooks section
of PLUGIN.md.
Payload conventions¶
For routine triggers, the body is whatever JSON your caller sends. Trimus records it on the resulting run. A stable shape makes it easy to write one routine that handles many sources:
{
"source": "github",
"event": "pull_request.opened",
"ref": "owner/repo#123",
"summary": "Add caching to /api/issues list",
"body": "PR description here, markdown OK"
}
Failure modes¶
| Response | Meaning | Fix |
|---|---|---|
401 missing signature |
hmac trigger, no X-Staple-Signature header |
Send the HMAC header. |
401 invalid signature |
HMAC didn't match | Sign the exact bytes you send, with the current secret. |
409 replay_rejected |
Same body hash within the replay window | Add a unique field, or wait out the window. |
404 trigger not found |
Unknown public_id, or the trigger was deleted |
Stop retrying; re-create the trigger. |
404 trigger is disabled |
Trigger exists but is disabled | Re-enable it. |
429 |
Per-IP rate limit | Slow down or raise RATE_LIMIT_RPM. |
503 (on create) |
hmac requested but TRIMUS_ENCRYPTION_KEY unset |
Configure the encryption key on the server. |
Security considerations¶
- Treat the secret like an API key. With
hmac, anyone who can forge a valid signature can fire the trigger. - The public ID is not the secret. It's fine to log the
public_id; never log the secret or the signature. - Lock down at the proxy when you can. If only one source should hit a trigger, allow only that source's IP ranges at your reverse proxy / WAF. Trimus itself has no per-trigger IP or User-Agent allowlist — network-layer controls are how you scope the caller.
- Audit it. Every fire creates a routine run; watch the routine's run history for unexpected sources.
Where to next¶
- api-recipes.md — the outbound direction.
- writing-a-plugin.md — when a routine isn't enough and you need plugin-defined logic.
- patterns.md — a worked GitHub-bridge example.