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¶
- In the Contio app, go to Settings → API & Webhooks → Webhooks.
- Click Create Webhook, enter an HTTPS endpoint URL, and select the events to subscribe to.
- 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.
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 event ID (UUID). Use for idempotency. |
X-Contio-Delivery-ID | Unique ID for this delivery attempt (UUID). |
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:
To verify:
- Read the raw request body before parsing it as JSON.
- Compute
HMAC-SHA256(rawBody, signingSecret)and hex-encode it. - Prefix with
sha256=and compare against the header using a constant-time comparison. - 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 3 times with an
n²-minute backoff (approximately 1, 4, and 9 minutes). 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
2xxstatus 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": "active",
"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_idcan arrive more than once. Record processedevent_ids and ignore duplicates. - Respond fast, process async. Acknowledge with
2xximmediately 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 event_id | Idempotency — the same event is only acted on once, even if delivered twice. | Record processed event_ids and ignore repeats. |
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:
- Verify the signature (layer 1) first.
- Parse
X-Contio-Timestampand reject the request if it is outside your tolerance window (for example, more than five minutes from the current time). - De-duplicate by
event_idso a replayed request within the window is still ignored.
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 event_id 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 + event_id dedup are belt-and-suspenders. If your handler triggers side effects, make those effects idempotent (keyed on event_id) — 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 | Retried up to 3 times with 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¶
- Personal Access Tokens — issue credentials for reading webhook configuration via the API
- API Reference — the full endpoint catalog and interactive spec