Browse the docs
Webhooks for calls
Updated
On this page
OneCamp can tell your own systems when a call begins and ends. An outgoing webhook set up for call.started and call.ended is sent an event when a call in a channel or a meeting starts, and another when it ends, with its times, how long it ran and how many people joined. Use them to log calls, count meeting time, or start your own follow-up when a meeting ends.
Set one up
Only admins can set up webhooks.
- Go to Admin → Webhooks and choose New webhook.
- Choose Outgoing, give it a name, and enter the address to send events to. It must start with
https://. - Under Events from, choose The whole workspace or One channel. A webhook set to one project is sent no calls.
- Under call, tick call.started, call.ended, or both. You can tick other kinds too: the dialog lists every kind of event a webhook can be sent.
- Choose Create webhook.
A webhook is sent only the kinds of event it names, so it needs at least one. Until one is ticked, Create webhook and Save changes wait, and OneCamp refuses a webhook with none: "Choose at least one event to send: a webhook with none is sent nothing."
Older webhooks could be made with no kinds ticked, and they were sent nothing. Admin → Webhooks lists such a webhook as Sends nothing: no kinds ticked. Choose Edit beside it and tick the kinds it should be sent.
Which calls are sent
- Calls in channels and in meetings that the person who set up the webhook can open: a channel they can read, or a meeting they host or were let into. OneCamp checks this each time it sends, so calls stop reaching the webhook once that person loses access.
- A webhook set to one channel is sent that channel's calls. A meeting's calls go only to webhooks set to the whole workspace.
- A call in a direct message or a group chat is never sent.
- A breakout room sends nothing of its own: it is part of its main call.
An event carries ids, times and counts. It never carries a name, a title or anything said in the call.
What an event looks like
Each event is a JSON POST to the webhook's address:
{
"event": "call.ended",
"timestamp": "2026-11-02T10:31:07Z",
"data": {
"session_id": "3f2b8c1e-5d4a-4f6b-9c2d-7e8f9a0b1c2d",
"meeting_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"started_at": "2026-11-02T10:00:02.418329Z",
"ended_at": "2026-11-02T10:31:06.902114Z",
"duration_s": 1864,
"people": 6,
"reason": "last_left"
}
}
event is the kind, timestamp is when OneCamp sent the event out (UTC, the same on every try), and data holds the call's fields:
| Field | Sent in | What it is |
|---|---|---|
session_id |
both | The call. Its call.started and call.ended carry the same id; a call started again later has a new one. |
channel_id |
both, for a call in a channel | The channel the call is in. |
meeting_id |
both, for a call in a meeting | The meeting the call is in. |
actor_uuid |
both, when someone did it | Who started the call, or the host who ended it for everyone. Absent when a call ended by itself. |
started_at |
both | When the call began (RFC 3339, UTC). |
ended_at |
call.ended | When it ended (RFC 3339, UTC). |
duration_s |
call.ended | How long it ran, in whole seconds. |
people |
call.ended | How many people joined it. |
reason |
call.ended | Why it ended (below). |
Why a call ended:
host_ended: a host ended it for everyone.last_left: the last person in it left.room_finished: the call's media room closed.reconciled: OneCamp found the call had already ended, for example after a restart, and closed it.
A call starting in a channel:
{
"event": "call.started",
"timestamp": "2026-11-02T10:00:03Z",
"data": {
"session_id": "3f2b8c1e-5d4a-4f6b-9c2d-7e8f9a0b1c2d",
"channel_id": "c9f0f895-fb98-4b91-8e1f-2d3c4b5a6978",
"actor_uuid": "45c48cce-2e2d-4fbd-a2b1-6f7e8d9c0b1a",
"started_at": "2026-11-02T10:00:02.418329Z"
}
}
Receiving events
- Answer with any
2xxwithin 10 seconds. Any other answer, or none, is a failed try. - A failed delivery is tried again, up to 10 tries in all over about a day: after 10 seconds, 1 minute, 5 minutes and 30 minutes, then 1, 2, 4, 6 and 8 hours, each a little later at random.
- After 50 failed tries in a row, the webhook pauses itself and OneCamp tells the person who set it up. Admin → Webhooks says why, with Resume.
Each delivery carries these headers:
| Header | What it is |
|---|---|
X-OneCamp-Event |
The kind, such as call.started. |
X-OneCamp-Webhook-Id |
The webhook's id. |
X-OneCamp-Delivery |
The event's id, the same on every try, so you can drop a repeat. |
X-OneCamp-Retry-Num |
0 on the first try, then 1, 2 and so on. |
X-OneCamp-Timestamp |
When this try was signed, in Unix seconds. |
X-OneCamp-Signature |
v1= and the signature (below). |
Check the signature
Each outgoing webhook gets its own signing secret when it is made. Admin → Webhooks shows it, and can regenerate it.
The signature is the HMAC-SHA256, in lowercase hex, of v1:<timestamp>:<body>, keyed with the secret, where <timestamp> is the X-OneCamp-Timestamp header and <body> is the raw request body. Compute it over the body's bytes exactly as they arrived, compare in constant time, and refuse an old timestamp (for example, one more than five minutes old) so a captured request can't be replayed.
Python:
import hashlib
import hmac
def verify(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
version, _, received = signature.partition("=")
if version != "v1":
return False
expected = hmac.new(secret.encode(), f"v1:{timestamp}:".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
Node.js:
const crypto = require("crypto")
// body is the raw request body, as a Buffer.
function verify(body, signature, timestamp, secret) {
const [version, received] = signature.split("=")
if (version !== "v1" || !received) return false
const expected = crypto.createHmac("sha256", secret).update(`v1:${timestamp}:`).update(body).digest("hex")
return received.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))
}
Something missing or wrong in this guide? Write to support@onemana.dev.