1️⃣ Your First Call with Data

Learn how to build your first Callr Actions scenario and receive call metadata on your local machine using ngrok.

Pre-requisites

  1. A Callr Account. You can request one here.
  2. ngrok installed on your machine.
  3. A working development environment (we'll provide examples in Python, PHP, Node and Go).

📘

What is ngrok?

It's a tool that exposes your local server to the internet via a secure, temporary URL.

👉

Using Windows?

We recommend WSL (Windows Subsystem for Linux) for the best experience.


Step 1 – Create a local server

Run a lightweight HTTP server that prints the data it receives. Below are examples in multiple languages — choose the one that matches your environment.

<?php
// run with php -S localhost:8888 server.php

header('Content-Type: application/json');

$data = file_get_contents('php://input');

if (!empty($data)) {
    // pretty print the json data
    error_log(json_encode(json_decode($data), JSON_PRETTY_PRINT));
} else {
    error_log('No data received');
}

// JSON response
echo json_encode(['success' => true]);
# server.py
# Run with: python server.py

from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class SimpleHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        content_length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(content_length)

        try:
            data = json.loads(body)
            pretty = json.dumps(data, indent=2)
            print(pretty)
        except json.JSONDecodeError:
            print("Invalid JSON received")

        self.send_response(200)
        self.send_header('Content-Type', 'application/json')
        self.end_headers()
        self.wfile.write(b'{"success": true}')

    def log_message(self, format, *args):
        return  # Silence default logging

if __name__ == '__main__':
    port = 8888
    print(f"Listening on http://localhost:{port}")
    server = HTTPServer(('0.0.0.0', port), SimpleHandler)
    server.serve_forever()
// Run with: node server.js

const http = require('http');

const server = http.createServer((req, res) => {
  if (req.method === 'POST') {
    let body = '';
    req.on('data', chunk => { body += chunk; });
    req.on('end', () => {
      try {
        const json = JSON.parse(body);
        console.log(JSON.stringify(json, null, 2));
      } catch (e) {
        console.error('Invalid JSON');
      }
      
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ success: true }));
    });
  } else {
    res.writeHead(405);
    res.end('Method not allowed');
  }
});

server.listen(8888, () => {
  console.log('Server listening on http://localhost:8888');
});
// Run with: go run main.go

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed)
		return
	}

	body, err := io.ReadAll(r.Body)
	if err != nil {
		log.Println("Error reading body:", err)
		return
	}

	var payload map[string]any
	if err := json.Unmarshal(body, &payload); err != nil {
		log.Println("Invalid JSON:", err)
	} else {
		pretty, _ := json.MarshalIndent(payload, "", "  ")
		fmt.Println(string(pretty))
	}
  
	w.Header().Set("Content-Type", "application/json")
	w.Write([]byte(`{"success": true}`))
}

func main() {
	http.HandleFunc("/", handler)
	fmt.Println("Listening on http://localhost:8888")
	log.Fatal(http.ListenAndServe(":8888", nil))
}

Step 2 – Start Your Server and Tunnel with ngrok

In two terminals:

# Terminal 1
php -S localhost:8888 server.php

# Terminal 2
ngrok http 8888

You’ll see output like this:

Session Status  online
[...]
Web Interface   http://127.0.0.1:4040  
Forwarding      https://7ca2-[...].ngrok-free.app -> http://localhost:8888     

Take note of the Forwarding URL (https://7ca2-[...].ngrok-free.app) - you will use it in your call flow.

💡

Tip

Visit http://localhost:4040 to inspect live HTTP requests.


Step 3 – Create your first Call Flow

  1. Go to Callr Actions.
  2. Click CREATE (top right).
  3. Choose the Blank template.

Paste the following YAML and replace $url with your ngrok forwarding URL:

description: My first call with Callr

defaults:
  language: en-US

variables:
  $url: https://<your-ngrok-forwarding-url> # replace this

compat:
  version: "2025-09-01"

branches:
  inbound-call:
    actions:
      - action: say@v2
        params:
          what: Welcome to my first call flow!

      - action: say@v2
        params:
          what: I will hangup now!

      - action: hangup@v1

  hangup:
    actions:
      - action: fetch@v2
        params:
          url: ${{ $url }}
          method: POST
          headers:
            content-type: application/json
          body: |
            {
              "run": ${{ JSON.stringify(run) }},
              "call": ${{ JSON.stringify(call) }},
              "scenario": ${{ JSON.stringify(scenario) }}
            }

This call flow greets the caller, hangs up, and then sends the call metadata to your server.

Click SAVE or press CTRL+S.


Step 4 – Assign a phone number

  • Click the Test tab (on the right sidebar). You will get this screen:
  • If you are in Build Mode, click ASSIGN TEMPORARY NUMBER.
  • If you are in production: close the editor, assign a phone number to the scenario, and go back to the Test tab.

You’ll see a number you can call for testing.

Check Intercept next call & open logs to activate the live debugger.


Step 5 – 📞 Call the phone number

Call the number from your phone.

The log window in the editor will display:

  • ▶️ Variables available at each step (updated ones shown in bold).
  • ▶️ Input parameters to each action.
  • ▶️ Output of each action.

Step 6 – View the Metadata

In Terminal 1, you should see a JSON payload like this:

[Thu Mar 27 15:06:09 2025] PHP 8.2.28 Development Server (http://localhost:8888) started
[Thu Mar 27 15:12:39 2025] 127.0.0.1:51676 Accepted
[Thu Mar 27 15:12:40 2025] {
  "run": {
    "id": "inbound-call:242218357889",
    "type": "inbound-call"
  },
  "call": {
    "cdr": {
      "billingCredit": "0.0000",
      "billingDebit": "0.8925",
      "billingDestinationLabel": "FR-GEOGRAPHIC-CLASSIC",
      "billingPaymentType": "prepaid",
      "callee": "+33939143726",
      "callerName": "",
      "callerNumber": "+33639980042",
      "callerRestricted": false,
      "callid": 242218357889,
      "direction": "inbound",
      "durationAnswered": 4,
      "durationBilled": 60,
      "durationTotal": 5,
      "started": "2025-03-29T17:55:57.093Z",
      "answered": "2025-03-29T17:55:58.373Z",
      "ended": "2025-03-29T17:56:01.873Z",
      "hangupCause": 16,
      "hangupSource": "callee",
      "hangupTech": "sip:200",
      "scenarioName": "Blank",
      "scenarioSid": "zy6ubxu0",
      "scenarioTypeName": "Actions",
      "scenarioTypeVersion": "1.0",
      "status": "answered"
    },
    "direction": "inbound",
    "fromCountry": "FR",
    "fromLocation": "France",
    "fromNumber": "+33639980042",
    "fromType": "MOBILE",
    "id": 242359184129,
    "leg": "A",
    "startedAt": "2025-03-29T17:55:57.093Z",
    "status": "hangup",
    "toCountry": "FR",
    "toLocation": "France",
    "toNumber": "+33939143726",
    "toType": "VOIP"
  },
  "scenario": {
    "name": "Blank",
    "sid": "zy6ubxu0"
  }
}
[Thu Mar 27 15:12:40 2025] 127.0.0.1:51676 Closing

This matches what you defined in the fetch@v2 action — an object with three keys:

  • run
  • call
  • scenario

These are pre-defined variables in Callr Actions.


🎊

Congratulations!

You’ve just built your first Callr call flow and received live call metadata on your local server.