Skip to content

Workspace Webhooks

Workspace webhooks require a PRO workspace or higher

Creating webhook endpoints is a paid feature. See Plans & Limits for the full feature matrix.

Workspace webhooks push real-time notifications to an HTTPS endpoint you control whenever events occur in your workspace — for example when a meeting is created or updated. They are the inbound counterpart to the User API: instead of polling /v1, you receive events as they happen.

Every delivery is signed with HMAC-SHA256 so your endpoint can verify that the request genuinely originated from Contio and was not altered in transit.

Configuration is done in the Contio app

Registering a webhook mints a signing secret, which is a privileged first-party operation. Create, rotate-secret, and the initial secret reveal happen in the Contio app under Settings → API & Webhooks → Webhooks. The User API exposes the read/lifecycle operations — list, delete, and re-enable — under /v1/workspace/webhooks.

Registering a webhook

  1. In the Contio app, go to Settings → API & Webhooks → Webhooks.
  2. Click Create Webhook, enter an HTTPS endpoint URL, and select the events to subscribe to.
  3. Contio generates a signing secret (prefixed whsec_) and displays it once.

The signing secret is shown once

The plaintext signing secret is returned exactly once, at creation time (and again each time you rotate it). Copy it immediately and store it in a secrets manager — it cannot be recovered. If you lose it, Rotate secret to mint a new one (this invalidates the old secret).

The secret is generated by Contio (you do not supply it) and is stored encrypted at rest.

Verifying endpoint ownership

A new webhook starts in the pending state and receives no subscribed events until it proves ownership of the URL by answering a signed verification challenge.

Right after the create request completes, Contio sends a webhook.verification event to the endpoint. It uses the same headers and HMAC signature as every other delivery (including X-Contio-Idempotency-Key), so you can verify it with the code below before answering:

{
  "event_type": "webhook.verification",
  "event_id": "018f3a9c-1d2e-7c34-9b5a-6f7e8d9c0a1b",
  "timestamp": "2026-09-10T14:30:00Z",
  "workspace_id": "018f3a9c-0000-7c34-9b5a-6f7e8d9c0a1b",
  "data": {
    "verification_token": "3f1c9e8b2a7d4c6e0b5a9f8e7d6c5b4a",
    "endpoint_id": "018f3a9c-2222-7c34-9b5a-6f7e8d9c0a1b"
  }
}

To complete verification, respond with HTTP 2xx and echo data.verification_token back in the response body, in either form:

3f1c9e8b2a7d4c6e0b5a9f8e7d6c5b4a
{ "verification_token": "3f1c9e8b2a7d4c6e0b5a9f8e7d6c5b4a" }

Any other response (a non-2xx status, an empty body, a different token) leaves the endpoint pending. Contio re-sends the challenge every 5 minutes for up to 24 hours; each attempt carries a fresh event_id, but the token is stable for the endpoint until it is verified.

Bootstrap note. The first challenge is sent as soon as the create request has been committed, which is normally before your code has stored the signing_secret from the create response. If your receiver rejects requests whose signature it cannot yet check, that first challenge simply fails and the endpoint stays verifying; the next scheduled retry (or Resend verification) will succeed once the secret is in place. Redirects are not followed — the challenge must be answered at the registered URL.

The token is never returned by the API — it only ever travels to your endpoint inside the signed challenge.

Verification state

Every webhook object exposes a verification_state alongside its status:

verification_state Meaning
verifying pending; the retry window is still open and challenges are being sent.
failed pending; the 24-hour retry window closed without a valid echo.
verified Ownership proven; subscribed events are delivered.
disabled Auto-disabled after repeated delivery failures; use Re-enable.

verification_attempt_count, verification_last_sent_at and verification_retry_until are also included so you can see where the loop is.

Resending the challenge

When a webhook is failed, or it is still verifying and you have just fixed your endpoint and do not want to wait for the next retry, use Resend verification on the webhook row or call:

POST /api/workspace/webhooks/{id}/resend-verification   (internal, workspace admin)
POST /v1/workspace/webhooks/{id}/resend-verification    (API key, workspace:write)

This resets the retry window and attempt counter and sends a fresh challenge immediately. The response is the updated webhook: verified if your endpoint echoed the token synchronously, otherwise verifying. It is accepted in both the verifying and failed states (the underlying status is pending in both); calling it on a verified or disabled webhook returns 409 endpoint_not_pending.

Delivery format

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

Header Description
Content-Type Always application/json.
User-Agent Always Contio-Webhook/1.0.
X-Contio-Signature HMAC-SHA256 signature, format sha256=<hex> (see below).
X-Contio-Event-Type The event type, e.g. meeting.created.
X-Contio-Event-ID Unique ID of the logical event (UUID). Stable across retries and redelivery; matches the payload's event_id.
X-Contio-Idempotency-Key The de-duplication key. Opaque, stable identifier for this event as delivered to your endpoint. Identical on every retry; distinct for each endpoint. See De-duplication.
X-Contio-Delivery-ID ID of Contio's internal delivery record (UUID). Stable across retries of that record, but not a logical-event key — do not dedupe on it.
X-Contio-Timestamp Unix timestamp (seconds) of the event. Use for replay protection.
X-Contio-Retry-Count Present only on retried deliveries; the attempt number.

Payload envelope

All events share this envelope:

{
  "event_type": "meeting.created",
  "event_id": "018f3a9c-1d2e-7c34-9b5a-6f7e8d9c0a1b",
  "timestamp": "2026-07-10T14:30:00Z",
  "workspace_id": "018f3a9c-0000-7c34-9b5a-6f7e8d9c0a1b",
  "actor_user_id": "018f3a9c-1111-7c34-9b5a-6f7e8d9c0a1b",
  "data": { }
}
Field Type Description
event_type string The event identifier (matches the header).
event_id string Unique event ID (UUID). Stable across retries.
timestamp string ISO 8601 timestamp of the event.
workspace_id string The workspace the event belongs to.
actor_user_id string The user who triggered the event. Empty for system-triggered events.
data object Event-specific payload.

Verifying the signature

The X-Contio-Signature header contains an HMAC-SHA256 of the raw request body (the exact bytes received, before any JSON parsing), keyed by your signing secret:

X-Contio-Signature: sha256=<hex-encoded-signature>

To verify:

  1. Read the raw request body before parsing it as JSON.
  2. Compute HMAC-SHA256(rawBody, signingSecret) and hex-encode it.
  3. Prefix with sha256= and compare against the header using a constant-time comparison.
  4. Reject the request (respond 401) if the signatures do not match.

Hash the raw body, not re-serialized JSON

Verification is performed over the raw body. If you re-serialize the parsed JSON before hashing, key ordering or whitespace differences will produce a mismatch.

const crypto = require('crypto');
const express = require('express');
const app = express();

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader) return false;

  const [scheme, signature] = signatureHeader.split('=');
  if (scheme !== 'sha256' || !signature) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');

  const a = Buffer.from(signature, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Use express.raw so req.body is the exact bytes Contio signed.
app.post('/webhooks/contio', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-contio-signature'];
  if (!verifyWebhookSignature(req.body, signature, process.env.CONTIO_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Process event.event_type / event.data ...
  res.status(200).json({ received: true });
});
import hashlib
import hmac
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your-signing-secret"

def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header or not signature_header.startswith("sha256="):
        return False
    provided = signature_header.split("=", 1)[1]
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(provided, expected)

@app.post("/webhooks/contio")
def contio_webhook():
    signature = request.headers.get("X-Contio-Signature", "")
    # request.get_data() returns the raw body bytes.
    if not verify_signature(request.get_data(), signature, WEBHOOK_SECRET):
        abort(401)

    event = request.get_json()
    # Process event["event_type"] / event["data"] ...
    return {"received": True}, 200
func verifySignature(rawBody []byte, signatureHeader, secret string) bool {
    const prefix = "sha256="
    if !strings.HasPrefix(signatureHeader, prefix) {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := prefix + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(signatureHeader), []byte(expected))
}

func handler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "read error", http.StatusBadRequest)
        return
    }
    if !verifySignature(body, r.Header.Get("X-Contio-Signature"), webhookSecret) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }
    // Parse body and process the event ...
    w.WriteHeader(http.StatusOK)
}

Delivery guarantees

  • Transport — endpoints must use HTTPS.
  • Timeout — each attempt has a short request timeout; respond quickly and do heavy work asynchronously.
  • Retries — a failed delivery (network error or non-2xx response) is retried up to 2 more times (3 attempts total) with an -minute backoff (approximately 1 and 4 minutes after the initial attempt). After the final attempt the delivery is abandoned.
  • Auto-disable — after 3 consecutive delivery failures the endpoint is automatically disabled and the endpoint's creator is notified by email. Use re-enable to resume deliveries once the endpoint is healthy.
  • Expected response — return any 2xx status to acknowledge receipt. Any other status (or a timeout) is treated as a failure and scheduled for retry.

Managing webhooks via the API

The User API exposes the read and lifecycle operations. These require the workspace:write scope and an ADMIN or OWNER role.

Method Endpoint Description
GET /v1/workspace/webhooks List the workspace's webhook endpoints
DELETE /v1/workspace/webhooks/{id} Delete a webhook endpoint
POST /v1/workspace/webhooks/{id}/re-enable Re-enable an auto-disabled endpoint
curl "https://api.contio.ai/v1/workspace/webhooks?limit=20&offset=0" \
  -H "Authorization: Bearer $CONTIO_TOKEN"
{
  "items": [
    {
      "id": "018f3a9c-...",
      "workspace_id": "018f3a9c-...",
      "url": "https://example.com/webhooks/contio",
      "subscribed_events": ["meeting.created", "meeting.updated"],
      "status": "verified",
      "last_success_at": "2026-07-10T14:30:05Z",
      "consecutive_failure_count": 0,
      "created_at": "2026-07-05T00:00:00Z",
      "updated_at": "2026-07-10T14:30:05Z"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

Creating and rotating are app-only

Registering a webhook and rotating its secret mint a plaintext secret and are therefore restricted to the Contio app (first-party). They are not available via /v1.

Best practices

  • Verify first. Always verify the signature before processing the payload, and reject unverified requests with 401.
  • Be idempotent. Deliveries may be retried, so the same event can arrive more than once. Record processed X-Contio-Idempotency-Key values and ignore duplicates (see De-duplication).
  • Respond fast, process async. Acknowledge with 2xx immediately and hand off to a background worker so you stay within the request timeout.
  • Protect the secret. Store the signing secret in a secret manager or environment variable — never in source control or logs. Rotate it periodically.

Security model

Securing a webhook endpoint is deliberately simple: three small, independent checks give you strong guarantees, and each is only a few lines of code.

Layer What it protects How
1. Verify the signature Authenticity & integrity — the request really came from Contio and was not altered. HMAC-SHA256 over the raw body, compared in constant time. See Verifying the signature.
2. Check freshness Staleness — bounds how long a captured delivery stays acceptable. Reject if X-Contio-Timestamp is outside your tolerance window. See Replay protection.
3. De-duplicate by X-Contio-Idempotency-Key Idempotency — the same event is only acted on once, even if delivered twice. Record processed keys and ignore repeats. See De-duplication.

You don't have to reason about cryptography or attack scenarios — implement these three checks and your endpoint is secure by construction. The rest of this section explains when layers 2 and 3 matter for your integration, so you can right-size the effort.

Replay protection

A replay is when someone captures a legitimate, correctly-signed delivery and re-sends the exact bytes later. The signature still validates (the body is unchanged), so signature verification alone doesn't stop it — freshness and de-duplication do.

Each delivery includes an X-Contio-Timestamp header carrying the event's Unix timestamp (in seconds). The value is stable across retries and matches the payload's signed timestamp field, so you can reject stale or replayed deliveries:

  1. Verify the signature (layer 1) first.
  2. Parse X-Contio-Timestamp and reject the request if it is outside your tolerance window (for example, more than five minutes from the current time).
  3. De-duplicate by X-Contio-Idempotency-Key so a replayed request within the window is still ignored.

De-duplication

X-Contio-Idempotency-Key is the single header you should dedupe on. Contio guarantees:

  • It is derived from the logical event (event_type + event_id) and your endpoint — never from the delivery attempt or the send time.
  • Every delivery attempt of the same event to the same endpoint carries the same key, whether it is an automatic retry or an internal redelivery of the event.
  • Different events, and the same event sent to a different endpoint, carry different keys.
  • Contio also enforces this key on its side: a repeated internal redelivery of the same event never produces a second POST to your endpoint. The header exists so that you can enforce the same guarantee end-to-end.

Treat the value as an opaque string of up to 64 characters. Store each processed key (with a TTL at least as long as your replay tolerance window plus our retry horizon — 24 hours is a safe default) and skip processing when a key has already been seen.

X-Contio-Event-ID (equal to the payload's event_id) is also stable per logical event and remains a valid dedup key if your endpoint is the only consumer; the idempotency key is preferred because it is scoped to your endpoint and is the value Contio enforces internally. X-Contio-Delivery-ID identifies Contio's internal delivery record (an operational handle for support) and must not be used for de-duplication.

Do you need to worry about replays?

Contio webhooks are event signals, not commands: a payload says "this happened, here is the ID" and the correct reaction is usually to fetch the current state from the User API. For that pattern, a replay is harmless — you simply re-fetch and converge on the same authoritative state, because the fetch is naturally idempotent.

Replays only cause problems if your handler has non-idempotent side effects — for example sending an email, charging a card, or inserting a row directly on each delivery. If that describes your integration, the X-Contio-Idempotency-Key de-duplication in step 3 is your mitigation: it guarantees each logical event is acted on exactly once.

Rule of thumb

If your handler only reads Contio data and reconciles local state, freshness + idempotency-key dedup are belt-and-suspenders. If your handler triggers side effects, make those effects idempotent (keyed on X-Contio-Idempotency-Key) — that single step neutralizes replays entirely.

The timestamp is not part of the signature

X-Contio-Timestamp is an informational header — it is not included in the signed material, so the signature is still computed over the raw body only (see Verifying the signature). It mirrors the signed timestamp inside the payload for convenience, so you can check freshness before parsing JSON. If you want a fully-trusted value, read timestamp from the verified payload rather than the header — and don't compare the two, since the header is an unsigned copy, not a second source of truth.

Webhooks vs. Server-Sent Events (SSE)

Both webhooks and SSE deliver the same workspace event catalogue, but they are designed for different integration patterns. Admins usually choose the one that fits their infrastructure:

Workspace Webhooks User Events (SSE)
Delivery model Push over HTTPS POST to your endpoint. Pull over a single long-lived HTTP GET stream.
Who can use it workspace:write scope with OWNER/ADMIN role. Any of meetings:read, action-items:read, calendar:read, or workspace:read.
Endpoint requirement You must expose a public HTTPS endpoint. No public endpoint needed — open the stream from your client.
Event selection Admin chooses the subscribed event types per webhook. All events the token's scopes allow are eligible; delivered after scope and actor screening.
Actor visibility All selected events are delivered, including every user's activity. Non-admin tokens only receive system events or events triggered by the caller. workspace:read (admin) sees all actors.
Authentication / integrity HMAC-SHA256 signature over the raw body. Bearer token over TLS; events are delivered as SSE frames.
Reliability Up to 3 delivery attempts (initial + 2 retries) with -minute backoff; auto-disabled after 3 consecutive failures. No retries; client reconnects with Last-Event-ID to resume.
Best for Backend services, data replication, CRM/ERPs. Clients that cannot expose a public endpoint, real-time user agents, scripts.

Use webhooks when you control a server that can receive and verify signed HTTPS deliveries. Use SSE when you need events inside a client or agent that cannot accept inbound connections. The same actor_user_id field is included in both payloads so you can attribute activity to individual users.

See also