Webhooks
Jimothy can run a small HTTP server that receives webhooks. It serves two purposes:
- Webhook triggers: a generic trigger type any system can
POSTJSON to. Each request starts a run. - Instant starts for Linear, Jira and GitHub triggers: those triggers poll, but they also accept their platform’s native webhooks at their own URL, so runs start within seconds.
Enable the webhook server
Section titled “Enable the webhook server”The server is off by default. Turn it on in Settings → Webhook server:
| Field | Default | Notes |
|---|---|---|
| Receive trigger webhooks (Linear, Jira, GitHub, custom) for instant runs | off | |
| Listen address | 127.0.0.1 |
127.0.0.1 = this machine only (use a tunnel); 0.0.0.0 = your network. |
| Port | 7717 |
Click Save. When it’s running, Settings shows Listening on http://127.0.0.1:7717/hooks/<trigger-id>. If the port is taken, you get a Trigger errors notification: Webhook server failed to start — Port 7717: ….
Endpoints
Section titled “Endpoints”| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Returns {"ok": true}. Use it to check a tunnel. |
POST |
/hooks/<trigger-id> |
Deliver an event to one trigger. A trailing slash is allowed. |
Everything else returns 404 {"error":"not found"}. Request bodies over about 2 MB are dropped.
The trigger id (trg_…) is shown in the trigger editor’s Instant webhook box once the trigger is saved. That box shows the full URL with a copy button.
Generic webhook triggers
Section titled “Generic webhook triggers”Open Triggers → Add trigger → Webhook. The editor has Name, Runs pipeline and Webhook secret (prefilled with a random value). Save, then copy the URL.
Payload
Section titled “Payload”POST a JSON object. Every field is optional except a title:
{ "title": "Add rate limiting to the public API", "description": "Limit /v1/* to 100 req/min per key. Return 429 with Retry-After.", "key": "OPS-981", "url": "https://tickets.acme.dev/OPS-981", "labels": ["api", "security"], "priority": "high"}| Field | Used as | Fallbacks |
|---|---|---|
title |
{{issue.title}} |
summary, then the ?title= query parameter. If there’s no title, the request is ignored (HTTP 202). |
description |
{{issue.description}} |
body, else empty. |
key |
{{issue.key}} (and the dedup key) |
id, else a generated HOOK-<timestamp> key. |
url |
{{issue.url}} |
— |
labels |
{{issue.labels}} |
Must be an array. |
priority |
{{issue.priority}} |
— |
| the whole body | {{issue.raw.*}} |
Any extra fields you send, e.g. {{issue.raw.environment}}. |
If the body isn’t valid JSON, its first 200 characters become the title and the whole body the description.
{{issue.source}} is webhook.
Authentication
Section titled “Authentication”With a Webhook secret set, every request must present it in one of these ways:
?token=<secret>in the URL (the copied URL already includes it);- an
X-Factory-Token: <secret>header; Authorization: Bearer <secret>.
Clear the secret to accept unauthenticated requests (not recommended once the server is reachable from outside).
Examples
Section titled “Examples”# Token in the query string (as copied from the trigger editor)curl -X POST "http://127.0.0.1:7717/hooks/trg_0abc123?token=s3cr3t" \ -H 'content-type: application/json' \ -d '{"title":"Bump lodash to 4.17.21","key":"DEP-1","labels":["deps"]}'
# Token as a headercurl -X POST http://127.0.0.1:7717/hooks/trg_0abc123 \ -H 'X-Factory-Token: s3cr3t' -H 'content-type: application/json' \ -d '{"title":"Investigate 500s on /checkout","description":"Sentry issue 4411"}'Webhook runs are deduplicated by key per trigger, like tracker issues: a second request with the same key doesn’t start another run. Omit key to start a run for every request (each one gets a fresh HOOK-… key). See Polling & dedup.
Generic webhook triggers don’t write back anywhere; use a shell step or a notification channel to report results.
Provider webhooks for Linear, Jira and GitHub
Section titled “Provider webhooks for Linear, Jira and GitHub”A Linear, Jira or GitHub trigger’s editor shows Instant webhook (optional — in addition to polling) with the trigger’s URL and a Webhook secret field. Point the platform’s webhook at that URL and the trigger starts runs as soon as events arrive. Polling keeps running as a safety net.
| Trigger | Verification | Secret field |
|---|---|---|
| Linear | Linear-Signature header: hex HMAC-SHA256 of the raw body |
Linear webhook signing secret |
| GitHub | X-Hub-Signature-256 header: sha256= + hex HMAC-SHA256 |
the secret you entered on GitHub |
| Jira | shared token: ?token=, X-Factory-Token or Bearer (the copied URL includes ?token=) |
any random string |
Signatures are compared in constant time. If no secret is set, requests aren’t verified.
How each platform’s events are filtered is described on its page: Linear, Jira, GitHub. In short, webhook events are checked against some but not all of the trigger’s filters, and Jira webhook events aren’t checked against the JQL.
Responses
Section titled “Responses”| Status | Body | Meaning |
|---|---|---|
201 |
{"issue": "OPS-981", "created": true} |
A run was started. |
200 |
{"issue": "OPS-981", "created": false} |
Accepted but no run (already processed, or not re-triggered). |
202 |
{"ignored": true} |
Not an event this trigger handles (no title, a filtered-out label, a pull request, …). |
400 |
{"error": "…"} |
The provider payload couldn’t be parsed. |
401 |
{"error": "invalid signature"} |
Wrong or missing secret / signature. |
404 |
{"error": "unknown trigger"} |
No trigger with that id. |
409 |
{"error": "trigger disabled"} |
The trigger is switched off. |
Exposing the server to cloud services
Section titled “Exposing the server to cloud services”Linear, GitHub and Jira Cloud can’t reach 127.0.0.1. Keep Listen address on 127.0.0.1 and put a tunnel in front of it. Then use the tunnel’s public URL plus /hooks/<trigger-id> (and ?token=… where needed) as the webhook URL.
ngrok http 7717# Forwarding https://ab12-34-56.ngrok-free.app -> http://localhost:7717Webhook URL: https://ab12-34-56.ngrok-free.app/hooks/trg_0abc123. Free ngrok URLs change on every restart unless you reserve a domain, so you’d have to update the webhooks each time.
Quick, temporary tunnel:
cloudflared tunnel --url http://localhost:7717# … https://random-words.trycloudflare.comFor a stable hostname, create a named tunnel in Cloudflare Zero Trust that routes a hostname such as jimothy-hooks.example.com to http://localhost:7717.
tailscale funnel --bg 7717# https://my-mac.tail1234.ts.net/ -> proxy http://127.0.0.1:7717Webhook URL: https://my-mac.tail1234.ts.net/hooks/trg_0abc123. Funnel must be allowed for your tailnet. Stop it with tailscale funnel --https=443 off.
This is independent of Remote access, which uses tailscale serve on a different port and refuses to run on a port that has Funnel enabled.