Skip to content

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>"
}
  • kind must be "webhook" (or "schedule" for cron — different shape). Over this JSON create endpoint, signing_mode accepts 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 that PATCH cannot change signing_mode at all — pick the mode at create time.
  • public_id is 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 as secret, and stores it encrypted at rest. Creating a signed trigger requires TRIMUS_ENCRYPTION_KEY to be configured on the server — otherwise the create returns 503 (the server refuses to persist a signing secret in plaintext).

The fire URL is:

POST https://trimus.example.com/api/routine-triggers/public/<public_id>/fire

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

  1. Looks up the trigger by public_id404 if unknown.
  2. 404 if the trigger is disabled.
  3. If signing_mode == "hmac" or "hmac_ts": verify X-Staple-Signature (decrypting the stored secret first). Missing header → 401 missing signature; wrong HMAC → 401 invalid signature. In hmac_ts, first require a fresh X-Staple-Timestamp (±300 s) and sign over "<ts>.<body>" (a stale/missing timestamp → 401).
  4. 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"}.
  5. Apply the routine's concurrency policy (always_enqueue, coalesce_if_active, skip_if_active) and create the run.
  6. Return 200 {"status":"<policy result>"} — e.g. enqueued, coalesced, or skipped.

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:

{"reason":"GitHub PR #123 opened","delivery_id":"a1b2c3","ts":"2026-05-31T12:00:00Z"}

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:

POST https://trimus.example.com/api/companies/{companyId}/plugins/webhooks/{path}

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