Short answer: there are three ways to get missed call leads into Google Sheets:
- CSV import (free, no code, not real time).
- Real-time webhook into a Google Apps Script web app that appends
from,to, timestamp andcall_sid, de-duplicating bycall_sid. Add a small relay in front if you also want the signature checked, because Apps Script cannot read request headers. - Skip the sheet and send leads to Edesy CRM, which accepts the webhook directly (free plan).
Prefer not to code it? We set it up for you on request.
Which Option to Choose
| Option | Real time | Code | Signature checked | Cost on top of rental |
|---|---|---|---|---|
| CSV export + import | No | None | n/a | None |
| Apps Script + relay | Yes | ~60 lines | Yes | ₹0.30 per event (or Unlimited) |
| Apps Script with secret in URL | Yes | ~40 lines | No (weaker) | ₹0.30 per event (or Unlimited) |
| Edesy CRM | Yes | None | Token URL | ₹0.30 per event (or Unlimited); CRM free plan |
| Done for you | Yes | None | Yes | On request |
Option 1: CSV Import (No Code)
- In the missed call portal, filter the log by number and date range.
- Click Export CSV (up to 1M rows).
- In Google Sheets: File > Import > Upload, choose "Append to current sheet".
Good for daily lists. Without a webhook, pay as you go charges only the ₹499 monthly number rental (plus GST).
Option 2: Real-Time with Google Apps Script
What the webhook sends
Each missed call produces a JSON POST like this:
{
"event": "call.missed",
"timestamp": "2026-10-20T09:42:18Z",
"data": {
"call_sid": "a1b2c3...",
"from": "+9198XXXXXXXX",
"to": "+9180XXXXXXXX",
"direction": "inbound",
"status": "missed",
"phone_number_id": "5e229f03-...",
"phone_number_name": "Hoarding MG Road"
}
}The body is signed with HMAC-SHA256 using your webhook secret, in the header
X-Edesy-Signature: sha256=<hex>. Failed deliveries are retried up to 6 times over about 15
minutes with the same call_sid. Full details are in the missed call docs.
Two Apps Script quirks you must know
- No request headers. A web app's
doPost(e)gets the body and query parameters, not headers, so it cannot readX-Edesy-Signature. - It answers with a redirect. Apps Script web apps reply to a POST with a 302 redirect to
script.googleusercontent.com. Edesy's webhook sender refuses redirects in general (a redirect could point at an internal address) but follows this one Apps Script hop, so pointing the webhook straight at the/execURL shows up as delivered. A shortened or custom-domain link to the script redirects somewhere else first and is not followed, so always paste the/execURL.
To verify the signature as well, put a tiny relay in front that checks it, forwards the event to Apps Script, and returns 200 to Edesy. A free serverless worker is enough.
Step 1: Create the sheet and script
- Create a Google Sheet with a tab named
Leadsand headers in row 1:received_at | occurred_at | from | to | number_name | call_sid - Open Extensions > Apps Script and paste:
const SHEET_NAME = 'Leads';
const SHARED_KEY = 'replace-with-a-long-random-string';
function doPost(e) {
if (!e || !e.parameter || e.parameter.key !== SHARED_KEY) {
return json({ ok: false, error: 'unauthorised' });
}
let body;
try {
body = JSON.parse(e.postData.contents);
} catch (err) {
return json({ ok: false, error: 'bad json' });
}
if (body.event !== 'call.missed' || !body.data) {
return json({ ok: true, ignored: true });
}
const d = body.data;
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
const seen = sheet.getRange('F:F')
.createTextFinder(String(d.call_sid))
.matchEntireCell(true)
.findNext();
if (seen) return json({ ok: true, duplicate: true });
sheet.appendRow([
new Date(),
body.timestamp || '',
d.from || '',
d.to || '',
d.phone_number_name || '',
d.call_sid || '',
]);
} finally {
lock.releaseLock();
}
return json({ ok: true });
}
function json(obj) {
return ContentService.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}The lock stops two simultaneous calls writing at once, and the call_sid lookup makes retries and
replays safe.
Step 2: Deploy as a web app
- Click Deploy > New deployment, type Web app.
- Execute as Me; who has access: Anyone.
- Authorise, then copy the web app URL (ending in
/exec). - Your forward URL is
<web app URL>?key=<SHARED_KEY>.
If you edit the script later, deploy a new version of the same deployment so the URL stays the same.
Step 3: Add a signature-checking relay
This example runs on any serverless worker that supports the standard Fetch and Web Crypto APIs
(for example a free Cloudflare Worker). Set two secrets: EDESY_SECRET (your webhook signing
secret) and SHEET_URL (the forward URL from step 2).
export default {
async fetch(request, env) {
if (request.method !== 'POST') return new Response('ok');
const raw = await request.text();
const expected = await sign(env.EDESY_SECRET, raw);
const got = request.headers.get('X-Edesy-Signature') || '';
if (!timingSafeEqual(expected, got)) {
return new Response('bad signature', { status: 401 });
}
const res = await fetch(env.SHEET_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: raw,
redirect: 'follow',
});
return new Response(res.ok ? 'ok' : 'sheet error', { status: res.ok ? 200 : 502 });
},
};
async function sign(secret, body) {
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
return 'sha256=' + hex;
}
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}The relay follows Apps Script's redirect itself and returns a clean 200 to Edesy, so the delivery log shows success. If the sheet is unreachable it returns 502 and Edesy retries.
Check your signature code against a sample payload with the free webhook signature verifier.
Step 4: Connect it in the missed call portal
- Open Webhooks and add the relay URL.
- Subscribe to
call.missedand set the signing secret (the same value asEDESY_SECRET). - Click Send test event and check the sheet.
- Dial your missed call number and confirm a real row appears.
The weaker shortcut: secret in the URL only
You can skip the relay and rely only on the ?key= query parameter. It keeps casual traffic out,
but the key travels in the URL and may appear in logs, and nothing proves the event came from
Edesy. Use it only for low-stakes tests or internal sheets.
Option 3: Use a CRM Instead
Sheets work well for one person and a few hundred leads. They get awkward when several people follow up, when you need status and notes, or when rows run into thousands.
Edesy CRM accepts the missed call webhook directly:
- Add a Custom webhook lead source in the CRM and copy its URL.
- Paste it into the missed call portal's webhooks page.
- After the first event, map
data.fromto the phone field (a one-time step).
Every missed call then becomes a lead you can assign and track, on the free CRM plan. For Salesforce, Zoho, HubSpot or LeadSquared, we connect it for you, usually within a few days, using our webhook and API. See missed call CRM integration.
Cost
| Volume | Plan | Monthly before GST |
|---|---|---|
| CSV only, any volume | Pay as you go | ₹499 |
| 1,000 webhook events | Pay as you go | ₹799 |
| 5,000 webhook events | Unlimited | ₹1,498 |
Add 18% GST. A one-time setup fee may apply per number. Above about 3,330 webhook events per number a month, Unlimited is cheaper. See missed call service pricing in India.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Delivery log shows 302 | Webhook URL is a short or custom-domain link, not the /exec URL |
Paste the script.google.com/.../exec URL from Deploy |
| Relay returns 401 | Secret mismatch or body modified | Use the raw body; check the secret |
| Duplicate rows | call_sid column moved |
Keep call_sid in column F or update the range |
| No rows, relay 200 | Wrong key in SHEET_URL |
Copy the key exactly |
| Rows stop after edits | New deployment URL | Redeploy the same deployment |
We Can Set It Up for You
If you would rather not touch code, tell us your sheet and we will set up the integration for your account quickly.