Send Webhook Action — Developer Integration Guide
A technical reference for developers building a service that receives webhooks from Oppy’s Send Webhook automation rule action. If you’re a business user asking when and why to use automation rules, start with the Automation Rules — Customer Guide. For the catalog of event types Oppy emits, see the Oppy System Events Reference.
What this action does
When an automation rule’s trigger fires, the send_webhook action makes a single outbound HTTP request to a URL you configure. The request carries the triggering event’s payload as JSON and, optionally, authentication headers you can verify. No retries are performed by the action — implement idempotency or retry on your end if you need it.
Rule configuration
Configure in the Oppy frontend under Automation Rules → New Rule → Send Webhook, or via the API (POST /api/automation_rules). The relevant fields:
| Field | Purpose |
action_config.webhook_url |
HTTPS URL of your receiver. Must not resolve to localhost, 127.0.0.1, 0.0.0.0, [::1], metadata.google.internal, or any private/loopback/link-local IP. Use a public tunnel (e.g. ngrok) when testing locally. |
action_config.method |
POST (default), PUT, or PATCH. Any other value falls back to POST. |
action_config.body_template |
Optional. A JSON template string with interpolated payload values (see Templating). Leave blank for the default envelope. |
action_config.headers |
Optional. Extra headers merged into the request. Values support templating. |
webhook_auth_header |
Optional. Header name for API key auth. Defaults to X-API-Key. |
webhook_auth_key |
Optional. API key value. Encrypted at rest; never returned by the API after creation. |
webhook_secret |
Optional. HMAC signing secret. Encrypted at rest; never returned by the API after creation. |
Both webhook_auth_key and webhook_secret are independent and optional. If you configure neither, your receiver has no way to verify requests came from Oppy — the UI and logs will flag this. Production integrations should use at least one.
Request shape
POST https://your-receiver.example.com/hook
Content-Type: application/json
X-API-Key: <your webhook_auth_key> # only if configured
X-Webhook-Signature: sha256=<hex> # only if webhook_secret is configured
Default body (no body_template set)
{
"event": {
"type": "note.updated",
"fired_at": "2026-04-24T21:16:04Z"
},
"rule": {
"id": "c1a5…",
"name": "Notify CRM on note update"
},
"payload": { /* the event-specific payload — full structured object */ }
}
This is the recommended shape for most integrations: your receiver gets the raw payload as a nested JSON object with no templating on your part. See the System Events Reference for the payload shape of each event type.
Custom body via body_template
If you need a specific shape (a third-party webhook schema, a flattened structure, etc.), set body_template to a JSON string with interpolation markers. The action interpolates payload values, then sends the result as the request body.
Templating
Two interpolation markers are supported. Choosing the right one is important:
-
{{field}}— Raw substitution. The payload value is pasted in via.to_s. Use for pre-escaped strings, numbers in query strings, or anything already safe for its context. -
{{json:field}}— JSON literal. The payload value is serialized viaJSON.generate, producing a valid JSON literal: strings come back quoted and escaped, numbers/booleans/nullcome back bare, and arrays/objects come back fully serialized. Missing keys emitnull.
Use {{json:field}} whenever the marker sits where a JSON value goes in a JSON body. This is the only way to safely template values that might contain embedded quotes, newlines, backslashes, or unicode.
Important: do not wrap {{json:field}} in quotes
The marker emits its own quoting for strings, so wrapping it produces invalid JSON with doubled quotes:
// ✗ WRONG — produces "subject": ""Hello""
{ "subject": "{{json:subject}}" }
// ✓ RIGHT — produces "subject": "Hello"
{ "subject": {{json:subject}} }
// ✗ WRONG — produces "tags": "[]" (string, not array)
{ "tags": "{{json:tags}}" }
// ✓ RIGHT — produces "tags": []
{ "tags": {{json:tags}} }
A robust example
Template:
{
"event": "note.updated",
"note": {
"subject": {{json:subject}},
"body": {{json:body}},
"tags": {{json:tags}}
}
}
Payload:
{
"subject": "Hi — \"welcome\"",
"body": "Line 1\nLine 2",
"tags": ["a", "b"]
}
Produces (valid JSON even with embedded quotes and newlines):
{
"event": "note.updated",
"note": {
"subject": "Hi — \"welcome\"",
"body": "Line 1\nLine 2",
"tags": ["a", "b"]
}
}
If your template interpolates to a string that isn’t valid JSON, the action falls back to { "message": "<raw interpolated string>" } and logs a warn.
Authentication
API key header
When webhook_auth_key is set, Oppy adds a header whose name is webhook_auth_header (default X-API-Key):
X-API-Key: <your key>
Compare against your stored secret with a constant-time function. Keep the key in environment variables or a secret store on your side — never log it.
HMAC signature
When webhook_secret is set, Oppy adds:
X-Webhook-Signature: sha256=<hex>
Where hex is computed as:
hex = HMAC-SHA256(webhook_secret, raw_request_body).hex
The signature is computed over the exact bytes of the HTTP body as sent — not a canonicalized form, not the parsed JSON, not a nested sub-key. Your verifier must read the raw request body before any framework-level JSON parsing, because re-serialization will change the bytes and break verification.
-
In Rack / Rails:
request.body.read -
In Express: use
express.raw({ type: 'application/json' })and readreq.bodyas a Buffer -
In FastAPI:
await request.body()on theRequestobject -
In Go’s
net/http: read fromr.Bodyonce into a buffer, then re-parse
Verifying a request — code snippets
Python
import hmac, hashlib
def verify(raw_body: bytes, received_sig: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(received_sig, expected)
Node.js
const crypto = require("crypto");
function verify(rawBody, receivedSig, secret) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(receivedSig),
Buffer.from(expected),
);
}
Ruby
require "openssl"
require "active_support/security_utils"
def verify(raw_body, received_sig, secret)
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
ActiveSupport::SecurityUtils.secure_compare(expected, received_sig)
end
Always compare with a constant-time function (hmac.compare_digest, crypto.timingSafeEqual, ActiveSupport::SecurityUtils.secure_compare) to avoid timing oracles.
Testing locally
localhost and private IPs are blocked by the action for security reasons, so local testing needs a public tunnel. With ngrok the flow is:
# Terminal 1 — your local receiver listens on 8765
./your-receiver --port 8765
# Terminal 2 — expose it
ngrok http 8765
Use the https://<subdomain>.ngrok.app URL as the rule’s webhook_url, then trigger the event (or fire the action manually in a Rails console) and watch your receiver log the request.
Rotation and removal
-
Rotate: PATCH the rule with a new
webhook_secretand/orwebhook_auth_key. Values are encrypted at rest; API responses never return the values — onlyhas_webhook_secret/has_webhook_auth_keybooleans so the UI can show whether a value is configured. -
Remove: PATCH the rule with
""ornullfor the field. The corresponding header will stop being emitted.
Error handling
-
Blocked URL, invalid URL, missing
webhook_url→ no request is issued; a warn is logged. -
HTTP failure (non-2xx response, connection error, timeout after 15 s) → the failure is logged; no retry is performed by the action. If you need guaranteed delivery, design your receiver to be highly available and implement idempotency keys in the payload.
Recommendations
-
Always configure at least one of
webhook_secretorwebhook_auth_key. Fully-unauthenticated webhooks mean anyone who learns your URL can spoof events. -
Prefer HMAC signing over API key alone if your receiver’s infrastructure can support reading the raw body — HMAC also protects against payload tampering, not just sender identity.
-
Use the default envelope unless you have a specific reason. It’s the lowest-effort path for the receiver and avoids body-template escaping pitfalls entirely.
-
Return 2xx quickly. The action times out at 15 seconds. If your processing is slow, 200-acknowledge and enqueue the work.
-
Version your receiver path. Something like
/webhooks/oppy/v1lets you roll out schema changes on your side without Oppy needing to change the rule’s URL.
Related
-
Automation Rules — Customer Guide — when and why to use automation rules
-
Oppy System Events Reference — catalog of event types and payload shapes