LanternDocs

Webhook triggers

A trigger binds a public URL, POST /v1/hooks/{id}, to one agent. GitHub, Sentry, PagerDuty or any service that can sign a request with HMAC-SHA256 posts events there, and every verified delivery starts a run. The signature is the only credential: the sender never holds a Lantern token.

In plain termsThe trigger URL is a mail slot with a wax seal. Anyone can drop a letter in, but the clerk only opens letters stamped with the seal that matches your secret. A letter delivered twice is opened once.

How it works

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.

Create a trigger

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

curl -X POST http://localhost:8080/v1/agents/my-agent/triggers \
  -H "Authorization: Bearer $LANTERN_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 $LANTERN_API_KEY"
curl http://localhost:8080/v1/triggers/$ID -H "Authorization: Bearer $LANTERN_API_KEY"
curl -X PATCH http://localhost:8080/v1/triggers/$ID -H "Authorization: Bearer $LANTERN_API_KEY" -d '{"enabled":false}'
curl -X DELETE http://localhost:8080/v1/triggers/$ID -H "Authorization: Bearer $LANTERN_API_KEY"   # 204

lantern hooks is also reachable as lantern hook and lantern triggers. 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 URL and the secret

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

  • If you omit secret, Lantern generates one (whsec_ plus 64 hex characters) and returns it in the create response only. List and get never return it. The owner data export leaves it out too.
  • If you supply one, it must be at least 16 characters and is not echoed back.
  • To change it, PATCH /v1/triggers/{id} with a new secret (same 16-character minimum). The old secret stops working immediately; there is no overlap window.

Provider setup

ProviderHeaderSigned bytesDelivery id (dedup key)
githubX-Hub-Signature-256: sha256=<hex>raw bodyX-GitHub-Delivery
sentrySentry-Hook-Signature: <hex>raw bodyRequest-ID
pagerdutyX-PagerDuty-Signature: v1=<hex>[,v1=<hex>]raw bodyevent.id in the JSON body
genericX-Lantern-Signature: t=<unix>,v1=<hex>[,v1=<hex>]<t>.<raw body>X-Lantern-Delivery (optional)

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

GitHub

Create a github trigger and let Lantern generate the secret. In the repository (or organization), 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. The event name is available to templates 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 paste Sentry's client secret into Lantern:

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

Or use Replace secret in the dashboard, or PATCH with secret. Lantern reads the event from Sentry-Hook-Resource.

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 subscription's delivery URL, then replace the trigger's secret with the one PagerDuty displays. During a PagerDuty secret rotation it sends several v1= signatures in one header; 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-Lantern-Signature: t=<unix>,v1=<hex>. The timestamp must be within five minutes of server time, in either direction. Send X-Lantern-Delivery as a stable id per event and optionally X-Lantern-Event.

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-Lantern-Signature: t=$T,v1=$SIG" \
  -H "X-Lantern-Delivery: incident-42" \
  -d "$BODY"
# HTTP/1.1 202 Accepted
# {"runId":"...","deliveryId":"incident-42"}

Run it a second time within five minutes with the same X-Lantern-Delivery and you get the duplicate response below.

Responses

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

StatusBodyWhen
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.

Limits

  • Body size: 1 MiB. Larger gets 413.
  • Rate: per trigger, 60 deliveries per minute with a burst of 10 by default. Set LANTERN_HOOK_RATE_PER_MIN and LANTERN_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.

Duplicate deliveries

Every delivery is checked against two keys, each unique per trigger: the provider's delivery id (table above; for generic without X-Lantern-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. The consequence: a sender that posts the byte-identical body twice gets one run. The real providers put a unique id or timestamp in their payloads; for hand-rolled senders use generic, whose signed bytes include the timestamp. Delivery rows are not pruned yet.

What the run receives

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.

lantern hooks create my-agent --provider github \
  --template '{"pr":"{{payload.number}}","title":"{{payload.pull_request.title}}","note":"PR {{payload.number}} opened"}'

Budgets and spend

A delivery creates a run through the same handler as POST /v1/runs, so the agent's budget and the tenant spawn limit apply unchanged. With hardFail on and a cap reached, the delivery gets 402 and no run starts. A signed sender can therefore spend only the budget of the one agent its trigger is bound to. Set a budget on any agent you expose this way.

Security notes

  • Signature only. The route takes no token. The one query before verification is the trigger lookup by id; the trigger's tenant is taken from that row, never from the request.
  • Constant-time compare of the MAC (hmac.Equal); every candidate signature is checked with no early exit.
  • 404 vs 401. Unknown, disabled and bad-UUID ids all return the same 404 body before any signature check, so an outsider cannot tell which of those ids exist. 401 appears only for an existing, enabled trigger, including one whose agent was deleted: that trigger returns 404 only after a valid signature. The rate limit is spent only after the signature passes, so an unsigned flood cannot use up a real sender's allowance.
  • Secret storage. Stored encrypted with AES-256-GCM when LANTERN_CREDENTIAL_KEY is set (plaintext pass-through when unset, as for other credentials). Never returned by list or get, never logged, and left out of the owner data export.
  • Tenant scoping. Trigger CRUD runs under row-level security by tenant; get, update and delete of another tenant's trigger return 404. The run is created in the trigger's tenant.
  • Replay. Only generic signs a timestamp (five-minute window). The others rely on the duplicate check above.

Not yet

  • No overlap window when rotating a secret.
  • No retention sweep for delivery records.
  • Per-replica rate limit; no per-IP limit at this layer.
  • No event filter: every verified delivery, including a GitHub ping, starts a run.

Related: API reference, Budgets, CLI, SDKs.