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

ForSet it withSigned
Every call in the workspaceCreate a subscription (POST /v1/webhooks), choosing the eventsYes
Every call of one agentThe agent’s webhookUrl. Receives all eventsNo
One callwebhooks when you start the call, choosing the eventsOnly 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

EventWhen`data`
call.startedA call was created and the agent sent to its room.participantIdentity
call.agent_joinedThe agent is in the room and ready.stt, llm, tts: the engines in use
call.user_joinedThe caller joined.identity
call.user_leftThe caller left.identity
transcript.userThe caller finished saying something.text
transcript.assistantThe agent finished a reply.text
call.errorA 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.endedThe 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.

call.ended
{
  "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-Eventheader

The event type, the same as type in the body.

X-Goblin-Deliveryheader

The event’s ID, the same as id in the body. Use it to ignore a delivery you have already handled.

X-Goblin-Timestampheader

When the delivery was signed, in Unix seconds.

X-Goblin-Signatureheader

sha256= 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.

Node.js · Express
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);
});
Python · Flask
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 "", 200

Answering, and retries

  • Answer with any 2xx status 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-Delivery against what you have seen.
  • Events for one call can arrive out of order. Use each event’s createdAt if 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.