-
Notifications
You must be signed in to change notification settings - Fork 2
Webhooks
A webhook posts to an endpoint you choose when something happens: a finding Detect reported, a response matching a filter you wrote, a fuzzing campaign finishing, an automation completing. It is how Joro tells you something without you watching the UI.
Everything here lives under Settings → Webhooks, and applies to proxy mode only.
Webhooks need none of the automation flags. --no-webhooks removes the feature entirely — no routes, no ~/.joro/webhooks.json, and no outbound request from this process.
Configured endpoints sit on the left; selecting one opens it on the right. The power button beside each row switches it on and off, and switching one back on also clears a pause Joro applied itself.
A row can carry two marks. broken means the webhook will not deliver and the tooltip says why — usually a trigger it names has been deleted, or its body template refers to a field none of its events carry. paused means the runaway breaker stopped it; see Delivery.
A webhook watches the same triggers an automation does, so a filter you build once decides both. Pick one or more:
-
Joro's own events —
request.captured,detect.finding,fuzzer.complete,automation.completed— fire every time they happen. - Custom triggers add conditions, so a webhook fires on some of them. Build one under Settings → Automation → Scripting, in the Triggers section of the rail; see Scripting#custom-triggers. Triggers are served whether or not any automation flag is set, so you can filter a webhook on a Joro started with none of them.
A webhook names at most eight.
detect.finding fires again each time a finding recurs, with isNew false. On a busy engagement that is a lot of notifications, and the usual answer is a custom trigger with isNew is true and a severity floor.
A trigger that has been deleted, or one this build cannot read, never fires — it never means "no filter". Deleting a trigger a webhook still references is refused, and names the webhook. A webhook goes further than an automation here: a trigger reference it cannot resolve is refused when you save it, rather than stored and discovered later.
A finding an agent filed through findings_create never fires a webhook, for the same reason it never fires an automation: an automation client must not be able to make Joro talk to the outside on demand.
A webhook that names no trigger at all is valid as long as automations may fire it. That is a channel a script speaks on and nothing else.
URL takes an http or https address, and Method is POST (the default), PUT or PATCH. Certificates are verified. A webhook URL is frequently the credential itself — a Slack or Teams incoming-webhook URL is the whole secret — so posting one over an unverified connection hands it to whoever answered. Skip TLS certificate verification, under Delivery, exists for an internal receiver with a self-signed certificate, and is off by default.
Auth is bearer, basic, or a header of your own name. Headers adds up to twenty others; the ones Joro sets itself — Content-Type, Content-Length, Host and the X-Joro-* family — are refused rather than silently overridden. Every delivery goes out as joro/<version>.
Signing turns on HMAC-SHA256 so a receiver can prove a delivery came from your Joro. The signature covers <timestamp>.<body> and arrives as sha256=<hex> in the header you name, X-Joro-Signature by default. The timestamp is inside the signed string on purpose: a receiver that verifies the body alone will accept a captured delivery replayed at any time.
Verifying one, in Python:
import hmac, hashlib
expected = "sha256=" + hmac.new(
secret.encode(),
request.headers["X-Joro-Timestamp"].encode() + b"." + request.data,
hashlib.sha256,
).hexdigest()
hmac.compare_digest(expected, request.headers["X-Joro-Signature"])Every delivery also carries X-Joro-Event, X-Joro-Trigger, and X-Joro-Delivery — a unique id, so a receiver can drop a repeat after a retry.
Secrets are never returned. A stored token, signing key or header value comes back empty with the field marked as filled. Leaving one blank keeps what is stored, so editing a name cannot silently wipe a key you can no longer see. To clear one, change the auth kind or switch signing off.
Four shapes:
| Format | Sends |
|---|---|
envelope |
Joro's own JSON: the event, the trigger that matched, a one-line summary, and every field the event carries |
slack |
{"text": "…"} carrying the summary |
discord |
{"content": "…"} carrying the summary |
template |
A JSON document you write |
A template is a JSON document, not a string with holes. Joro parses it when you save, then substitutes only inside string values. What you write is the finished shape: a placeholder can fill a value and cannot add a key, close an object, or turn a number into a string — so a host header or a finding name containing a quote arrives correctly escaped rather than breaking the body.
Two consequences worth knowing. A placeholder in a key is refused when you save, because filling one would change the shape. And a placeholder none of the webhook's events carry is refused too, with the fields it could have meant listed beside the editor.
{
"text": "{{SUMMARY}}",
"blocks": [
{ "type": "section", "text": { "type": "mrkdwn", "text": "*{{severity}}* — {{name}}\non `{{host}}`" } }
]
}Always available: {{SUMMARY}} (Joro's one-liner — what the Slack and Discord presets send), {{EVENT}}, {{TRIGGER}}, {{WEBHOOK}}, {{TIME}}, {{INSTANCE}} (this Joro's version, not a hostname), and {{MESSAGE}} — which carries what an automation passed and is empty for a delivery an event fired.
Per event: every field that event carries, under the same names the condition editor uses — {{severity}}, {{host}}, {{url}}, {{status}}, and so on. The editor lists them beside the box. Request and response bodies are deliberately not available: they exist so a condition can search them, and a notification is not where a megabyte belongs.
A webhook watching two events may name a field only one of them carries. The one that does not renders it empty.
A template is capped at 16 KiB and the rendered body at 256 KiB. Passing either is refused rather than truncated — half a JSON document is not something a receiver should have to parse. A single substituted value longer than 4 KiB is cut with a trailing …, so one enormous header cannot push an otherwise reasonable body over.
Joro's own envelope is {webhook, event, trigger, time, summary, instance, events[]}, plus message and dropped when they are non-empty.
One request per event is the default. Batch events together sends one request carrying up to fifty, which suits a machine receiver and not a chat client. A template renders one event's fields, so it always sends one request per event.
Minimum interval paces the endpoint — a second by default, an hour at most. Events arriving faster queue up, two hundred deep; past that the oldest are dropped and the count travels in the next delivery as dropped, so a receiver can see it was told less than everything rather than assuming it was told all.
Retries back off, doubling from half a second, and only for a timeout, a connection failure, a 429 or a 5xx. A 4xx other than 429 is the receiver saying the request is wrong, and repeating it will not make it right. Two by default, five at most. Timeout is ten seconds by default and sixty at most.
The runaway breaker pauses a webhook that delivers sixty times in a minute and tells you why. Your own on/off switch is untouched, so switching it back on is what resumes it — usually after narrowing its trigger or raising the interval.
Test sends a real request to the real endpoint, rendering a sample of the first event the webhook watches. It shows the exact bytes sent and what came back, which is what finds a wrong URL, a wrong auth header or a body the receiver rejects. Save first: a test sends what is stored, not what is on screen.
Recent deliveries below the editor lists the last twenty attempts with status, duration, retries, how many events the delivery carried, how many were dropped to make room, and any error. It is in memory, so a restart clears it.
Deliveries go straight out rather than through Joro's own proxy. They are never captured into History, never scanned by Detect, never rewritten by Match & Replace, and never stall in an enabled Intercept queue — which is also why a webhook watching traffic cannot feed itself.
Tick Let automations fire this and a sandboxed automation can send through it by name:
async function run(ctx) {
const findings = joro.findings.list({ minSeverity: 'high', limit: 5 })
joro.webhook.send({
id: 'team-slack',
message: `${findings.length} new high findings on this engagement`,
data: { host: 'api.example.com' },
})
}joro.webhook.list() returns the endpoints open to automation, by id and name. It never returns a destination, and neither does anything else a script can call: an automation chooses among your endpoints, and never chooses where.
The tick is the whole gate. Without it no automation can reach the endpoint, and none can tell it apart from one that does not exist — the refusal is worded the same either way, so a script cannot enumerate what you have configured. The body stays the shape you set: message fills {{MESSAGE}} and stands in for the summary, and data is delivered only when the format is Joro's own envelope.
Firing is rate limited to ten a minute per principal. For a sandboxed run that works out per run, since each run is its own principal; a long-lived MCP token is not so lucky — one budget covers every call it makes, however many automations it drives. Every call is recorded in Settings → Automation → Activity with the webhook's id and the status, never the payload.
~/.joro/webhooks.json, mode 0600, holding up to fifty endpoints and their secrets. It is global rather than per-project, for the same reason triggers.json is: a webhook references a trigger by id, and a per-project copy would resolve on one engagement and dangle on the next. It never travels inside a project config, so a .joro you share with a teammate carries none of your keys.
- Scripting — the custom triggers a webhook filters on, and the automations that can fire one
-
Automation —
webhook_listandwebhook_fire, and what a token may do with them - Settings — the Webhooks category
-
Detect — where a
detect.findingdelivery comes from -
Configuration —
--no-webhooks