Both webhooks

Install DotSIP, open the web admin, connect real SIP, and decide each call from your own HTTP API.

On answer, DotSIP notifies your CRM and does not wait. After the caller presses a digit, DotSIP waits, your server looks up the announcement for that digit, and DotSIP plays it.

1. Records in DotSIP

  1. Put menu.wav and choice.wav in data/media. Mono PCM16, 8000, 16000, or 24000 Hz.
  2. Under SIP accounts, the trunk that receives the call. Set its inbound route to route-tools, or leave that field empty so the call uses route-tools.
  3. route-tools URL http://127.0.0.1:9099/route. The hook and the digit callback are paths on that same process.

2. Your database

CREATE TABLE crm_log (call_id TEXT PRIMARY KEY, event TEXT NOT NULL, caller TEXT);
CREATE TABLE digit_media (digit TEXT PRIMARY KEY, media TEXT NOT NULL);
INSERT INTO digit_media(digit, media) VALUES ('1', 'choice.wav');

3. The plan

hook wait false posts dotsip.hook and ignores the body. The call goes on to the recording. gather wait true posts dotsip.dtmf and runs the plan you return.

{
  "format": "dotsip.hook",
  "format_version": 1,
  "call_id": "c-7f3a",
  "event": "answered",
  "caller": { "number": "+421901234567" }
}
{
  "format": "dotsip.dtmf",
  "format_version": 1,
  "call_id": "c-7f3a",
  "digits": "1",
  "timeout": false,
  "caller": { "number": "+421901234567" }
}

4. The server

<?php
$db = new PDO("sqlite:" . __DIR__ . "/operator.sqlite");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec("CREATE TABLE IF NOT EXISTS crm_log (call_id TEXT PRIMARY KEY, event TEXT NOT NULL, caller TEXT)");
$db->exec("CREATE TABLE IF NOT EXISTS digit_media (digit TEXT PRIMARY KEY, media TEXT NOT NULL)");
$db->exec("INSERT OR IGNORE INTO digit_media(digit, media) VALUES ('1', 'choice.wav')");
$body = json_decode(file_get_contents("php://input"), true);
header("Content-Type: application/json");
$format = is_array($body) && isset($body["format"]) ? $body["format"] : "";
if ($format === "dotsip.route") {
    echo json_encode(array("format" => "dotsip.route-result", "format_version" => 2, "commands" => array(
        array("do" => "answer"),
        array("do" => "hook", "url" => "http://127.0.0.1:9099/crm", "event" => "answered", "wait" => false),
        array("do" => "play", "media" => "menu.wav", "barge_in" => false),
        array("do" => "gather", "max_digits" => 1, "timeout_ms" => 8000, "callback_url" => "http://127.0.0.1:9099/digit", "wait" => true),
    )));
    return;
}
if ($format === "dotsip.hook") {
    $stmt = $db->prepare("INSERT OR REPLACE INTO crm_log(call_id, event, caller) VALUES (?, ?, ?)");
    $caller = isset($body["caller"]["number"]) ? $body["caller"]["number"] : "";
    $stmt->execute(array(isset($body["call_id"]) ? $body["call_id"] : "", isset($body["event"]) ? $body["event"] : "", $caller));
    echo "{}";
    return;
}
if ($format === "dotsip.dtmf") {
    $digit = isset($body["digits"]) ? (string) $body["digits"] : "";
    $media = $db->query("SELECT media FROM digit_media WHERE digit = " . $db->quote($digit))->fetchColumn();
    if (!$media) {
        echo json_encode(array("commands" => array(array("do" => "hangup"))));
        return;
    }
    echo json_encode(array("format" => "dotsip.route-result", "format_version" => 2, "commands" => array(
        array("do" => "play", "media" => $media, "barge_in" => false),
        array("do" => "hangup"),
    )));
    return;
}
http_response_code(400);
echo json_encode(array("error" => "bad request"));

Press 1. The server finds choice.wav for digit 1 and DotSIP plays it, then hangs up. The CRM row is written even though that reply is ignored. A digit with no row hangs up.