oppy
Developer guide · Outbound webhooks

Send Oppy events to your own systems.

How automation rules call your URL, what counts as a failure, when Oppy retries, and when it turns a rule off.

An automation rule says "when this happens, do that." 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.

ActionWhat it sendsAuth and signingFailures
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 {"text", "blocks"} to a Slack incoming-webhook URL. 10 second timeout.None. The Slack URL is the secret.Not checked. See Ping 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:

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

payload.event_id names the Oppy event. Use it to drop duplicates. A retried delivery adds "automation_retry": true to payload, and a manual replay adds "automation_replay": 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.
{
  "type":    "call.summarized",
  "contact": {{json:contact_name}},
  "summary": {{json:summary}},
  "seconds": {{json:duration_seconds}}
}

If the filled-in template is not valid JSON, Oppy sends {"message": "<the text>"} instead.

Verify it came from Oppy

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

OptionHeader Oppy sendsHow to check it
API keyX-API-Key: <key>, or a header name you chooseConstant-time compare with your stored key.
Signing secretX-Webhook-Signature: sha256=<hex>HMAC-SHA256 of the raw request body with the secret, then a constant-time compare.
Custom headersAny 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) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(header, expected)

// Node
const crypto = require("crypto");
function verify(rawBody, header, secret) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(header), b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Retries and failure rules

Every delivery ends in one of three results. You can see each one in the rule's run history.

ResultWhenRetryCounts toward pause
successAny 2xx reply.NoNo. Resets the streak.
temporary failureTimeout after 15 s, connection refused or reset, host unreachable, or a 429 or 5xx reply. The error starts with Temporary:.Once, 5 minutes laterNo
failureAny other non-2xx reply, such as 400, 401, 403, 404 or 422. Also TLS certificate errors.NoYes
skippedConditions did not match, or the URL is missing, blocked, or its hostname does not resolve.NoNo. Does not break a streak either.

How a temporary failure plays out

  1. 0 minFirst attempt times out or gets a 503.
  2. +5 minOppy sends the same event to that rule again, with automation_retry: true.
  3. SuccessRecorded as success. Nothing else happens.
  4. Fails againRecorded 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'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.

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's message to a Slack incoming webhook. It does not check Slack'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].

FieldWhat it holds
statussuccess, failure or skipped
error_messageReason for a failure or skip. Temporary failures start with Temporary:.
webhook_details.requestMethod, URL with secrets removed, header names only, body size in bytes.
webhook_details.responseStatus code, content type, and the first 2 KB of your reply.

API

Send your Oppy API key as Authorization: Bearer <key> 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              # {"enabled": true} turns a paused rule back on
POST /api/automation_rules/replay            # {"event_id": "…", "rule_id": "…"} 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'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.