Overview
The Webhook action lets an automation send an HTTP request carrying a JSON payload of your choosing to an external system whenever something happens in ZenGRC. For example, it can notify an on-call rotation the moment a Risk is flagged critical, open a ticket in an ITSM system when an Issue is created, or push Assessment completions into a data warehouse.
Instead of polling the ZenGRC API on a schedule and diffing the results, ZenGRC pushes to your endpoint the moment the change happens, with exactly the fields you ask for. The payload is a JSON template you author yourself, not a fixed envelope, so it can match whatever shape your receiving system expects.
Important: The Webhook action can only reach endpoints that are on the public internet, over HTTPS, at a DNS name that resolves to a public IP address. It cannot reach localhost, private network ranges (10.x, 192.168.x, 172.16-31.x), carrier-grade NAT (100.64.x), or link-local addresses such as 169.254.169.254. This is a deliberate security guard against server-side request forgery (SSRF) and can't be turned off. If your receiver lives inside your own network, put an internet-facing relay in front of it. This is the single most common cause of "my webhook doesn't work."
Prerequisites
An Administrator role, or whichever role your instance grants automation-edit permission to
The Webhook action enabled for your tenant. If you don't see a Webhook card in the action picker, it isn't enabled yet. Contact your ZenGRC representative.
A receiver that:
Is reachable over HTTPS with a valid, publicly-verifiable TLS certificate (self-signed certificates are rejected)
Resolves to a public IP address
Accepts POST, PUT, or PATCH
Responds within 30 seconds
How the Action Works
Every Webhook action answers four questions:
Where does it go? The destination URL and HTTP method.
How do we authenticate? Headers, optionally marked as secret.
What do we send? A JSON payload template using
{{object.*}}variables.What happened? The delivery outcome, recorded in the automation's execution history.
The trigger and filters are configured on the automation itself, exactly as for any other action. The Webhook action only handles the outbound call once the automation fires.
Step by Step Instructions
The example below builds a webhook that notifies an incident intake endpoint whenever a Risk tagged critical is created.
Step 1: Configure the Trigger
Go to Automations > + New Automation (or open an existing one).
Set the trigger and filters. For example, set When to Object Created with object type Risk, and Filter to Tags contains critical. This part is identical regardless of which action you add.
Step 2: Add the Webhook Action
In the Actions panel, click + Add action and choose Webhook (the plug icon).
A card appears with a URL field, a method selector, a headers area, a JSON payload editor, and a Send test button.
Step 3: Enter the Destination URL
Enter your endpoint, e.g. https://intake.example.com/hooks/zengrc. The URL must start with https://. An http:// URL shows an inline error immediately and blocks Save. The URL also can't embed credentials (https://user:pass@host/...) or exceed 2048 characters; put credentials in a header instead.
Step 4: Choose the HTTP Method
Leave it on POST unless your receiver expects PUT or PATCH. There's no GET or DELETE option, since this action always carries a body.
Step 5: Add Headers
Click + Add Header. A row appears with Name, Value, and a Secret checkbox.
For an API key: set Name to something like
X-Api-Key, enter the value, and tick Secret. The value field switches to a password input as you type.Click the checkmark to confirm. The header displays as a chip, e.g.
🔒 X-Api-Key: ••••••.
You can add up to 20 headers. Content-Type: application/json is added automatically unless you set your own.
Important: Certain connection-controlled headers can't be set and are rejected at save time: Host, Content-Length, Transfer-Encoding, Connection, Keep-Alive, Proxy-Authenticate, Proxy-Authorization, TE, Trailer, Upgrade. These belong to the HTTP layer itself.
Step 6: Author the Payload
The editor is prefilled with a default envelope keyed to your trigger object type:
{ "event": "automation.triggered", "object": { "type": "Risk", "title": "{{object.title}}", "url": "{{object.object_url}}" } }Edit it freely to match your receiver's expected format. This is a template you fully control. Watch the Valid JSON / Invalid JSON indicator as you type; Save is blocked while the payload is empty or invalid. The payload must be between 1 and 10,000 characters, both as typed and after variables are substituted.
Step 7: Send a Test
Click Send test. ZenGRC delivers the current configuration to your endpoint once, with no retries, using a real object of the trigger type to fill the template. A result strip shows the outcome, e.g. 200 OK · 143ms. Click View details to see the full rendered request and response.
Note: The test send goes through the same SSRF guard and TLS verification as a real delivery. If the test is blocked, a real delivery would be too.
Step 8: Save the Automation
Click Save. The action is live and fires on the next matching trigger.
Payload Templating
Tokens take the form {{object.<path>}} and resolve against the object that triggered the automation.
Token | Resolves To |
| The object's title |
| Its status, using your status workflow's display name |
| Its description |
| A deep link back into ZenGRC |
| A collection, joined with |
| The email of each owner, joined with |
| The value of custom attribute |
| Assignees (also available: |
Note: Values are JSON-escaped, not HTML-escaped, so a title containing a " or \ won't break your JSON. Keep every token inside quotes, since a token substitutes as a JSON string fragment: { "title": "{{object.title}}" } is correct, while { "count": {{object.some_number}} } will fail once the field contains anything non-numeric. An unresolvable token (a typo, a null value) renders as an empty string rather than causing an error. Use a test send to check your spelling. Collections are joined into one string, not returned as a JSON array.
Secret Headers
Ticking Secret on a header does three things:
Encrypts the value at rest.
Masks it as
••••••on every subsequent read: the builder, the API, and the automation listing. There's no way to read the original value back out; keep your own copy.Preserves it automatically on future edits. If you save the automation without touching that header, the mask is treated as "keep the stored value."
Important: Renaming a secret header without re-entering its value will fail the save, since the "keep it" lookup matches on the header's name. If you rename X-Api-Key to X-API-Key while it still shows the mask, there's no stored value under the new name. You'll need to re-enter the real value when renaming a secret header. Non-secret header values are stored and displayed as plain text, so don't put a token in one and forget to tick the box.
Delivery, Retries, and Timeouts
Behavior | Value |
Attempts | Up to 3 (the original plus 2 retries) |
Backoff | 2 seconds before attempt 2, 5 seconds before attempt 3 |
Connect timeout | 10 seconds |
Read timeout | 30 seconds |
Retried on | Network errors, timeouts, HTTP 429, and HTTP 5xx |
Not retried on | Any other 4xx, treated as a configuration error and failed immediately |
Redirects | Not followed. A 3xx is a failed delivery |
Success | Any 2xx |
Response captured | First 10 KB read; first 500 characters stored as a snippet |
Delivery happens synchronously as part of the automation's execution, so a slow endpoint slows the whole automation run. Keep your receiver fast by accepting and enqueuing the request rather than processing it inline. Redirects aren't followed because a redirect could otherwise be used to bypass the SSRF guard; point the action directly at the final URL.
Where the outcome lands: Open the automation and check its execution history. A successful delivery records the HTTP status, attempt count, and response snippet. A failed delivery is marked failed with an error such as "webhook delivery failed after 3 attempt(s): HTTP 500." If the trigger fired but your filters didn't match, you'll still see an execution row marked skipped. That's expected, not a failed webhook. A disabled automation produces no execution row at all.
Worked Examples
Page on-call when a critical Risk is created Trigger: Object Created on Risk, filtered to Tags contains critical.
{ "routing_key": "YOUR_ROUTING_KEY", "event_action": "trigger", "payload": { "summary": "New critical risk in ZenGRC: {{object.title}}", "severity": "critical", "source": "zengrc" }, "links": [{ "href": "{{object.object_url}}", "text": "Open in ZenGRC" }] }Open an ITSM ticket when an Issue is created Trigger: Object Created on Issue. Header: Authorization: Basic ... (Secret).
{ "short_description": "{{object.title}}", "description": "{{object.description}}\n\nZenGRC: {{object.object_url}}", "u_source": "zengrc", "u_status": "{{object.status}}" }Note the escaped newline (\n). A literal line break in the payload will not work.
Push Assessment completions to a data pipeline Trigger: Status Transition on Assessment, to Completed. Headers: X-Api-Key (Secret), X-Tenant: acme.
{ "event": "assessment.completed", "assessment": { "title": "{{object.title}}", "status": "{{object.status}}", "owners": "{{object.owners.email}}", "url": "{{object.object_url}}" } }Include a custom attribute For a Vendor custom attribute like "Criticality Tier" (id 412):
{ "vendor": "{{object.title}}", "tier": "{{object.ca_412}}", "link": "{{object.object_url}}" }Find a custom attribute's ID in your Custom Attributes configuration, or use a test send to confirm what comes through.
Permissions
Building and editing webhook actions requires Administrator (or whichever role your tenant grants automation-edit permission to).
Secret header values can be set by anyone who can edit the automation, but can't be read back by anyone. Treat automation-edit permission as equivalent to holding the credential.
The payload is rendered from the trigger object's fields with no per-viewer permission filtering. Whoever receives the webhook sees whatever fields you put in the template, regardless of what triggered the change could see. Be deliberate about what you send to a lower-trust endpoint, especially descriptions, custom attributes, and people fields.
Troubleshooting
"could not resolve host '...'" DNS doesn't resolve the hostname from ZenGRC's network. Check for a typo, and confirm the name is publicly resolvable rather than internal-only DNS.
"host '...' resolves to a disallowed address ..." The hostname points at a private or reserved address. This is the SSRF guard. You need a publicly routable endpoint. This fires even if only one of several resolved addresses is private.
"URL must use https" / "URL must not contain userinfo" / "URL must include a host" Save-time URL validation. Fix the URL, and move any credentials into a header instead.
"'Host' is a connection-controlled header and cannot be set" Remove the header. See the list of restricted headers in Step 5.
"... is not a valid header name" Header names can't contain spaces; use a format like X-Api-Key.
"webhook body is not valid JSON after substitution" A resolved value broke otherwise-valid JSON. This is almost always caused by a token used outside quotes. Wrap every token in quotes.
"cannot preserve secret for header '...': no stored value to keep" You renamed a secret header while its value still showed the mask. Re-enter the real value.
"unexpected redirect (HTTP 3xx)" Your endpoint redirects. Redirects aren't followed. Point the action at the final URL.
Delivery fails with a TLS error Self-signed or otherwise unverifiable certificates are rejected. Use a certificate from a public CA.
"webhook delivery failed after 3 attempt(s): HTTP 500" Your receiver returned a 5xx three times. Check the response snippet in the execution history for details.
The test send succeeds but the automation never fires The action itself is fine. The trigger isn't matching. Check the execution history: a skipped row means the filters excluded the object, and no row at all means the automation is disabled or the trigger never fired.
The Webhook card isn't in the action picker The Webhook action isn't enabled for your tenant yet. Contact your ZenGRC representative.
An existing webhook action suddenly stops delivering If the feature is disabled for your tenant, existing actions stop delivering and record a skipped result rather than failing outright.
Quick Reference
Question | Answer |
Which schemes are allowed? | HTTPS only |
Can it reach my internal network? | No. Private, loopback, link-local, reserved, and carrier-grade NAT addresses are blocked |
Which methods? | POST, PUT, PATCH |
How many headers? | Up to 20 |
Can I set | No. Connection-controlled headers are rejected |
Is | Yes, |
Can I read a secret header back? | No. Masked as |
What happens if I rename a secret header? | The save fails unless you re-enter the value |
How big can the payload be? | Up to 10,000 characters, and it must be valid JSON |
What resolves the | The object that triggered the automation |
What if a token doesn't resolve? | It renders as an empty string; delivery still happens |
How many attempts? | 3 (backoff 2s, then 5s) |
What gets retried? | Network errors, timeouts, 429, 5xx. Other 4xx fail immediately |
Are redirects followed? | No. A 3xx is a failed delivery |
What counts as success? | Any 2xx |
Is delivery synchronous? | Yes. A slow endpoint slows the automation |
Does the test send retry? | No. Exactly one attempt |
Does the test send use real data? | Yes, the most recently updated object of the trigger type |
Need Help?
If you're having trouble setting up a webhook action, contact ZenGRC Support.
