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

# Outbound Webhooks

> Event-driven HTTP POST delivery to external services when records are created, updated, or deleted.

Outbound webhooks notify external services when events happen in your workflow app.

## Setup

1. Open your workflow app and go to **Webhooks** in the sidebar.
2. On the **Outgoing** tab, click **Add endpoint**.
3. Enter the destination URL, select the events to subscribe to, and save.
4. Copy the **signing secret** — you'll use it to verify incoming requests.

## Events

| Event                 | When it fires                            |
| --------------------- | ---------------------------------------- |
| `record.created`      | A single record is created               |
| `record.updated`      | A record is updated                      |
| `record.deleted`      | A record is deleted                      |
| `record.bulk_created` | Records are created via a bulk operation |
| `schema.updated`      | The entity schema is modified            |

## Delivery

Requests are delivered asynchronously. HASP retries failed deliveries up to **5 times** with exponential backoff:

| Attempt | Delay after previous failure |
| ------- | ---------------------------- |
| 1       | Immediate                    |
| 2       | 10 seconds                   |
| 3       | 60 seconds                   |
| 4       | 5 minutes                    |
| 5       | 30 minutes                   |
| (final) | 2 hours                      |

A delivery is considered successful if the endpoint returns any `2xx` status code within **10 seconds**. Redirects are not followed.

## Request Format

```
Content-Type: application/json
X-Hasp-Webhook-Id: <delivery-ulid>
Hasp-Signature: t=<unix-timestamp>,v1=<hmac-hex>
User-Agent: Hasp-Webhook/1.0
```

Body envelope:

```json theme={null}
{
  "id": "01JQDELIVERY0000000000000",
  "event": "record.created",
  "app_id": "01JQAPP00000000000000000",
  "entity_key": "tasks",
  "timestamp": "2026-03-22T01:31:46+00:00",
  "data": { ... }
}
```

## Verifying Signatures

Every delivery includes a `Hasp-Signature` header using the same scheme Stripe uses: `t={unix_timestamp},v1={hmac-hex}`. The signed value is `"{timestamp}.{raw_body}"`, HMAC-SHA256'd with your endpoint's secret — binding the signature to a point in time so a captured request can't be replayed later.

While a secret rotation is within its 7-day grace period (see [Rotating the Signing Secret](#rotating-the-signing-secret)), the header carries **two** `v1` entries — one signed with the new secret, one with the old:

```
Hasp-Signature: t=1710000000,v1=<sig-with-new-secret>,v1=<sig-with-old-secret>
```

Verification is: parse `t` and every `v1` value, recompute the expected signature with whichever secret(s) you have deployed, and accept the delivery if **any** `v1` value matches.

<CodeGroup>
  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from 'crypto';

  const TOLERANCE_SECONDS = 300; // ±5 minutes

  function verifySignature(body, secret, signatureHeader) {
    const parts = Object.fromEntries(
      signatureHeader.split(',').map((p) => {
        const [key, value] = p.split('=');
        return [key, value];
      })
    );
    const timestamp = parseInt(signatureHeader.match(/t=(\d+)/)[1], 10);
    const signatures = [...signatureHeader.matchAll(/v1=([0-9a-f]+)/g)].map((m) => m[1]);

    if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

    const expected = createHmac('sha256', secret)
      .update(`${timestamp}.${body}`)
      .digest('hex');

    return signatures.some((sig) => {
      const a = Buffer.from(sig);
      const b = Buffer.from(expected);
      return a.length === b.length && timingSafeEqual(a, b);
    });
  }

  app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const sig = req.headers['hasp-signature'];
    if (!verifySignature(req.body, process.env.WEBHOOK_SECRET, sig)) {
      return res.status(401).send('Invalid signature');
    }
    res.sendStatus(200);
  });
  ```

  ```python Python theme={null}
  import hashlib, hmac, os, re, time

  TOLERANCE_SECONDS = 300  # ±5 minutes

  def verify_signature(body: bytes, secret: str, signature_header: str) -> bool:
      timestamp_match = re.search(r"t=(\d+)", signature_header)
      if not timestamp_match:
          return False
      timestamp = int(timestamp_match.group(1))

      if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
          return False

      signed_payload = f"{timestamp}.".encode() + body
      expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()

      signatures = re.findall(r"v1=([0-9a-f]+)", signature_header)
      return any(hmac.compare_digest(expected, sig) for sig in signatures)

  @app.route("/webhook", methods=["POST"])
  def webhook():
      sig = request.headers.get("Hasp-Signature", "")
      if not verify_signature(request.get_data(), os.environ["WEBHOOK_SECRET"], sig):
          return "Invalid signature", 401
      return "", 200
  ```
</CodeGroup>

<Warning>Compute the signature against the **raw request body bytes**, not a parsed JSON object. Use a constant-time comparison to prevent timing attacks, and reject timestamps outside a ±5-minute tolerance to prevent replay.</Warning>

## Idempotency

The `X-Hasp-Webhook-Id` header contains the delivery ULID. Store this value and check for duplicates before processing — network failures can cause the same delivery to arrive more than once.

## Rotating the Signing Secret

Call `POST /v1/webhooks/{endpoint_id}/rotate-secret` (see [Rotate the signing secret](/ai-api/control/webhooks#rotate-the-signing-secret)). The previous secret remains valid for signing for **7 days** after rotation — every delivery during that window carries both signatures (see [Verifying Signatures](#verifying-signatures) above), so you can deploy the new secret without missing or rejecting a delivery.
