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

# Subscribing and verifying webhooks

> How to register a listener in the Console, verify the HMAC signature and handle retries.

This is the single reference page to integrate Cativa webhooks: listener subscription, event list, signature verification, retries and idempotency.

## How to subscribe

In the Cativa dashboard, open **Console > Webhooks** and click **Add listener**. The flow asks for 3 things:

1. **URL** — your app's public endpoint that will receive the `POST` (e.g. `https://myapp.com/webhooks/cativa`).
2. **Events** — which event names you want to listen to (snake\_case — e.g. `user_received_badge`, `paywall_payment_completed`).
3. **Secret** — generated automatically. After you create the listener, open it and click **Reveal secret** to copy the value (format `whsec_` + 64 hex characters). **Only the tenant admin can reveal the secret** — store it safely in your credentials vault.

<Note>
  Each listener has **its own secret**. If you register two listeners (e.g. one for staging and one for production), each signs deliveries with its own key.
</Note>

After that, every event matching the listener's rules is delivered via `POST` with `Content-Type: application/json`, `X-Cativa-Signature`, `X-Cativa-Execution-Id` and `X-Cativa-Automation-Id`.

## Available events

| Event                                                                                 | Description                                        |
| ------------------------------------------------------------------------------------- | -------------------------------------------------- |
| [`user_created`](/en/webhooks/events/user-created)                                    | New user registered in the tenant.                 |
| [`user_joined_group`](/en/webhooks/events/user-joined-group)                          | User joined a group (manual, via badge or invite). |
| [`user_received_badge`](/en/webhooks/events/user-received-badge)                      | Badge assigned to a user.                          |
| [`post_created`](/en/webhooks/events/post-created)                                    | Post published in a group.                         |
| [`paywall_payment_completed`](/en/webhooks/events/paywall-payment-completed)          | Paywall payment confirmed.                         |
| `comment_created` <Tooltip tip="page coming soon">coming soon</Tooltip>               | Comment created on a post.                         |
| `course_completed` <Tooltip tip="page coming soon">coming soon</Tooltip>              | User completed an entire course.                   |
| `lesson_completed` <Tooltip tip="page coming soon">coming soon</Tooltip>              | User completed a lesson.                           |
| `user_received_private_message` <Tooltip tip="page coming soon">coming soon</Tooltip> | Private message received.                          |

Use **exactly these snake\_case names** when subscribing the listener — that's how the Console matches.

## Verifying the HMAC signature

Every delivery ships the header:

```
X-Cativa-Signature: t=1715177521,v1=8c1d4e2a3b5f4d8a9c6e7f0b1a2d3e4f8a9c6e7f0b1a2d3e4f8a9c6e7f0b1a2d
```

* `t` — Unix timestamp (seconds) at delivery time.
* `v1` — HMAC-SHA256 (hex) over the string `"<t>.<rawBody>"`, using the listener's `secret` as the key.

To validate, do three steps:

1. **Parse** the header into `t` and `v1`.
2. **Anti-replay**: reject the request if `|now - t| > 300` seconds (5 minutes is the industry standard). This prevents replay attacks with old captured requests.
3. **Recompute** the HMAC and compare in **constant time**.

<Warning>
  Always compute the HMAC over the **raw body** (the exact string received in the request), never over re-serialized JSON. Re-serializing changes whitespace and key order, which invalidates the signature.
</Warning>

### Node.js (Express)

```js theme={null}
import express from 'express';
import crypto from 'crypto';

const app = express();
const SECRET = process.env.CATIVA_WEBHOOK_SECRET; // whsec_...

app.post(
  '/webhooks/cativa',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.header('X-Cativa-Signature') ?? '';
    const parts = Object.fromEntries(
      header.split(',').map(p => p.split('='))
    );
    const ts = Number(parts.t);
    const sig = parts.v1;

    if (!ts || !sig) return res.status(400).send('bad signature header');
    if (Math.abs(Date.now() / 1000 - ts) > 300) {
      return res.status(400).send('stale timestamp');
    }

    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${ts}.${req.body.toString('utf8')}`)
      .digest('hex');

    const ok = crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(sig, 'hex')
    );
    if (!ok) return res.status(401).send('invalid signature');

    const event = JSON.parse(req.body.toString('utf8'));
    // ... process the event
    res.status(200).send('ok');
  }
);
```

### Python (Flask)

```python theme={null}
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['CATIVA_WEBHOOK_SECRET'].encode()  # whsec_...

@app.post('/webhooks/cativa')
def receive():
    header = request.headers.get('X-Cativa-Signature', '')
    parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
    try:
        ts = int(parts['t'])
        sig = parts['v1']
    except (KeyError, ValueError):
        abort(400, 'bad signature header')

    if abs(time.time() - ts) > 300:
        abort(400, 'stale timestamp')

    raw_body = request.get_data()  # raw bytes — do not use request.json
    expected = hmac.new(
        SECRET,
        f"{ts}.".encode() + raw_body,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, sig):
        abort(401, 'invalid signature')

    payload = request.get_json()
    # ... process the event
    return ('ok', 200)
```

### Go

```go theme={null}
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var secret = []byte(os.Getenv("CATIVA_WEBHOOK_SECRET")) // whsec_...

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "read body", http.StatusBadRequest)
        return
    }

    var ts int64
    var sig string
    for _, part := range strings.Split(r.Header.Get("X-Cativa-Signature"), ",") {
        kv := strings.SplitN(part, "=", 2)
        if len(kv) != 2 {
            continue
        }
        switch kv[0] {
        case "t":
            ts, _ = strconv.ParseInt(kv[1], 10, 64)
        case "v1":
            sig = kv[1]
        }
    }
    if ts == 0 || sig == "" {
        http.Error(w, "bad signature header", http.StatusBadRequest)
        return
    }

    if abs(time.Now().Unix()-ts) > 300 {
        http.Error(w, "stale timestamp", http.StatusBadRequest)
        return
    }

    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(strconv.FormatInt(ts, 10) + "."))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expected), []byte(sig)) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    // ... process the event using body
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ok"))
}

func abs(x int64) int64 {
    if x < 0 {
        return -x
    }
    return x
}
```

### C# (ASP.NET Core)

```csharp theme={null}
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

app.MapPost("/webhooks/cativa", async (HttpContext ctx) =>
{
    var secret = Encoding.UTF8.GetBytes(
        Environment.GetEnvironmentVariable("CATIVA_WEBHOOK_SECRET")!); // whsec_...

    using var ms = new MemoryStream();
    await ctx.Request.Body.CopyToAsync(ms);
    var rawBody = ms.ToArray();

    var header = ctx.Request.Headers["X-Cativa-Signature"].ToString();
    long ts = 0;
    string? sig = null;
    foreach (var part in header.Split(','))
    {
        var kv = part.Split('=', 2);
        if (kv.Length != 2) continue;
        if (kv[0] == "t") long.TryParse(kv[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out ts);
        else if (kv[0] == "v1") sig = kv[1];
    }

    if (ts == 0 || sig is null)
        return Results.BadRequest("bad signature header");

    var nowUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    if (Math.Abs(nowUnix - ts) > 300)
        return Results.BadRequest("stale timestamp");

    using var hmac = new HMACSHA256(secret);
    var data = Encoding.UTF8.GetBytes($"{ts}.").Concat(rawBody).ToArray();
    var expected = Convert.ToHexString(hmac.ComputeHash(data)).ToLowerInvariant();

    var expectedBytes = Encoding.UTF8.GetBytes(expected);
    var sigBytes = Encoding.UTF8.GetBytes(sig);
    if (expectedBytes.Length != sigBytes.Length ||
        !CryptographicOperations.FixedTimeEquals(expectedBytes, sigBytes))
        return Results.Unauthorized();

    // ... process the event using rawBody
    return Results.Ok("ok");
});
```

## Retries and permanent failures

If your endpoint doesn't reply with `2xx`, Cativa retries on this curve:

```
30s  →  5min  →  30min  →  2h  →  6h  →  24h
```

That's **6 retries** after the initial attempt — **7 deliveries** total, covering roughly **33 hours**. Each retry carries the same `X-Cativa-Execution-Id` (stable across retries of the same event) — use it as your idempotency key.

Cativa honors the `Retry-After` header you return (capped at the next backoff window's max).

### Behavior table by status

| Code                            | Behavior                         |
| ------------------------------- | -------------------------------- |
| `2xx`                           | Success — no retry               |
| `408 Request Timeout`           | Retry                            |
| `429 Too Many Requests`         | Retry (honors `Retry-After`)     |
| `5xx` (500, 502, 503, 504, ...) | Retry                            |
| Network errors / TCP timeouts   | Retry                            |
| `400 Bad Request`               | **Permanent failure** — no retry |
| `401 Unauthorized`              | **Permanent failure** — no retry |
| `403 Forbidden`                 | **Permanent failure** — no retry |
| `404 Not Found`                 | **Permanent failure** — no retry |
| `410 Gone`                      | **Permanent failure** — no retry |

The logic: status in the `4xx` range (except `408` and `429`) means "the request is wrong and retrying won't help" — typically a client bug, deactivated URL or wrong auth. Status `5xx`, `408`, `429` and transport errors mean "try again later".

## When all retries fail

If the 7th attempt also fails, the delivery is recorded internally as failed. v1 does **not** yet ship a Console UI to inspect failed deliveries — monitor your endpoint's uptime on your own side, and if you suspect missed events, open a ticket at [dev@cativa.digital](mailto:dev@cativa.digital) and the team investigates internally.

## Idempotency

Delivery is at-least-once. The same event can arrive more than once (retries after a timeout, connection loss while replying, etc.) — you have to **detect duplicates** on your side.

The canonical key is the `X-Cativa-Execution-Id` header: it's generated once per event and **stays the same across all retries**. Save that ID in the same transaction as your business logic:

```js theme={null}
async function processEvent(executionId, payload) {
  const alreadyProcessed = await db.events.exists(executionId);
  if (alreadyProcessed) return; // already processed — return success

  await db.transaction(async (tx) => {
    await applyBusinessLogic(tx, payload);
    await tx.events.insert({ id: executionId, processedAt: new Date() });
  });
}
```

That guarantees either everything happened or nothing happened — no chance of double-processing.

## Related events

<CardGroup cols={2}>
  <Card title="Webhooks (overview)" icon="webhook" href="/en/concepts/webhooks">
    Why webhooks, delivery guarantees and the payload format.
  </Card>

  <Card title="user_received_badge" icon="shield-check" href="/en/webhooks/events/user-received-badge">
    Full reference page for an event — payload, headers and example receiver.
  </Card>
</CardGroup>
