Skip to main content

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.

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.
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.
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

EventDescription
user_createdNew user registered in the tenant.
user_joined_groupUser joined a group (manual, via badge or invite).
user_received_badgeBadge assigned to a user.
post_createdPost published in a group.
paywall_payment_completedPaywall payment confirmed.
comment_created Comment created on a post.
course_completed User completed an entire course.
lesson_completed User completed a lesson.
user_received_private_message 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.
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.

Node.js (Express)

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)

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

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)

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

CodeBehavior
2xxSuccess — no retry
408 Request TimeoutRetry
429 Too Many RequestsRetry (honors Retry-After)
5xx (500, 502, 503, 504, …)Retry
Network errors / TCP timeoutsRetry
400 Bad RequestPermanent failure — no retry
401 UnauthorizedPermanent failure — no retry
403 ForbiddenPermanent failure — no retry
404 Not FoundPermanent failure — no retry
410 GonePermanent 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 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:
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.

Webhooks (overview)

Why webhooks, delivery guarantees and the payload format.

user_received_badge

Full reference page for an event — payload, headers and example receiver.