Build
Webhooks
A webhook is a URL of yours that Crackle sends a request to whenever something happens on a call. It is how your system learns that a call ended and what was said, without asking.
Three places to subscribe
| For | Set it with | Signed |
|---|---|---|
| Every call in the workspace | Create a subscription (POST /v1/webhooks), choosing the events | Yes |
| Every call of one agent | The agent’s webhookUrl. Receives all events | No |
| One call | webhooks when you start the call, choosing the events | Only if you give a secret |
curl -X POST "https://goblins-call-api.onrender.com/v1/webhooks" \
-H "Authorization: Bearer $CRACKLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/crackle",
"events": [
"call.ended"
]
}'The response is the one time the subscription’s signing secret is shown in full. Store it next to your API key.
Events
| Event | When | `data` |
|---|---|---|
call.started | A call was created and the agent sent to its room. | participantIdentity |
call.agent_joined | The agent is in the room and ready. | stt, llm, tts: the engines in use |
call.user_joined | The caller joined. | identity |
call.user_left | The caller left. | identity |
transcript.user | The caller finished saying something. | text |
transcript.assistant | The agent finished a reply. | text |
call.error | A part of the call failed, or most of a greeting or reply was in a script the call does not speak and was not spoken (stage speech: sent at most once per call, and the call carries on). | stage (stt, llm, tts, greeting or speech), message |
call.ended | The call is over. | reason, durationSeconds, callCost, transcript, and quality when reported |
Ask for the events you will use. call.ended alone is enough to store transcripts; the transcript.* events are for following a conversation live. ["*"] subscribes to everything.
What a delivery looks like
Each delivery is a POST with a JSON body. call.metadata is the metadata you sent when starting the call, so your own IDs come back with every event.
{
"id": "evt_0b6f5c1e-2a4d-4e8b-9f11-6c3d2a7e9b40",
"type": "call.ended",
"createdAt": "2026-10-11T05:59:19.000Z",
"call": {
"id": "8f1c2c1e-7b1a-4e0a-9c55-2f6f0d3b1a90",
"roomName": "call_8f1c2c1e7b1a",
"metadata": { "orderId": "A-1042", "agentId": "ua_3f9a1c2b", "userId": "usr_8c1d2e3f4a5b" }
},
"data": {
"reason": "user-left",
"durationSeconds": 184,
"callCost": 0.2147,
"userJoinedAt": "2026-10-11T05:56:15.000Z",
"transcript": [
{ "role": "assistant", "text": "Hi Divya, this is Sunrise Dental.", "at": "2026-10-11T05:56:16.000Z" },
{ "role": "user", "text": "Hi, I need to move my appointment.", "at": "2026-10-11T05:56:21.000Z" }
]
}
}X-Goblin-EventheaderThe event type, the same as type in the body.
X-Goblin-DeliveryheaderThe event’s ID, the same as id in the body. Use it to ignore a delivery you have already handled.
X-Goblin-TimestampheaderWhen the delivery was signed, in Unix seconds.
X-Goblin-Signatureheadersha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the signing secret. Sent only when there is a secret.
Checking a delivery is genuine
Your webhook URL is public, so anyone can post to it. Recompute the signature from the raw body and the timestamp, compare it with the header, and turn away deliveries whose timestamp is more than a few minutes old.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// The signature covers the exact bytes that were sent, so read the body raw.
app.post('/webhooks/crackle', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('x-goblin-timestamp');
const expected =
'sha256=' +
crypto.createHmac('sha256', process.env.CRACKLE_WEBHOOK_SECRET).update(`${timestamp}.${req.body}`).digest('hex');
const received = req.get('x-goblin-signature') || '';
const genuine =
received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
if (!genuine || !fresh) return res.sendStatus(401);
const event = JSON.parse(req.body);
if (event.type === 'call.ended') {
saveTranscript(event.call.id, event.data.transcript, event.data.durationSeconds);
}
res.sendStatus(200);
});import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/webhooks/crackle")
def crackle_webhook():
# The signature covers the exact bytes that were sent, so read the body raw.
raw = request.get_data()
timestamp = request.headers.get("X-Goblin-Timestamp", "")
expected = "sha256=" + hmac.new(
os.environ["CRACKLE_WEBHOOK_SECRET"].encode(),
timestamp.encode() + b"." + raw,
hashlib.sha256,
).hexdigest()
received = request.headers.get("X-Goblin-Signature", "")
genuine = hmac.compare_digest(received, expected)
fresh = timestamp.isdigit() and abs(time.time() - int(timestamp)) < 300
if not genuine or not fresh:
abort(401)
event = json.loads(raw)
if event["type"] == "call.ended":
save_transcript(event["call"]["id"], event["data"]["transcript"], event["data"]["durationSeconds"])
return "", 200Answering, and retries
- Answer with any
2xxstatus within 5 seconds. Do slow work after you have answered. - Anything else, or no answer, is retried after 1, 5 and 15 seconds. After the fourth attempt the delivery is dropped.
- A retry can arrive after you handled the first attempt, so make your handler safe to run twice: check
X-Goblin-Deliveryagainst what you have seen. - Events for one call can arrive out of order. Use each event’s
createdAtif order matters. - If a delivery is lost for good, nothing is: the call and its transcript are always available from Get a call.
Trying it out
Send a test event posts one signed webhook.test event to a subscription and tells you how your endpoint answered. The studio has the same button under API keys → Webhooks. Webhook URLs must be public addresses; to receive deliveries on your own machine, put a tunnel in front of it.