Cookbooks · Running it
Save every call to your database
Every finished call written to a table of your own: who it was with, how long it ran, what it cost and what was said. A webhook does the saving as calls end, and a short script fills in anything the webhook missed.
Before you start
- An agent, and its ID. The Quickstart makes one.
- A workspace API key in the
CRACKLE_API_KEYenvironment variable. See Authentication. - Node.js 18 or later, with
"type": "module"inpackage.json, andnpm install express pg. - A Postgres database, with its address in
DATABASE_URL.
Make the table
schema.sqlCREATE TABLE calls ( id text PRIMARY KEY, -- the call's ID agent_id text, end_reason text, duration_s integer, cost_usd numeric(12, 4), metadata jsonb, -- what you sent when starting the call transcript jsonb, -- [{ role, text, at }] ended_at timestamptz );The call’s ID is the primary key. That one decision makes everything below safe to run more than once.
Write the one function that saves a call
Both the webhook and the catch-up script go through it, so a call looks the same whichever route it arrived by.
save-call.jsimport pg from 'pg'; export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL }); // Storing a call twice does nothing the second time, so a repeated delivery is harmless. export async function saveCall(call) { await db.query( `INSERT INTO calls (id, agent_id, end_reason, duration_s, cost_usd, metadata, transcript, ended_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO NOTHING`, [ call.id, call.metadata?.agentId ?? null, call.endReason, call.durationSeconds, call.callCost, call.metadata ?? {}, JSON.stringify(call.transcript ?? []), call.endedAt, ], ); }Receive call.ended
The receiver checks the signature against the raw body, saves the call, and only then answers
200. If saving fails it answers500, and the delivery is tried again.webhook.jsimport crypto from 'node:crypto'; import express from 'express'; import { saveCall } from './save-call.js'; const app = express(); function genuine(req) { 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') || ''; return ( received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)) && Math.abs(Date.now() / 1000 - Number(timestamp)) < 300 ); } // express.raw keeps the body as the exact bytes that were signed. app.post('/webhooks/crackle', express.raw({ type: 'application/json' }), async (req, res) => { if (!genuine(req)) return res.sendStatus(401); const event = JSON.parse(req.body); try { if (event.type === 'call.ended') { await saveCall({ id: event.call.id, metadata: event.call.metadata, endReason: event.data.reason, durationSeconds: event.data.durationSeconds, callCost: event.data.callCost, transcript: event.data.transcript, endedAt: event.createdAt, }); } res.sendStatus(200); } catch (error) { console.error('Could not save call', event.call?.id, error); res.sendStatus(500); // anything but a 2xx is delivered again } }); app.listen(3000);Subscribe
Register the receiver’s public address for
call.ended. The response carries the signing secret, once; put it inCRACKLE_WEBHOOK_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" ] }'Catch up on what was missed
A delivery is tried four times within a minute and then dropped, so a deploy or an outage on your side can lose some. The calls themselves are not lost: this script walks back through every ended call and saves the ones you do not have. Run it once now to load your history, and again whenever your receiver has been down.
backfill.jsimport { db, saveCall } from './save-call.js'; const headers = { Authorization: `Bearer ${process.env.CRACKLE_API_KEY}` }; let before = null; let seen = 0; do { const url = new URL('https://goblins-call-api.onrender.com/v1/calls'); url.searchParams.set('status', 'ended'); url.searchParams.set('limit', '100'); if (before) url.searchParams.set('before', before); const response = await fetch(url, { headers }); if (!response.ok) throw new Error(await response.text()); const { calls, nextCursor } = await response.json(); for (const call of calls) await saveCall(call); seen += calls.length; before = nextCursor; } while (before); console.log(`Checked ${seen} calls.`); await db.end();
Try it
Send a test event to check your receiver answers and the signature passes; a test event is not a call, so nothing is saved. Then make a real call, hang up, and look in the table:
SELECT id, end_reason, duration_s, cost_usd, jsonb_array_length(transcript) AS turns
FROM calls ORDER BY ended_at DESC LIMIT 5;Making it yours
- Put your own IDs in
metadatawhen you start a call. They come back incall.metadata, so each row can point at your customer or order. - A call nobody joined ends with the reason
no-userand an empty transcript. Skip those insaveCallif they are noise to you. - To follow a call while it is happening, subscribe to
transcript.userandtranscript.assistantas well and store each line as it arrives.