> ## Documentation Index
> Fetch the complete documentation index at: https://docs.atlasent.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook subscriptions

> Create a webhook subscription, verify the HMAC-SHA256 signature on each delivery, and inspect or replay failed deliveries.

AtlaSent can send an HTTP `POST` to your endpoint when an event occurs in your
organization. Use webhooks to alert on-call engineers, feed a SIEM, or start a
downstream workflow without polling the API.

Webhooks are notifications. A webhook delivery is not a permit and does not
authorize anything. Your protected actions must still verify a permit before
they execute. See [Permit verification](/permit-verification).

## Manage subscriptions

The `v1-webhooks` endpoint manages subscriptions. Every call is a `POST` to
the same endpoint. The `action` field in the JSON body selects the operation.
Calls need an API key with the `webhooks:write` scope. Any IP allowlist on the
key applies.

| `action` | Required fields | What it does |
| - | - | - |
| `create` | `name`, `url` | Creates a subscription. Returns `signing_secret` once. |
| `list` | none | Lists your organization's subscriptions. |
| `update` | `id`, `patch` | Changes `name`, `url`, `event_types`, `is_active`, `max_attempts` or `timeout_seconds`. |
| `delete` | `id` | Deletes a subscription. |
| `rotate_secret` | `id` | Issues a new signing secret and returns it. |
| `test` | `id` | Queues a test delivery to the subscription. |
| `list_deliveries` | none | Lists delivery history. You can filter by `subscription_id`. |
| `replay` | `delivery_id` | Resets a failed delivery so the next delivery run sends it again. |

### Create a subscription

```bash theme={null}
curl -X POST https://api.atlasent.io/functions/v1/v1-webhooks \
  -H "Authorization: Bearer $ATLASENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "create",
    "name": "on-call-alerts",
    "url": "https://your-app.example.com/webhooks/atlasent"
  }'
```

`name` must be unique in your organization and `url` must start with
`http://` or `https://`. You can also send `event_types` (an array of event
type strings) to limit the events you receive, plus `max_attempts`,
`timeout_seconds` and `is_active`.

The response contains the subscription and its `signing_secret`:

```json theme={null}
{
  "subscription": {
    "id": "…",
    "name": "on-call-alerts",
    "url": "https://your-app.example.com/webhooks/atlasent",
    "event_types": null,
    "is_active": true,
    "max_attempts": …,
    "timeout_seconds": …,
    "signing_secret": "…",
    "created_at": "…"
  }
}
```

<Warning>
  You see the `signing_secret` only in this response. Store it in your
  secrets manager right away. If you lose it, call `rotate_secret` to get a
  new one.
</Warning>

The response also shows the `max_attempts` and `timeout_seconds` values that
apply to the subscription.

## What a delivery looks like

Each delivery is a `POST` with a JSON body and these headers:

| Header | Value |
| - | - |
| `X-Atlasent-Event` | The event type |
| `X-Atlasent-Delivery` | The delivery ID (a UUID) |
| `X-Atlasent-Attempt` | The attempt number, starting at 1 |
| `X-Atlasent-Timestamp` | The send time, ISO 8601 in UTC |
| `X-Atlasent-Signature-256` | `sha256=` followed by a hex HMAC-SHA256 digest |

The body has this shape:

```json theme={null}
{
  "event_type": "…",
  "delivery_id": "…",
  "organization_id": "…",
  "subscription_id": "…",
  "attempt": 1,
  "timestamp": "…",
  "payload": { }
}
```

## Verify the signature

The signature is an HMAC-SHA256 of the timestamp header value, a period, and
the raw request body:

```
HMAC-SHA256(signing_secret, "{X-Atlasent-Timestamp}.{raw body}")
```

Your receiver must:

1. Read the raw body before you parse any JSON. Parsing and re-serializing
   changes the bytes and breaks the signature.
2. Rebuild the signed string from the `X-Atlasent-Timestamp` header and the
   raw body.
3. Compare digests in constant time.
4. Reject the delivery if the timestamp is too old. You choose the window.
   Five minutes is a common choice.
5. Return a `2xx` status only after the signature checks pass.

<CodeGroup>
  ```typescript TypeScript (Node.js) theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  export function verifyAtlasentDelivery(
    rawBody: string,
    timestamp: string | undefined,
    signatureHeader: string | undefined,
    secret: string,
    maxAgeMs = 5 * 60 * 1000,
  ): boolean {
    if (!timestamp || !signatureHeader?.startsWith("sha256=")) return false;

    const sentAt = Date.parse(timestamp);
    if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > maxAgeMs) return false;

    const expected = createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");
    const provided = signatureHeader.slice("sha256=".length).toLowerCase();

    const a = Buffer.from(expected, "utf8");
    const b = Buffer.from(provided, "utf8");
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  from datetime import datetime, timezone


  def verify_atlasent_delivery(
      raw_body: bytes,
      timestamp: str | None,
      signature_header: str | None,
      secret: str,
      max_age_seconds: int = 300,
  ) -> bool:
      if not timestamp or not signature_header or not signature_header.startswith("sha256="):
          return False
      try:
          sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
      except ValueError:
          return False
      age = abs((datetime.now(timezone.utc) - sent_at).total_seconds())
      if age > max_age_seconds:
          return False

      signed = timestamp.encode("utf-8") + b"." + raw_body
      expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
      provided = signature_header[len("sha256="):].lower()
      return hmac.compare_digest(expected, provided)
  ```
</CodeGroup>

If the check fails, return `401` and do not process the event.

## Retries and failed deliveries

A `2xx` response marks the delivery as delivered. Any other status, a network
error or a timeout counts as a failure. AtlaSent retries failed deliveries up
to the subscription's `max_attempts`. After the last attempt fails, the
delivery moves to the dead-letter queue and no more automatic retries happen.

Because a delivery can arrive more than once, use `X-Atlasent-Delivery` to
drop duplicates.

To inspect failed deliveries, call `list_deliveries` or use the Console. To
send one again, call `replay` with its `delivery_id`. See
Webhook dead-letter queue for the Console workflow.

## Test the integration

Queue a test delivery to a subscription:

```bash theme={null}
curl -X POST https://api.atlasent.io/functions/v1/v1-webhooks \
  -H "Authorization: Bearer $ATLASENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "action": "test", "id": "<subscription id>" }'
```

The response is `{ "queued": true, "delivery_id": "…" }`. A test delivery
ignores the subscription's `event_types` filter. It is signed the same way as
a real delivery, so your receiver's signature check runs on it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.