Send Webhook Action — Developer Integration Guide

· · 7 min read
View as Markdown Open in ChatGPT Open in Claude

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 via JSON.generate, producing a valid JSON literal: strings come back quoted and escaped, numbers/booleans/null come back bare, and arrays/objects come back fully serialized. Missing keys emit null.

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 read req.body as a Buffer

  • In FastAPI: await request.body() on the Request object

  • In Go’s net/http: read from r.Body once 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_secret and/or webhook_auth_key. Values are encrypted at rest; API responses never return the values — only has_webhook_secret / has_webhook_auth_key booleans so the UI can show whether a value is configured.

  • Remove: PATCH the rule with "" or null for 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_secret or webhook_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/v1 lets you roll out schema changes on your side without Oppy needing to change the rule’s URL.