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_KEY environment variable. See Authentication.
  • Node.js 18 or later, with "type": "module" in package.json, and npm install express pg.
  • A Postgres database, with its address in DATABASE_URL.
  1. Make the table

    schema.sql
    CREATE 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.

  2. 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.js
    import 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,
        ],
      );
    }
  3. Receive call.ended

    The receiver checks the signature against the raw body, saves the call, and only then answers 200. If saving fails it answers 500, and the delivery is tried again.

    webhook.js
    import 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);
  4. Subscribe

    Register the receiver’s public address for call.ended. The response carries the signing secret, once; put it in CRACKLE_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"
      ]
    }'
  5. 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.js
    import { 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:

psql
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 metadata when you start a call. They come back in call.metadata, so each row can point at your customer or order.
  • A call nobody joined ends with the reason no-user and an empty transcript. Skip those in saveCall if they are noise to you.
  • To follow a call while it is happening, subscribe to transcript.user and transcript.assistant as well and store each line as it arrives.