Production-ready setup + test scripts + fix for the most common errors
Contents
- What is a webhook from Retell AI?
- Typical payloads (request body & headers)
- Create your webhook endpoint (PHP & Node)
- Configure Retell AI to POST JSON to your URL
- Test with curl / PowerShell / Postman
- Store calls safely (dedupe keys & SQL)
- Troubleshooting guide
- Go-live checklist
1) What is a webhook from Retell AI?
Retell AI can send HTTP POST requests to your server when a call starts, ends, or when your custom function runs. Your server receives a JSON payload and returns 200 OK. Think of it as “Retell AI → your URL with JSON”.
Tip: Use a secret header (e.g., X-Webhook-Secret) so only Retell can call your endpoint.
2) Typical payloads (request body & headers)
Headers
Content-Type: application/json
X-Webhook-Secret: YOUR_SHARED_SECRET
User-Agent: RetellAI/1.0JSON body (example)
{
"event": "postcall.summary",
"timestamp": "2025-09-29T03:25:00Z",
"call_id": "call_abc123",
"agent_id": "agent_icyspicy",
"customer": {"phone":"+61xxxxxxxxx","name":"John"},
"summary": "Customer asked about order status and address.",
"sentiment": "neutral",
"entities": {"order_id":"AUS-3921"},
"dedupe_key": "c1b4e2...64hex",
"meta": {"source":"retell","version":"1"}
}Schema: Treat unknown fields as optional. Log the payload before parsing.
3) Create your webhook endpoint
3.1 PHP (cPanel-friendly)
// /public_html/webhooks/retell.php
declare(strict_types=1);
// CONFIG
$SECRET = getenv('RETELL_WEBHOOK_SECRET') ?: 'change_me';
// Only POST with JSON
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
echo json_encode(['ok' => false, 'error' => 'Method not allowed. Use POST with JSON.']);
exit;
}
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (stripos($contentType, 'application/json') === false) {
http_response_code(415);
echo json_encode(['ok' => false, 'error' => 'Unsupported Media Type. Expect application/json']);
exit;
}
// Verify secret header
$got = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '';
if (!hash_equals($SECRET, $got)) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'Unauthorised']);
exit;
}
// Read raw JSON
$raw = file_get_contents('php://input');
$payload = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid JSON']);
exit;
}
// Basic logging (rotate this file or use DB)
$logFile = __DIR__ . '/retell.log';
file_put_contents($logFile, date('c')." ".$raw.PHP_EOL, FILE_APPEND);
// Respond quickly to Retell
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'note' => 'caught']);
// Async processing (optional)
fastcgi_finish_request(); // cPanel often supports this
// Example: insert into database (PDO)
try {
$pdo = new PDO('mysql:host=localhost;dbname=prod;charset=utf8mb4','dbuser','dbpass',[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
$stmt = $pdo->prepare('INSERT INTO retell_events
(event, call_id, agent_id, dedupe_key, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, NOW())');
$stmt->execute([
$payload['event'] ?? null,
$payload['call_id'] ?? null,
$payload['agent_id'] ?? null,
$payload['dedupe_key'] ?? hash('sha256', $raw),
$raw
]);
} catch (Throwable $e) {
error_log('DB insert failed: '.$e->getMessage());
}
?>3.2 Node.js (Express)
import express from "express";
const app = express();
const SECRET = process.env.RETELL_WEBHOOK_SECRET || "change_me";
app.post("/webhooks/retell", express.json(), (req, res) => {
if ((req.headers["x-webhook-secret"] || "") !== SECRET) {
return res.status(401).json({ ok:false, error:"Unauthorised" });
}
// Do your work here (queue, DB, etc.)
console.log("Retell payload:", req.body);
return res.json({ ok:true, note:"caught" });
});
// Guard: reject non-POST
app.all("/webhooks/retell", (_req,res) => {
res.set("Allow", "POST");
res.status(405).json({ ok:false, error:"Method not allowed. Use POST with JSON." });
});
app.listen(3000, () => console.log("Listening on :3000"));4) Configure Retell AI to POST JSON to your URL
- Open your Retell AI agent / custom function settings.
- Set Method to
POST. - Set API Endpoint to your HTTPS URL, e.g.
https://example.com/webhooks/retell.php. - Headers:
Content-Type: application/jsonX-Webhook-Secret:
- Payload/body: JSON. If the UI has “args only / JSON schema”, keep it simple: ensure Retell sends a JSON object (not form-encoded).
- Save and run a test call to trigger the webhook.
If you previously saw Method not allowed. Use POST with JSON.common — your endpoint rejected GET or wrong content-type. Switch to POST and set Content-Type: application/json.
5) Test your endpoint (before wiring Retell)
5.1 Linux / macOS (curl)
curl -X POST "https://example.com/webhooks/retell.php" \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: change_me" \
-d '{"event":"self.test","timestamp":"2025-09-29T03:25:00Z","message":"Hello"}'5.2 Windows PowerShell (important!)
Gotcha PowerShell’s curl is an alias for Invoke-WebRequest. The -d flag from Unix curl will fail with CommandNotFoundException. Use:
Invoke-WebRequest -Method POST `
-Uri "https://example.com/webhooks/retell.php" `
-Headers @{'Content-Type'='application/json';'X-Webhook-Secret'='change_me'} `
-Body '{"event":"self.test","timestamp":"2025-09-29T03:25:00Z","message":"Hello"}'5.3 Postman
- Method: POST, URL: your endpoint.
- Headers:
Content-Type: application/json,X-Webhook-Secretwith your secret. - Body: raw → JSON → paste sample payload.
6) Store calls safely (idempotency & dedupe)
Use a dedupe_key to avoid duplicate inserts. If Retell retries (network hiccup), your DB won’t double-count.
6.1 MySQL table
CREATE TABLE IF NOT EXISTS retell_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
event VARCHAR(64) NULL,
call_id VARCHAR(64) NULL,
agent_id VARCHAR(64) NULL,
dedupe_key CHAR(64) NULL,
payload_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);6.2 Unique index (portable approach)
Older MySQL versions don’t support ADD COLUMN IF NOT EXISTS inside a single ALTER TABLE. Run statements separately:
-- Add column if you don't have it yet (will error if exists; safe to skip on repeat)
ALTER TABLE retell_events ADD COLUMN dedupe_key CHAR(64) NULL;
-- Create unique index (will error if it exists; for idempotent deploys you can ignore 1061)
CREATE UNIQUE INDEX uk_retell_dedupe ON retell_events (dedupe_key);Application logic: if insert fails with duplicate key on dedupe_key, treat as success (already processed).
7) Troubleshooting guide
| Symptom | Likely Cause | Fix |
|---|---|---|
405 Method not allowed. Use POST with JSON. common | Retell (or you) called with GET or wrong content-type. | Set method to POST and header Content-Type: application/json in Retell settings. |
| HTTP 415 Unsupported Media Type | Body sent as form-urlencoded or multipart. | Switch to raw JSON body. |
| HTTP 401 Unauthorised | Missing/incorrect X-Webhook-Secret. | Add header in Retell: X-Webhook-Secret with the same value your server checks. |
| No logs appear in your file/DB | File permissions or DB credentials wrong; code exits before logging. | Log the raw body before DB insert; verify PHP error_log; check DB user/password/host. |
Windows -d flag error in PowerShell | PowerShell’s curl ≠ Unix curl. | Use Invoke-WebRequest example above. |
| Duplicate rows for same call | Retried webhook, no idempotency. | Use dedupe_key and a unique index; on duplicate error, treat as success. |
| cPanel PHP returns blank page | Fatal error with display_errors off. | Check error_log, ensure PHP >= 7.4, remove short tags, and set declare(strict_types=1) safely. |
| HTTP 403 / 404 from your server | Incorrect path or security plugin blocking. | Confirm exact URL, whitelist Retell UA/IP (if using WAF), disable “block empty referrer”. |
MySQL #1064 near IF NOT EXISTS | Older MySQL syntax. | Run ALTER TABLE ... ADD COLUMN ... and CREATE UNIQUE INDEX ... as separate statements (see above). |
Quick sanity steps
- Hit the URL in browser: you should see 405 JSON telling you to use POST. That means the script loads.
- Send a POST with correct headers & minimal JSON using Postman.
- Check your server access/error logs and any custom
retell.log. - Only then trigger from Retell and verify you receive the live payload.
8) Go-live checklist
- ✅ HTTPS endpoint reachable publicly.
- ✅ POST +
Content-Type: application/jsonconfirmed. - ✅ Secret header validated server-side.
- ✅ Logging in place (file or DB) before deeper processing.
- ✅ Dedupe key + unique index to avoid double inserts.
- ✅ Timeouts < 3s (do heavy work async, ack immediately).
- ✅ Error handling returns JSON with
ok:falseand useful message (but no secrets).
Copy-paste snippets
Minimal JSON to send
{"event":"self.test","timestamp":"2025-09-29T03:25:00Z","message":"Hello from Retell test"}Expected 200 OK response
{"ok":true,"note":"caught"}If your endpoint must be silent, still return 200 OK with a tiny JSON body to avoid retries.