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.
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:
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_secretfrom the create response. If your receiver rejects requests whose signature it cannot yet check, that first challenge simply fails and the endpoint staysverifying; 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:
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 2 more times (3 attempts total) with an
n²-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
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": "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-Keyvalues and ignore duplicates (see De-duplication). - 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 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:
- 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
X-Contio-Idempotency-Keyso 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
POSTto 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 n²-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¶
- Personal Access Tokens — issue credentials for reading webhook configuration via the API
- API Reference — the full endpoint catalog and interactive spec