# Oppy Outbound Webhooks: Developer Guide

Published: 2026-10-28
Updated: 2026-10-28
Source: https://docs.oppy.pro/n/oppy-outbound-webhooks-developer-guide
Account: Oppy Inc

---

An automation rule says &quot;when this happens, do that.&quot; Two of its actions call a web address you choose: **Send to another app** (a JSON webhook) and **Ping Slack** (a Slack incoming webhook).

Delivery

### One request per event

Each rule that matches an event sends one request. A 2xx reply is success. Timeout is 15 seconds.

Retry

### One retry after 5 minutes

Only for temporary errors: timeouts, dropped connections, 429 and 5xx replies.

Auto-pause

### Off after 10 straight failures

Permanent errors in a row, spread over at least 6 hours, turn the rule off until you turn it back on.

## Webhook types

Every outbound call to an address you set comes from an automation rule. These are all of them.

| Action | What it sends | Auth and signing | Failures |
| --- | --- | --- | --- |
| **Send to another app**  
`send_webhook` | JSON to any public URL. `POST` by default, or `PUT` / `PATCH`. Default envelope or your own template. | API key header, HMAC-SHA256 signature, custom headers. | Recorded, retried and paused as described below. |
| **Ping Slack**  
`send_notification` | `POST` of `{&quot;text&quot;, &quot;blocks&quot;}` to a Slack incoming-webhook URL. 10 second timeout. | None. The Slack URL is the secret. | Not checked. See [Ping Slack](#slack). |

Built-in integration syncs, such as CRM updates, also call outside services. Oppy manages those addresses and credentials, so they are not covered here.

## The request

### Address rules

- The URL must be `http://` or `https://` and reach a public address. Private, loopback, link-local and cloud metadata addresses are blocked, including hostnames that resolve to them.
- To test against a local server, expose it through a tunnel such as ngrok.
- Use the final URL. Oppy follows up to 5 redirects. A `301`, `302` or `303` turns the request into a `GET` with no body. A redirect to another host drops your auth headers, and a `307` / `308` to another host is refused.
- A blocked or missing URL is recorded as **skipped** , not failed. So is a hostname that does not resolve in DNS. Skips never retry and never pause the rule, so check run history after you change the URL.

### Default body

With no template, Oppy sends this envelope with `Content-Type: application/json`:

    {
      &quot;event&quot;: { &quot;type&quot;: &quot;contact.created&quot;, &quot;fired_at&quot;: &quot;2026-10-27T15:04:05Z&quot; },
      &quot;rule&quot;: { &quot;id&quot;: &quot;8b0c…&quot;, &quot;name&quot;: &quot;New contacts to CRM&quot; },
      &quot;payload&quot;: { &quot;event_id&quot;: &quot;3f2a…&quot;, &quot;event_type&quot;: &quot;contact.created&quot;, … }
    }

`payload.event_id` names the Oppy event. Use it to drop duplicates. A retried delivery adds `&quot;automation_retry&quot;: true` to `payload`, and a manual replay adds `&quot;automation_replay&quot;: true`.

### Custom body

Set a body template to send your own JSON shape. Two markers fill in event fields:

- `{{name}}` pastes the value as plain text.
- `{{json:name}}` writes the value as a JSON literal: strings quoted and escaped, numbers and booleans bare, missing fields as `null`. Use this form for every value inside a JSON body, and do not wrap it in quotes.

    {
      &quot;type&quot;: &quot;call.summarized&quot;,
      &quot;contact&quot;: {{json:contact_name}},
      &quot;summary&quot;: {{json:summary}},
      &quot;seconds&quot;: {{json:duration_seconds}}
    }

If the filled-in template is not valid JSON, Oppy sends `{&quot;message&quot;: &quot;&lt;the text&gt;&quot;}` instead.

## Verify it came from Oppy

Set one or both on the rule. Both secrets are encrypted at rest.

| Option | Header Oppy sends | How to check it |
| --- | --- | --- |
| API key | `X-API-Key: &lt;key&gt;`, or a header name you choose | Constant-time compare with your stored key. |
| Signing secret | `X-Webhook-Signature: sha256=&lt;hex&gt;` | HMAC-SHA256 of the raw request body with the secret, then a constant-time compare. |
| Custom headers | Any name and value. Values accept `{{name}}` markers. | Your choice. `Host`, `Forwarded`, `X-Real-IP`, `Metadata-Flavor`, `X-Forwarded-*` and `Proxy-*` are removed. |

The signature covers the exact bytes on the wire. Read the raw body before any JSON parsing.

    # Python
    import hmac, hashlib
    def verify(raw_body: bytes, header: str, secret: str) -&gt; bool:
        expected = &quot;sha256=&quot; + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
        return hmac.compare_digest(header, expected)
    
    // Node
    const crypto = require(&quot;crypto&quot;);
    function verify(rawBody, header, secret) {
      const expected = &quot;sha256=&quot; + crypto.createHmac(&quot;sha256&quot;, secret).update(rawBody).digest(&quot;hex&quot;);
      const a = Buffer.from(header), b = Buffer.from(expected);
      return a.length === b.length &amp;&amp; crypto.timingSafeEqual(a, b);
    }

## Retries and failure rules

Every delivery ends in one of three results. You can see each one in the rule&#39;s run history.

| Result | When | Retry | Counts toward pause |
| --- | --- | --- | --- |
| success | Any `2xx` reply. | No | No. Resets the streak. |
| temporary failure | Timeout after 15 s, connection refused or reset, host unreachable, or a `429` or `5xx` reply. The error starts with `Temporary:`. | Once, 5 minutes later | No |
| failure | Any other non-2xx reply, such as `400`, `401`, `403`, `404` or `422`. Also TLS certificate errors. | No | Yes |
| skipped | Conditions did not match, or the URL is missing, blocked, or its hostname does not resolve. | No | No. Does not break a streak either. |

### How a temporary failure plays out

1. **0 min** First attempt times out or gets a 503.
2. **+5 min** Oppy sends the same event to that rule again, with `automation_retry: true`.
3. **Success** Recorded as success. Nothing else happens.
4. **Fails again** Recorded as failed. No further retry. The event is not resent unless you replay it.

Oppy does not queue events while your endpoint is down. After the one retry, a missed event stays missed until you replay it.

### When Oppy turns a rule off

- A rule pauses when it has **10 failures in a row** and the first of them was **at least 6 hours ago**. Both must be true, so a burst of errors in one afternoon does not pause a healthy rule.
- Only permanent failures count. Temporary failures and skips never pause a rule.
- The streak starts over after any success, or when you turn the rule back on.
- When it pauses, Oppy marks the rule as auto-paused with the reason, and posts a notice in your account&#39;s Oppy conversation, flagged as needing attention, with a button to turn the rule back on.
- A paused rule sends nothing. The events themselves are still stored, so you can replay them once the rule is back on.

**To recover:** fix the receiver, turn the rule back on, then replay the events you missed. See [Run history and replay](#history).

### What your endpoint should do

- Answer within a few seconds. Save the request, return `200` or `202`, and do slow work in the background. Oppy gives up after 15 seconds.
- Return `5xx` or `429` for problems that will pass, and `4xx` only for requests that will never work. A `4xx`, including `408`, is never retried and moves the rule toward pausing.
- Drop duplicates by `payload.event_id`. A retry or replay sends the same event again.
- Do not assume order. A retried event can arrive after newer events.

## Ping Slack

This action posts the rule&#39;s message to a Slack incoming webhook. It does not check Slack&#39;s reply.

- Every run is recorded as success, even when Slack rejects the post or the request times out.
- It is never retried and never pauses the rule.
- A blocked or missing URL is recorded as skipped.

If you need delivery tracking for Slack, use **Send to another app** pointed at the Slack webhook URL with a body template.

## Run history and replay

Every run is saved. For **Send to another app** , a run on your own rule also keeps a short record of the call. Credentials, header values and URL query values are replaced with `[redacted]`.

| Field | What it holds |
| --- | --- |
| `status` | `success`, `failure` or `skipped` |
| `error_message` | Reason for a failure or skip. Temporary failures start with `Temporary:`. |
| `webhook_details.request` | Method, URL with secrets removed, header names only, body size in bytes. |
| `webhook_details.response` | Status code, content type, and the first 2 KB of your reply. |

### API

Send your Oppy API key as `Authorization: Bearer &lt;key&gt;` or `X-API-Key`.

    GET /api/automation_rules/:id/executions # runs for one rule
    GET /api/automation_rules/executions/all # runs for the whole account
    PATCH /api/automation_rules/:id # {&quot;enabled&quot;: true} turns a paused rule back on
    POST /api/automation_rules/replay # {&quot;event_id&quot;: &quot;…&quot;, &quot;rule_id&quot;: &quot;…&quot;} sends a stored event again

Replay queues the event for the named rule, or for every enabled rule on that event type when you leave out `rule_id`. A paused rule cannot be replayed; turn it on first. Events created by automations cannot be replayed.

## Events a webhook can react to

Any of these can trigger a rule. Add conditions on the event&#39;s fields to narrow it down, for example `outcome` is `missed`.

- 
#### Contacts
- `contact.created`
- `contact.updated`
- `contact.identified`
- `contact.stage_changed`
- `contact.score_changed`
- `contact.dnc_added`
- 
#### Conversations and messages
- `conversation.created`
- `conversation.status_changed`
- `conversation.needs_attention_set`
- `conversation.needs_attention_cleared`
- `conversation.tag_added`
- `message.created`
- `message.delivered`
- 
#### Calls
- `call.ended`
- `call.summarized`
- 
#### Boards and cards
- `board.created`
- `board.updated`
- `list.created`
- `list.updated`
- `list_item.created`
- `list_item.updated`
- `list_item.moved`
- `list_item.deadline_changed`
- `list_item.deadline_approaching`
- `list_item.deadline_missed`
- 
#### Notes and Oppys
- `note.created`
- `note.updated`
- `channel.created`
- 
#### Website visitors
- `page.viewed`
- `page.navigated`
- `page.engagement`
- `visitor.session_start`
- `visitor.click`
- `visitor.link_click`
- `visitor.form_submit`
- `visitor.scroll_milestone`
- `visitor.idle`
- `visitor.rage_click`
- `website_chat.conversation_started`
- `website_chat.message_sent`
- 
#### Credits
- `wallet.balance_low_warning`
- `wallet.balance_low_urgent`
- `wallet.balance_critical`
- `wallet.auto_recharge_failed`
- 
#### Your own events
- Any event type you create through the API or MCP, such as `orders.shipped`.

Applies to the Oppy API. Behavior described is current as of October 2026.


