Cariosan

Webhook guide

Verify Cariosan webhook signatures and implement idempotency in Go, Node, and Python.

8 min readUpdated Apr 2026

Every webhook delivery arrives at your endpoint as a signed HTTP POST. Verifying the signature and implementing idempotency is on you — this guide shows how in three languages.

Request shape

POST /cariosan/events HTTP/1.1
Content-Type: application/json
User-Agent: Cariosan-Webhooks/1.0
X-Cariosan-Event: message.created
X-Cariosan-Event-Id: evt_01HXYZ...
X-Cariosan-Signature: sha256=<hex>
X-Cariosan-Timestamp: 1713350400

{
  "event_id": "evt_01HXYZ...",
  "event": "message.created",
  "workspace_id": "ws_uuid",
  "created_at": "2026-04-17T10:00:00Z",
  "data": {
    "message": {
      "id": "msg_uuid",
      "channel_id": "chan_uuid",
      "channel_external_id": "order_123",
      "user": { "id": "user_uuid", "external_id": "user_abc", "name": "Alice" },
      "content": "Halo",
      "type": "text",
      "attachment_url": null,
      "created_at": "2026-04-17T10:00:00Z"
    }
  }
}

Events

Subscribe to any of these on CreateWebhook. The envelope is identical to the example above; only data differs per event.

EventFires whendata
message.createdA message is sent{ message: { … } }
message.updatedA message is edited{ message: { id, channel_id, channel_external_id, content, edited_at } }
message.deletedA message is unsent{ message: { id, channel_id, channel_external_id, deleted_at } }
message.reaction.addedA reaction is added{ reaction: { message_id, channel_id, channel_external_id, user_id, emoji } }
message.reaction.removedA reaction is removed{ reaction: { … same shape } }
message.mentionedA message @-mentions ≥1 member{ message_id, channel_id, channel_external_id, by_user: { … }, mentions: [{ user_id, external_id, name }] }
message.moderatedAutomatic moderation acts on a message{ channel_id, channel_external_id, user_id, message_id?, rule, action, excerpt }
channel.created / channel.deletedA channel is created / deleted{ channel: { … } }
member.joined / member.leftChannel membership changes{ channel_id, user_id }
user.createdA user is provisioned{ user: { … } }

Quoted replies

When a message.created event is a reply, its message object also includes parent_id (the quoted message's id) and a quoted_message preview — { id, user_id, user_name, content, type, deleted }. Both fields are absent on non-reply messages, so branch on their presence.

Mentions

When a message.created event @-mentions members, its message object includes a mentions array ([{ user_id, external_id, name }]). A dedicated message.mentioned event also fires once per mentioning message — subscribe to it specifically if you only care about routing mention notifications.

Signature scheme

  • Algorithm: HMAC-SHA256.
  • Pre-image: <timestamp>.<raw body>. The body is the exact bytes you received, not a re-serialised version.
  • Key: the signing_secret you got once on CreateWebhook.
  • Encoding: hex, prefixed with sha256= in the header.

Always use constant-time comparisonhmac.Equal (Go), crypto.timingSafeEqual (Node), hmac.compare_digest (Python). Non-constant-time comparison leaks timing information that can recover the HMAC byte-by-byte from a remote attacker.

Replay protection

Reject deliveries whose timestamp is more than 5 minutes older than your server clock. Same window, same idea as Stripe and Slack — tight enough to stop replays, loose enough to tolerate NTP skew.

Idempotency

Cariosan retries on 5xx / timeout. Same event_id can arrive twice. Your handler must be safe to apply twice.

PatternINSERT ... ON CONFLICT (event_id) DO NOTHING in SQL, or make the downstream effect naturally idempotent (upserts, set assignments). Either approach makes retries free.

Examples

webhook.go
package main
 
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
)
 
const signingSecret = "whsec_..."
 
func handler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "read body", http.StatusBadRequest)
		return
	}
	ts := r.Header.Get("X-Cariosan-Timestamp")
	sig := r.Header.Get("X-Cariosan-Signature")
 
	tsInt, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		http.Error(w, "bad timestamp", http.StatusBadRequest)
		return
	}
	if time.Since(time.Unix(tsInt, 0)) > 5*time.Minute {
		http.Error(w, "timestamp too old", http.StatusBadRequest)
		return
	}
 
	mac := hmac.New(sha256.New, []byte(signingSecret))
	mac.Write([]byte(ts))
	mac.Write([]byte{'.'})
	mac.Write(body)
	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	if !hmac.Equal([]byte(sig), []byte(expected)) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
 
	// 2xx acks the delivery. Do the real work here — INSERT ON
	// CONFLICT on r.Header.Get("X-Cariosan-Event-Id") so retries are
	// no-ops.
	_ = strings.ToLower // keep imports lively in snippets
	w.WriteHeader(http.StatusOK)
}

Retry and failure handling

  • 2xx → success, no further attempts.
  • 4xx → terminal failure. Cariosan assumes your handler has a bug and won't retry.
  • 5xx / timeout / network → retried at 30s, 2m, 10m, 1h, 6h with ±20% jitter, then marked permanently_failed.
  • 24 consecutive failures → the webhook is auto-disabled.

4xx is terminal — fix the handler bug, then manually retry via POST /v1/webhooks/:id/deliveries/:delivery_id/retry. Cariosan won't retry 4xx automatically because doing so would just spin against a broken endpoint.

Use the delivery history endpoint to debug:

terminal
curl -u pk_live_abc:sk_live_xyz \
    "$CARIOSAN_URL/v1/webhooks/$WH_ID/deliveries?limit=10"

Tip

Each delivery record shows the attempt count, the server's response status, the truncated response body, and any network error — handy for tracking down silent handler failures.

Was this page helpful?

On this page