# Send Webhook Action — Developer Integration Guide

Published: 2026-04-24
Updated: 2026-09-10
Source: https://docs.oppy.pro/n/automation-rules-send-webhook-developer-guide
Account: Oppy Inc

---

# Send Webhook Action — Developer Integration Guide

A technical reference for developers building a service that receives webhooks from Oppy&#39;s **Send Webhook** automation rule action. If you&#39;re a business user asking *when and why* to use automation rules, start with the [Automation Rules — Customer Guide](https://app.oppy.pro/n/public-events-automation-rules-customer-guide). For the catalog of event types Oppy emits, see the [Oppy System Events Reference](https://app.oppy.pro/n/oppy-system-events-reference).

## What this action does

When an automation rule&#39;s trigger fires, the `send_webhook` action makes a single outbound HTTP request to a URL you configure. The request carries the triggering event&#39;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: &lt;your webhook_auth_key&gt;            # only if configured
X-Webhook-Signature: sha256=&lt;hex&gt;             # only if webhook_secret is configured
```

### Default body (no `body_template` set)

```json
{
  &quot;event&quot;: {
    &quot;type&quot;: &quot;note.updated&quot;,
    &quot;fired_at&quot;: &quot;2026-04-24T21:16:04Z&quot;
  },
  &quot;rule&quot;: {
    &quot;id&quot;: &quot;c1a5…&quot;,
    &quot;name&quot;: &quot;Notify CRM on note update&quot;
  },
  &quot;payload&quot;: { /* 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](https://app.oppy.pro/n/oppy-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:

```json
// ✗ WRONG — produces   &quot;subject&quot;: &quot;&quot;Hello&quot;&quot;
{ &quot;subject&quot;: &quot;{{json:subject}}&quot; }

// ✓ RIGHT — produces   &quot;subject&quot;: &quot;Hello&quot;
{ &quot;subject&quot;: {{json:subject}} }

// ✗ WRONG — produces   &quot;tags&quot;: &quot;[]&quot;   (string, not array)
{ &quot;tags&quot;: &quot;{{json:tags}}&quot; }

// ✓ RIGHT — produces   &quot;tags&quot;: []
{ &quot;tags&quot;: {{json:tags}} }
```

### A robust example

Template:

```json
{
  &quot;event&quot;: &quot;note.updated&quot;,
  &quot;note&quot;: {
    &quot;subject&quot;: {{json:subject}},
    &quot;body&quot;:    {{json:body}},
    &quot;tags&quot;:    {{json:tags}}
  }
}
```

Payload:

```json
{
  &quot;subject&quot;: &quot;Hi — \&quot;welcome\&quot;&quot;,
  &quot;body&quot;: &quot;Line 1\nLine 2&quot;,
  &quot;tags&quot;: [&quot;a&quot;, &quot;b&quot;]
}
```

Produces (valid JSON even with embedded quotes and newlines):

```json
{
  &quot;event&quot;: &quot;note.updated&quot;,
  &quot;note&quot;: {
    &quot;subject&quot;: &quot;Hi — \&quot;welcome\&quot;&quot;,
    &quot;body&quot;: &quot;Line 1\nLine 2&quot;,
    &quot;tags&quot;: [&quot;a&quot;, &quot;b&quot;]
  }
}
```

If your template interpolates to a string that isn&#39;t valid JSON, the action falls back to `{ &quot;message&quot;: &quot;&lt;raw interpolated string&gt;&quot; }` 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: &lt;your key&gt;
```

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=&lt;hex&gt;
```

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: &#39;application/json&#39; })` and read `req.body` as a Buffer
    
-   In FastAPI: `await request.body()` on the `Request` object
    
-   In Go&#39;s `net/http`: read from `r.Body` once into a buffer, then re-parse
    

## Verifying a request — code snippets

### Python

```python
import hmac, hashlib

def verify(raw_body: bytes, received_sig: str, secret: str) -&gt; bool:
    expected = &quot;sha256=&quot; + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(received_sig, expected)
```

### Node.js

```javascript
const crypto = require(&quot;crypto&quot;);

function verify(rawBody, receivedSig, secret) {
  const expected = &quot;sha256=&quot; + crypto
    .createHmac(&quot;sha256&quot;, secret)
    .update(rawBody)
    .digest(&quot;hex&quot;);
  return crypto.timingSafeEqual(
    Buffer.from(receivedSig),
    Buffer.from(expected),
  );
}
```

### Ruby

```ruby
require &quot;openssl&quot;
require &quot;active_support/security_utils&quot;

def verify(raw_body, received_sig, secret)
  expected = &quot;sha256=&quot; + OpenSSL::HMAC.hexdigest(&quot;SHA256&quot;, 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](https://ngrok.com) 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://&lt;subdomain&gt;.ngrok.app` URL as the rule&#39;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 `&quot;&quot;` 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&#39;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&#39;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&#39;s URL.
    

## Related

-   [Automation Rules — Customer Guide](https://app.oppy.pro/n/public-events-automation-rules-customer-guide) — when and why to use automation rules
    
-   [Oppy System Events Reference](https://app.oppy.pro/n/oppy-system-events-reference) — catalog of event types and payload shapes
