Short answer: treat a call.missed webhook like a payment webhook. Read the raw body, verify the
X-Edesy-Signature HMAC with a constant-time compare, return 2xx within a second or two, push the
event onto a queue, and make your handler idempotent on data.call_sid. Retries and replays will send
the same event again; that is by design.
New to the product? Get a Missed Call Number and use the "send test event" button before you go live.
The Event
When a caller dials a number routed to missed call, the call is rejected with a busy tone and an event is posted to every active webhook subscription for that event type:
{
"event": "call.missed",
"timestamp": "2026-10-20T09:41:07Z",
"data": {
"call_sid": "a1b2c3d4-...",
"from": "+919876543210",
"to": "+918045xxxxxx",
"direction": "inbound",
"phone_number_id": "f0e1...",
"phone_number_name": "Diwali print ad"
}
}| Field | Use it for |
|---|---|
data.from |
The lead's number, normalised to +91 E.164 |
data.to |
Which of your numbers was called |
data.phone_number_name |
The label you gave the number: campaign, ad or source |
data.call_sid |
Unique per call; your idempotency key |
timestamp |
When the event was created (UTC, RFC 3339) |
Headers include Content-Type: application/json and, when you have set a secret,
X-Edesy-Signature: sha256=<hex>. The full reference is in the
missed call docs and on the API page.
Rule 1: Verify the Signature Over the Raw Body
The signature is sha256= followed by the hex HMAC-SHA256 of the exact bytes we sent, keyed with
your webhook secret. The most common bug is verifying a re-serialised body: your framework parses the
JSON, you call JSON.stringify on it, key order or whitespace changes, and every signature fails.
Always capture the raw body before any JSON middleware touches it, and compare with a constant-time function so the check does not leak timing information.
Node.js (Express)
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.EDESY_WEBHOOK_SECRET;
function validSignature(rawBody, header) {
if (!header) return false;
const expected =
"sha256=" + crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
"/webhooks/missed-call",
express.raw({ type: "application/json" }), // keep the raw bytes
async (req, res) => {
if (!validSignature(req.body, req.get("X-Edesy-Signature"))) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
await queue.add("missed-call", event); // your job queue
res.status(200).end(); // acknowledge fast
}
);Python (Flask)
import hmac, hashlib, os, json
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["EDESY_WEBHOOK_SECRET"].encode()
def valid_signature(raw: bytes, header: str | None) -> bool:
if not header:
return False
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
@app.post("/webhooks/missed-call")
def missed_call():
raw = request.get_data() # raw bytes, before parsing
if not valid_signature(raw, request.headers.get("X-Edesy-Signature")):
abort(401)
event = json.loads(raw)
enqueue("missed-call", event) # Celery, RQ, SQS, etc.
return "", 200To debug a mismatch, paste a payload, secret and header into the webhook signature verifier. It runs in your browser.
Keep the secret in an environment variable or secret manager, never in source control. If it leaks, rotate it in the portal and deploy the new value.
Rule 2: Return 2xx Fast, Then Queue
Each delivery attempt has a short timeout of a few seconds. Anything that is not a 2xx within that
time counts as a failure and is retried. That leads to a simple design:
| Do in the request | Do in a background job |
|---|---|
| Read raw body | Write the lead to the CRM |
| Verify signature | Look up the caller in your database |
Basic shape check (event, data.call_sid) |
Trigger a callback or notify sales |
| Insert into queue or an inbox table | Call third-party APIs |
Return 200 |
Retry your own downstream failures |
If you write straight to a slow CRM inside the request and the CRM takes eight seconds, the delivery times out, we retry, and your handler runs twice. Queue first.
The simplest durable queue is an inbox table: insert the event with call_sid as a unique key and
return. A worker reads unprocessed rows. That gives you idempotency and a replayable record in one step.
Rule 3: Understand the Retry Schedule
| Attempt | Delay after previous failure |
|---|---|
| 1 | Immediate |
| 2 | 30 s |
| 3 | 1 min |
| 4 | 2 min |
| 5 | 4 min |
| 6 | 8 min |
That is 6 attempts over roughly 15 minutes. After the last failure the delivery is marked failed, it appears in the delivery log, and you are not charged for an event that was never delivered. The lead itself is not lost: it is still in the missed call log and the CSV export.
Practical implications:
- Deploys are safe as long as downtime is a few minutes. Retries cover it.
- Every non-2xx is retried, including 4xx, until attempts run out. A handler that returns 500 because of a bug will be called six times, which is another reason to make handlers idempotent. Return 401 for a bad signature anyway: it shows up clearly in the delivery log.
- Do not return 200 before you have durably stored the event. If you ack and then crash before enqueueing, the event is gone from your side and we will not retry it.
Rule 4: Dedupe on call_sid
You can receive the same missed call more than once:
- A retry after a timeout where your server actually processed the first request.
- A manual or bulk replay from the delivery log.
- Your own worker retrying after a partial failure.
Every retry and replay carries the same data.call_sid. Use it as the idempotency key:
CREATE TABLE missed_call_inbox (
call_sid TEXT PRIMARY KEY,
caller TEXT NOT NULL,
number_name TEXT,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
INSERT INTO missed_call_inbox (call_sid, caller, number_name)
VALUES ($1, $2, $3)
ON CONFLICT (call_sid) DO NOTHING;Note the difference between duplicate deliveries and repeat callers. If the same person gives
three missed calls, that is three calls with three different call_sid values. Whether to treat that
as one lead is a business decision: most CRMs match on phone number and attach new calls to the
existing contact. The dashboard reports unique callers separately from total calls. A configurable
repeat-caller window on our side is available on request, set up for your account.
Rule 5: Use Replay Instead of Custom Recovery Scripts
When something breaks downstream (a CRM credential expired, a bug in your worker), do not write a
one-off script that scrapes the CSV and re-imports. Fix the handler, then use replay in the
delivery log: replay one event, or bulk-replay a range of failed deliveries. Because your handler is
idempotent on call_sid, replaying a range that partly succeeded is harmless.
For backfills older than your webhook, use the REST API instead: GET /missed-calls with a date range,
or GET /missed-calls/export.csv, at https://voice-api.edesy.in/v1 with your API key.
Rule 6: Monitor Both Ends
| Signal | Where | Alert when |
|---|---|---|
| Failed deliveries | Delivery log in the portal | Any failed delivery |
| Your 4xx/5xx rate | Your API logs or APM | Above zero for 5 minutes |
| Queue age | Your queue or inbox table | Oldest unprocessed event older than your callback target |
| Daily volume vs CSV | GET /missed-calls/summary vs your table |
Counts diverge |
| Wallet balance | Portal email alert | Low balance |
A useful nightly job: pull yesterday's calls from GET /missed-calls (or the summary endpoint), count
rows in your inbox table for the same window, and alert if they differ. That catches silent problems no single request log will show.
Rule 7: Test Before Going Live
- Set the webhook URL and secret in the portal.
- Click Send test event. You will receive a
webhook.testevent signed the same way. - Confirm your handler verifies it and returns 200 (ignore non-
call.missedevents in processing). - Give a real missed call to your number from a phone and watch it arrive.
- Stop your server, give another missed call, start the server, and confirm the retry arrives.
Where This Leads
A reliable webhook is the base for everything else: sending leads to a CRM (see missed call to CRM), or triggering an automatic outbound call through the Voice API (see missed call to AI voice callback).
Webhook pricing is ₹0.30 per delivered event plus GST, or included in the ₹1,498 per month unlimited plan per number. Undelivered events are not charged.
Get a Missed Call Number and wire your endpoint up with the patterns above.