# Guide to Outreach Webhooks: Features and Best Practices

Outreach webhooks tell your systems what is happening in your sales engagement data: a prospect is created, a prospect is added to a sequence or finishes one, an email bounces or gets a reply, a call is logged, or a task is completed. If you build on Outreach, webhooks are how those changes reach your CRM sync, analytics or internal tools without polling the Outreach API.

This guide covers how Outreach webhooks are created and signed, the resources and actions you can subscribe to and the two payload versions, Outreach's policy of never retrying an HTTP error, and the best practices for running an Outreach receiver in production.

## What are Outreach webhooks?

In Outreach, a webhook is an API resource like any other. You create one with `POST /api/v2/webhooks`, giving it an HTTPS URL and, optionally, the resource and action it should respond to. Both filters default to `*`, which means every applicable resource and action is delivered.

When a matching change happens, Outreach sends a JSON:API-styled `POST` to the URL. The body has a `data` object describing the resource and a `meta` object with `deliveredAt` and `eventName`. The event name has the form `<resource>.<action>`, for example `task.completed`.

Signing is opt-in. If you set a `secret` attribute on the webhook, every delivery carries an `Outreach-Webhook-Signature` header: the hex HMAC-SHA256 of the raw body, keyed with that secret. If you don't, there is no signature at all.

## Outreach webhook features

| Feature | Details |
| --- | --- |
| Configuration | The REST API: `POST https://api.outreach.io/api/v2/webhooks`, listed with `GET /api/v2/webhooks` |
| Filters | `resource` and `action`, each defaulting to `*` |
| Event name | `meta.eventName`, as `<resource>.<action>` |
| Payload format | JSON:API-styled `data` and `meta` |
| Payload versions | `payloadVersion` 1 (default) or 2, which adds `beforeUpdate` |
| Signature header | `Outreach-Webhook-Signature`, only when the webhook has a `secret` |
| Scheme | HMAC-SHA256, bare lowercase hex |
| Signed content | The raw request body, nothing else |
| Replay window | None. No timestamp is sent or signed |
| Cleanup header | `outreach-webhook-cleanup-token`, a token that can only delete this webhook |
| Response window | 5 seconds |
| Retries | Network failures only: 4 attempts, one second apart |
| HTTP errors | Any status, including `500` and `429`, counts as an acknowledgement and is not retried |
| Redirects | Not followed |

The URL must be HTTPS. Outreach states that it does not provide a fixed list of IP addresses for webhooks, so the signature is the only way to authenticate a delivery.

## Common events

Outreach's docs list the resources and actions you can filter on. Each combination is delivered as `<resource>.<action>`.

| Resource | Actions |
| --- | --- |
| `account` | `created`, `updated`, `destroyed` |
| `call` | `created`, `updated`, `destroyed` |
| `emailAddress` | `created`, `updated`, `destroyed` |
| `import` | `created`, `finished` |
| `kaiaRecording` | `created` |
| `mailing` | `created`, `updated`, `destroyed`, `bounced`, `delivered`, `opened`, `replied` |
| `opportunity` | `created`, `updated`, `destroyed` |
| `opportunityProspectRole` | `created`, `updated`, `destroyed` |
| `prospect` | `created`, `updated`, `destroyed` |
| `sequence` | `created`, `updated`, `destroyed` |
| `sequenceState` | `created`, `updated`, `destroyed`, `advanced`, `finished` |
| `task` | `created`, `updated`, `destroyed`, `completed` |
| `user` | `created`, `updated` |

The only event name Outreach shows in a payload is `task.completed`. The rest follow from the grid, so log the `eventName` values you actually receive before you rely on an exact spelling.

Create and update events carry only the attributes that were created or changed, not the full resource. Destroyed events carry the last known attributes and relationships. Here is Outreach's `task.completed` example in the default version 1 format, written as JSON:

```json
{
  "data": {
    "type": "task",
    "id": 1,
    "attributes": {
      "completed": true,
      "completedAt": "2017-01-01T00:00:00",
      "state": "completed"
    }
  },
  "meta": {
    "deliveredAt": "2017-01-01T00:00:01",
    "eventName": "task.completed"
  }
}

```

Version 2 adds a `beforeUpdate` object with the same `type` and `id` and the previous values of the changed attributes, for example `"state": "in progress"`. `payloadVersion` is a setting on the webhook and is not sent with the delivery, so a version 2 body is recognisable only by its `beforeUpdate` key.

`data.id` is a number in Outreach's examples, and the timestamps have no time zone suffix.

## Setting up Outreach webhooks

Outreach webhooks are created through the API with an access token for `https://api.outreach.io`. Always include a `secret`. Without one, Outreach sends no `Outreach-Webhook-Signature` header, and a verifier, including Hookdeck's Outreach source, has nothing to check, so every delivery fails verification.

```bash
curl -X POST "https://api.outreach.io/api/v2/webhooks" \
  -H "Authorization: Bearer $OUTREACH_ACCESS_TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "webhook",
      "attributes": {
        "url": "https://example.com/webhooks/outreach",
        "resource": "task",
        "action": "completed",
        "secret": "a-long-random-string"
      }
    }
  }'

```

1. Generate a long random secret and store it server-side, for example as `OUTREACH_WEBHOOK_SECRET`.
2. Create the webhook with your URL, the `resource` and `action` you want (or leave them out for everything), and the `secret`.
3. Add `"payloadVersion": 2` if you want the previous values in `beforeUpdate`.
4. Keep the `cleanupToken` from the response if you are building a partner integration.

If you use Hookdeck, create an Outreach source, set the same secret on it, and use the Hookdeck source URL as the webhook's `url`. If you choose to leave a webhook unsigned, leave verification off on the source too.

Keep your own copy of the secret. Outreach redacts it when the webhook is listed by regular users or by applications other than the one that created it; only admin users and the creating application can see it.

## Securing Outreach webhooks

Compute an HMAC-SHA256 of the raw request body with your secret, encode it as hex, and compare it with `Outreach-Webhook-Signature` in constant time. The header is the bare digest with no prefix.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyOutreachSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/outreach', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.OUTREACH_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyOutreachSignature(req.body, req.get('outreach-webhook-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.meta.eventName. Persist the event, then process it asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_outreach_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_outreach_signature(raw_body, request.headers.get("outreach-webhook-signature"),
#                           os.environ["OUTREACH_WEBHOOK_SECRET"])

```

Outreach's own Ruby example compares with `==`, which is not constant time. Use `crypto.timingSafeEqual` or `hmac.compare_digest` instead. Outreach doesn't document the delivery's `Content-Type`, and a JSON:API body may not be labelled plain `application/json`, so `express.raw({ type: '*/*' })` keeps the raw bytes whatever it says.

> **Make Outreach webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Outreach-Webhook-Signature` header, accepts every prospect, sequence and task event as soon as it arrives, and durably queues it, so an error on your side doesn't lose an event Outreach will never resend.

## Outreach webhook limitations and pain points

### Errors are never retried

**The Problem:** An event that hits your endpoint during an outage or a bug is gone for good.

**Why It Happens:** Outreach treats any HTTP status as an acknowledgement. A `500` or a `429` is not retried, and neither is a redirect. Only network failures, such as socket errors and connection resets, are retried, with 4 attempts one second apart. Outreach warns that proxies which answer `429` when a capacity limit is hit cause the same silent loss.

**Workarounds:**

* Keep the receiver small and highly available: verify, store, return `200`.
* Don't put a rate-limiting proxy in front of the endpoint, or monitor it for `429`s.
* Reconcile important resources through the Outreach API after any incident.

**How Hookdeck Can Help:** Hookdeck acknowledges Outreach as soon as the request is stored, then retries delivery to your service on a schedule you control and lets you replay any event, so an error in your service no longer drops the event.

### A 5 second timeout

**The Problem:** Slow handlers miss the response window, and Outreach does not document whether a timed-out delivery is retried.

**Why It Happens:** Outreach waits 5 seconds for a response, and recommends finishing processing within that time or handing the work to a message bus. Even if a timeout counts as a network failure, the 4 attempts are only one second apart.

**Workarounds:**

* Return `200` straight after verification and storage, and process asynchronously.
* Keep database writes on the request path to a single insert.

**How Hookdeck Can Help:** Hookdeck answers Outreach as soon as the request is stored, without waiting for your service, and your service can take as long as it needs to process each event.

### No secret, no signature

**The Problem:** Deliveries arrive with no `Outreach-Webhook-Signature` header, and every one fails verification.

**Why It Happens:** `secret` is optional on the webhook, and Outreach signs only when it is present. A webhook created without one sends unsigned requests.

**Workarounds:**

* Always include `secret` when you create a webhook.
* Audit existing webhooks with `GET /api/v2/webhooks` and replace any that were created without a secret.

**How Hookdeck Can Help:** Hookdeck's Outreach source verifies the header once the secret is set on both sides, and records unsigned or mis-signed requests as failed verification instead of delivering them.

### Webhooks are disabled when the hostname doesn't resolve

**The Problem:** Events stop arriving after a DNS change.

**Why It Happens:** If the hostname resolves to a private network address, Outreach disables the webhook for an hour. If it doesn't resolve after 3 DNS attempts, the webhook is disabled for a minute. Webhooks that can't recover for a week or more are disabled permanently.

**Workarounds:**

* Use a public hostname with stable DNS.
* Check the `active`, `disabledReason`, `disabledSince` and `disabledUntil` attributes returned by `GET /api/v2/webhooks`, and alert when a webhook is disabled.

**How Hookdeck Can Help:** A Hookdeck source URL is a stable public hostname, so DNS changes on your side don't affect Outreach's webhooks.

### No event id

**The Problem:** You can't reliably tell a network retry from a new event.

**Why It Happens:** `meta` carries only `deliveredAt` and `eventName`, and nothing in the payload identifies the delivery.

**Workarounds:**

* Build a key from `eventName`, `data.type`, `data.id` and the changed attributes, such as `completedAt`.
* Make handlers idempotent, so applying the same change twice does no harm.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop identical repeated deliveries before they reach your service, based on the fields you choose.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Outreach sent. Mount `express.raw()` on the webhook route and parse only after verification, or read `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI.

### Filter at the source

Leaving `resource` and `action` at `*` sends every change in the account to your endpoint. Create one webhook per resource and action you need, so less traffic arrives and each handler stays simple.

### Expect partial resources

Update events contain only the changed attributes. Don't overwrite a full record with the `attributes` object. Merge the fields that are present, or fetch the full resource from the API when you need it. Use `payloadVersion` 2 when you need to know what a value changed from.

### Route on `meta.eventName`

`data.type` names the resource but not the action, so the same `task` type arrives for created, updated, destroyed and completed events. Switch on `meta.eventName`, and log and acknowledge anything you don't recognise.

### Keep the cleanup token if you're an integrator

Every delivery carries `outreach-webhook-cleanup-token`. If a customer revokes your access, `POST https://api.outreach.io/api/v2/webhooks/cleanup` with that token as a bearer token removes the webhook (`204`), or tells you it's already gone (`404`) or that the token is invalid (`401`). The token can do nothing else.

### Acknowledge fast and process asynchronously

See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Outreach signs a webhook delivery only when the webhook has a `secret`, as a bare hex HMAC-SHA256 of the raw body in `Outreach-Webhook-Signature`. Always set the secret through the API, verify against the raw bytes, route on `meta.eventName`, merge partial attributes rather than replacing records, and answer within 5 seconds, because Outreach never retries an HTTP error.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Outreach signature, and holds each event until your service has processed it, so an error on your side costs latency instead of data.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Outreach webhooks reliably in minutes.