Webhooks let you receive SendOps [notifications](/notifications/notification-types) as HTTP POST requests to an endpoint you control. This is useful for feeding alerts into incident management tools, triggering automation workflows, or syncing notification data with internal systems.

## How webhooks work

When a notification is triggered and you have a webhook configured for that [notification type](/notifications/notification-types), SendOps sends an HTTP POST request to your configured endpoint with a JSON payload describing the event. Each request includes an HMAC-SHA256 signature header so your server can verify the payload came from SendOps.

## Creating a webhook endpoint


  <Step title="Navigate to Connections > Webhooks">
    In the SendOps dashboard, navigate to **Connections → Webhooks** in the sidebar and click **Add Endpoint**.

    
  </Step>

  <Step title="Enter your endpoint URL">
    Provide the HTTPS URL where SendOps should deliver notifications. The endpoint must be publicly accessible and respond to POST requests.

    
  </Step>

  <Step title="Select notification types">
    Choose which notification types should be sent to this endpoint. You can select individual types or entire categories. See [Notification Types](/notifications/notification-types) for the full list.

    You can create multiple webhook endpoints and route different notification types to each one.
  </Step>

  <Step title="Copy your signing secret">
    After creating the endpoint, SendOps generates a unique **signing secret** for it. Copy this secret and store it securely — you will need it to verify incoming webhook payloads.

    The secret is only shown once. If you lose it, you can rotate it from the endpoint settings. When you rotate, the previous secret keeps working for a 24-hour grace period so you have time to update your server without dropping deliveries (see [Managing endpoints](#managing-endpoints)).
  </Step>



  Treat the webhook signing secret like a password. Store it in environment variables or a secrets manager — never hard-code it in your application source or commit it to version control. Anyone with the secret can forge valid-looking webhook payloads.


## Payload structure

Every webhook delivery sends a JSON payload with the following structure:

```json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "notification_type": "deliverability.bounce_rate_threshold",
  "severity": "warning",
  "account_id": "x7y8z9a0-b1c2-3456-defg-hi7890123456",
  "timestamp": "2026-03-14T10:30:00Z",
  "title": "Bounce rate exceeded threshold",
  "body": "Bounce rate is 5.2%, exceeding your configured threshold of 5.0%",
  "payload": {
    "rate_pct": 5.2,
    "threshold_pct": 5.0,
    "window_minutes": 60,
    "count": 520,
    "total_sent": 10000
  }
}
```

| Field | Description |
|-------|-------------|
| `id` | A unique identifier (UUID) for this individual delivery. See the note below — it is **not** a stable event ID and is not suitable for deduplication. |
| `notification_type` | The notification type in `category.event_name` format. |
| `severity` | One of `info`, `warning`, or `critical`. |
| `account_id` | Your SendOps organization identifier (UUID). |
| `timestamp` | ISO 8601 / RFC 3339 timestamp of when the event occurred. |
| `title` | A human-readable title for the notification. |
| `body` | A human-readable description of the event. |
| `payload` | An optional object containing event-specific metadata. The shape varies by notification type — the example above shows the fields for `deliverability.bounce_rate_threshold`. |


  A fresh `id` is generated for every webhook request — including each retry of the same underlying event. Two deliveries of the same event (for example, an initial attempt and its retry) will carry **different** `id` values, so you cannot use `id` to recognise a retry or to deduplicate. If you need idempotency, deduplicate on a combination of fields that are stable across retries, such as `notification_type`, `account_id`, and `timestamp`.


## Verifying webhook signatures

Every webhook request includes two headers for signature verification:

| Header | Description |
|--------|-------------|
| `X-SendOps-Signature` | The HMAC-SHA256 signature, prefixed with `v1=`. |
| `X-SendOps-Timestamp` | The Unix timestamp (seconds since epoch) when the request was sent. |

The signature is computed over the timestamp and the raw request body joined by a `.` separator. This binds the timestamp to the payload, preventing replay attacks.

Always verify this signature before processing a webhook payload to ensure the request originated from SendOps and was not tampered with in transit.

### Verification steps

1. Extract the `X-SendOps-Timestamp` header value.
2. Extract the raw request body as a byte string. Do not parse or transform it before verification.
3. Concatenate the timestamp, a literal `.` character, and the raw body: `timestamp + "." + body`.
4. Compute an HMAC-SHA256 digest of this string using your signing secret as the key.
5. Prefix the hex-encoded digest with `v1=` and compare it to the `X-SendOps-Signature` header using a constant-time comparison function.
6. If the signatures match, the payload is authentic. If they do not match, reject the request with a `401` status code.

### Pseudocode example

```
secret = get_env("SENDOPS_WEBHOOK_SECRET")
raw_body = request.get_raw_body()
timestamp = request.headers["X-SendOps-Timestamp"]

signed_content = timestamp + "." + raw_body
expected_signature = "v1=" + hmac_sha256(key: secret, message: signed_content).to_hex()
received_signature = request.headers["X-SendOps-Signature"]

if not constant_time_equal(expected_signature, received_signature):
    respond(status: 401, body: "Invalid signature")
    return

event = parse_json(raw_body)
# Process the event...
respond(status: 200)
```


  Always use a constant-time string comparison function when checking signatures. A standard string equality check can leak information through timing differences, potentially allowing an attacker to forge valid signatures.


## Responding to webhooks

Your endpoint must return an HTTP `2xx` status code within **10 seconds** to acknowledge receipt. SendOps considers any non-`2xx` response or timeout as a delivery failure and will retry.

You do not need to process the event before responding. A recommended pattern is to store the raw payload in a queue and return `200` immediately, then process the event asynchronously.

## Retry policy

When a webhook delivery fails, SendOps retries up to 2 additional times:

| Attempt | Delay |
|---------|-------|
| Initial delivery | Immediate |
| 1st retry | 5 minutes |
| 2nd retry | 15 minutes |

After 3 total attempts, the delivery is marked as failed. You can manually resend failed deliveries from the delivery log, up to **30 days** after the original delivery — older deliveries can no longer be resent.

### Auto-disable on consecutive failures

If a webhook endpoint fails **10 consecutive deliveries** (across any notification types), SendOps automatically disables the endpoint to prevent further failed requests. The endpoint can be re-enabled from **Connections > Webhooks** after resolving the issue.

You can view delivery history and failure details for each endpoint in **Connections > Webhooks** by clicking on an endpoint.


  SendOps guarantees **at-least-once delivery** for webhooks. In rare cases (e.g., your server responds with a `200` but the response is lost in transit), the same event may be delivered more than once. Because every delivery carries a fresh `id` (see [Payload structure](#payload-structure)), don't rely on `id` to deduplicate — instead make your handler idempotent using stable fields like `notification_type`, `account_id`, and `timestamp`.


## Managing endpoints

From **Connections → Webhooks**, you can:

- **Enable / disable** — temporarily stop deliveries to an endpoint without deleting it.
- **Edit subscriptions** — change which notification types are routed to the endpoint.
- **Rotate secret** — generate a new signing secret. The previous secret remains valid for 24 hours to give you time to update your server.
- **View delivery log** — inspect recent deliveries, including response status codes, latency, and payload contents.
- **Delete** — permanently remove the endpoint and all its delivery history.

## What's next?

- Review the full list of events you can subscribe to in [Notification Types](/notifications/notification-types).
- Configure which notifications reach you in [Configuring Notifications](/notifications/configuring-notifications).