# Guide to Paragon Webhooks: Features and Best Practices

Paragon webhooks let your own systems hear when the integrations you've embedded with Paragon go wrong. When a workflow fails for one of your customers, or a customer's OAuth credential expires and can't be refreshed, a Webhook Destination can tell your alerting, logging or support tooling straight away, so you can fix the workflow or ask the customer to reconnect.

This guide covers Paragon's monitoring Webhook Destinations: how to set one up, the two events they send and what the payloads look like, how to authenticate requests that Paragon doesn't sign, and the best practices for production. It also explains how they differ from Managed Sync webhooks, which are a separate feature.

## What are Paragon webhooks?

Paragon, the embedded integration platform, has two features that send webhooks:

* **Webhook Destinations** are monitoring notifications for your Paragon project. They're a kind of Event Destination, configured under **Settings > Monitoring**, and they send a `POST` when a workflow fails or a connected user's credential can't be refreshed. They're available on paid plans and only Admin users can manage them.
* **Managed Sync webhooks** report records created, updated or deleted in a Sync. They're configured on the **Syncs** page, send a `webhook_verification` request when you save the URL, and have their own event types.

This guide is about Webhook Destinations, which is what Hookdeck's Paragon source covers. Paragon documents no authentication for Managed Sync webhooks, so there's nothing for a source to check there.

Webhook Destinations aren't signed. Their documented options are a URL, a request payload template and additional request headers. Add a secret header and compare it in your receiver.

## Paragon webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Settings > Monitoring > Add Destination**, type **Webhook (HTTP Request)** |
| Availability | Paid plans, Admin users only |
| Events | `workflow_failure` and `credentials_failure` |
| Method | `POST` |
| Payload | The full event object (`$.event`) by default, or a template you write |
| Templates | Ready-made payloads for Slack, Datadog, New Relic and Sentry |
| Headers | Optional, under **Show more request options** |
| Authentication | A secret request header you add |
| Signature | None |
| Testing | **Test Webhook** button |
| Auto-disable | After too many non-2xx responses for consecutive events |
| Retries and timeout | Not documented |

## Common events

Paragon supports two events, and says more are planned:

| `type` | Fires when |
| --- | --- |
| `workflow_failure` | An error occurs at any point in a workflow and the workflow reaches the Failed status |
| `credentials_failure` | An OAuth refresh fails with an error that can't be retried. The user's credential is then marked as invalid |

Here is Paragon's example of a workflow failure, trimmed, with its placeholders filled in:

```json
{
  "type": "workflow_failure",
  "timestamp": 1729629642607,
  "timestampISO": "2024-10-22T20:40:42.607Z",
  "message": "Workflow “Test Workflow Name“ failed for User user_123",
  "user": { "id": "user_123", "metadata": {} },
  "project": { "id": "613b038f-e9bf-49ec-bc58-ba8d620754c7", "name": "Test Project Name" },
  "integration": { "id": "57a96b31-c69e-421a-bb32-7a25f9c6a893", "type": "slack" },
  "workflow": { "id": "cae72d65-7fc6-4249-9047-48fc640bfd46", "name": "Test Workflow Name" },
  "data": {
    "type": "workflow_failure",
    "workflowExecution": { "id": "07911c9b-2006-4348-938b-b7ebac94dc1f" },
    "stepExecution": {
      "stepId": "3507a59f-d552-45a5-8bca-ed5315111ca0",
      "startTime": 1729629642606,
      "endTime": 1729629642606
    },
    "error": "Test error message",
    "executionUrl": "https://dashboard.useparagon.com/connect/projects/.../executions/07911c9b-2006-4348-938b-b7ebac94dc1f"
  }
}

```

A `credentials_failure` has the same `type`, `timestamp`, `timestampISO`, `message`, `user`, `project` and `integration` fields, no `workflow`, and a `data` object with `userId` and `error` (for example `invalid_grant`). `timestamp` is in epoch milliseconds. `user.id` is your connected user's ID, which is what you need to notify the right customer.

This shape is only guaranteed with the default payload. If you change **Request Payload** to a template, the body contains whatever the template selects, and `type` is only there if you include it.

> **Inspect Paragon webhooks as they arrive.** Point a Webhook Destination at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to inspect and replay real deliveries, with no account or setup required.

## Setting up Paragon webhooks

Generate a long random secret first (`openssl rand -hex 32` works) and store it in your app's environment.

1. In Paragon, open the global **Settings** tab and select **Monitoring** under your Project Settings.
2. Click **Add Destination**.
3. In **Type**, select **Webhook (HTTP Request)**.
4. In **URL**, enter your HTTPS endpoint.
5. Leave **Request Payload** at its default (`$.event`) unless you need a different shape.
6. Expand **Show more request options** and add your secret header, for example `X-Webhook-Secret` with your secret value.
7. Click **Test Webhook**, then **Save** to create and enable the destination.

From the Monitoring page you can later update the destination, disable it to pause notifications, or delete it.

For local development, use the [Hookdeck CLI](/docs/cli): `hookdeck listen 3000 paragon --path /webhooks/paragon` gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required.

**With Hookdeck.** Create a Paragon source in Hookdeck, enter the **API Key Header** and **API Key** exactly as you set them on the destination, including any `Bearer ` prefix, and use the source URL as the destination URL. The source is for monitoring Webhook Destinations only.

## Securing Paragon webhooks

Compare your secret header with the stored value. Look it up case-insensitively, compare the whole value in constant time, and check lengths first, because `crypto.timingSafeEqual` throws on buffers of different lengths.

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

const app = express();

function safeEqual(a, b) {
  const x = Buffer.from(String(a), 'utf8');
  const y = Buffer.from(String(b), 'utf8');
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyParagonRequest(headers) {
  const secret = process.env.PARAGON_WEBHOOK_SECRET;
  if (!secret) return false; // fail closed when unconfigured
  const name = (process.env.PARAGON_WEBHOOK_HEADER || 'X-Webhook-Secret').toLowerCase();
  const received = headers[name];
  return typeof received === 'string' && safeEqual(received, secret);
}

app.post('/webhooks/paragon', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyParagonRequest(req.headers)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  res.sendStatus(200);

  switch (event.type) {
    case 'workflow_failure':
      // alert the team, link to event.data.executionUrl
      break;
    case 'credentials_failure':
      // ask user event.user.id to reconnect event.integration.type
      break;
  }
});

```

The same check in Python:

```python
import hmac
import os

def verify_paragon_request(headers) -> bool:
    secret = os.environ.get("PARAGON_WEBHOOK_SECRET")
    if not secret:
        return False
    name = os.environ.get("PARAGON_WEBHOOK_HEADER", "X-Webhook-Secret")
    received = headers.get(name)  # case-insensitive in Starlette and Flask
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))

```

A static header authenticates the sender but not the body, and gives no replay protection: nothing is signed and there's no timestamp check. Use HTTPS, keep the secret out of logs, and rotate it by updating the destination if it leaks. Be careful before acting automatically on a `credentials_failure`, for example emailing a customer, since a forged request could trigger it. Check the user's credential status through Paragon's API first.

> **Make Paragon webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your secret header on every request, stores each failure notification durably, and keeps your destination enabled by answering Paragon even when your own service is failing.

## Paragon webhook limitations and pain points

### Failing receivers get disabled

**The Problem:** If your endpoint keeps returning errors, Paragon may switch the destination off, and you stop hearing about failures at exactly the moment you need to.

**Why It Happens:** Paragon says a destination "may be automatically disabled" after too many non-2xx responses for consecutive events. It doesn't publish the threshold.

**Workarounds:**

* Return `200` quickly once the header checks out, and process the event afterwards.
* Check the Monitoring page after any outage and re-enable the destination if needed.

**How Hookdeck Can Help:** Hookdeck answers Paragon as soon as the request is stored, so errors in your own service never count against the destination. It then delivers to your service with [automatic retries](/docs/retries), and [Issues](/docs/issues) alert you when deliveries fail.

### No signature

**The Problem:** A request with the right header value is indistinguishable from a real failure notification.

**Why It Happens:** Webhook Destinations offer request headers and nothing else for authentication.

**Workarounds:**

* Use a long random header value and compare it in constant time.
* Confirm a credential failure through the API before contacting the customer.

**How Hookdeck Can Help:** Hookdeck's Paragon source compares the header you name against the exact value you enter. A request without a matching header still gets a 200 response, but Hookdeck marks it as failed verification and creates no event, so it never reaches your destination. Every request is stored with its headers and body.

### Custom payloads change the contract

**The Problem:** A handler written for the default event object breaks if someone changes the Request Payload template, and `type` can disappear.

**Why It Happens:** The template replaces the body with whatever it selects from the event.

**Workarounds:**

* Keep the default payload for destinations that feed your own code, and use templates only for third-party tools like Slack.
* If you do template the body, always include `type`, `user.id` and `integration`.

**How Hookdeck Can Help:** Keep the default payload going to Hookdeck and use [transformations](/docs/transformations) to reshape it for each destination, so one Paragon destination can feed several tools.

### Two different webhook products

**The Problem:** It's easy to confuse monitoring Webhook Destinations with Managed Sync webhooks, which use a different payload, a verification request and a different configuration page.

**Why It Happens:** Both are called webhooks in Paragon's docs, but they belong to different features.

**Workarounds:**

* Use separate endpoints for monitoring and Managed Sync traffic.
* Route on `type` for monitoring events and `event` for Managed Sync events.

**How Hookdeck Can Help:** Create separate Hookdeck sources for each feature, so each one has its own URL, its own verification settings and its own delivery history.

## Best practices

### Route on `type` and act on `user.id`

`workflow_failure` usually means something to fix on your side, and `data.executionUrl` links to the failed run. `credentials_failure` usually means the customer must reconnect: use `user.id` and `integration.type` to prompt them through the Connect Portal.

### Acknowledge fast and process asynchronously

Return `200` as soon as the header checks out, then send alerts or emails from a background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Deduplicate notifications

Paragon doesn't document an event ID for monitoring events. Use `data.workflowExecution.id` for workflow failures, and `user.id`, `integration.id` and `timestamp` for credential failures, so a repeated request doesn't send a customer two emails. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Paragon's monitoring Webhook Destinations send an unsigned `POST` for workflow failures and unrecoverable credential failures, with `type`, `user`, `integration` and an event-specific `data` object in the default payload. Authenticate them with a secret request header, keep the default payload for code you own, and keep your receiver healthy, since repeated errors can disable the destination.

[Hookdeck Event Gateway](/event-gateway) checks the header, stores every notification and retries delivery to your service, so failure alerts keep flowing even when the service receiving them is having a bad day.

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