← Back to All Articles

Retell AI Webhook Integration: Exporting Data & Fixing Common Issues

By Manpreet Kaur ; Sukhchain Singh preetdhaliwal1112001@gmail.com Posted on 29 Sep 2025
Area of Article:
AI applications

Production-ready setup + test scripts + fix for the most common errors




Contents



  1. What is a webhook from Retell AI?

  2. Typical payloads (request body & headers)

  3. Create your webhook endpoint (PHP & Node)

  4. Configure Retell AI to POST JSON to your URL

  5. Test with curl / PowerShell / Postman

  6. Store calls safely (dedupe keys & SQL)

  7. Troubleshooting guide

  8. 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.0



JSON 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



  1. Open your Retell AI agent / custom function settings.

  2. Set Method to POST.

  3. Set API Endpoint to your HTTPS URL, e.g. https://example.com/webhooks/retell.php.

  4. Headers:

    • Content-Type: application/json

    • X-Webhook-Secret:



  5. Payload/body: JSON. If the UI has “args only / JSON schema”, keep it simple: ensure Retell sends a JSON object (not form-encoded).

  6. 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



  1. Method: POST, URL: your endpoint.

  2. Headers: Content-Type: application/json, X-Webhook-Secret with your secret.

  3. Body: rawJSON → 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



  1. Hit the URL in browser: you should see 405 JSON telling you to use POST. That means the script loads.

  2. Send a POST with correct headers & minimal JSON using Postman.

  3. Check your server access/error logs and any custom retell.log.

  4. Only then trigger from Retell and verify you receive the live payload.




8) Go-live checklist



  • ✅ HTTPS endpoint reachable publicly.

  • ✅ POST + Content-Type: application/json confirmed.

  • ✅ 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:false and 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.