# Missed Call Webhooks: Signatures, Retries, Dedup

> Best practices for missed call webhooks: HMAC-SHA256 signature verification over the raw body, fast 2xx responses, retry behaviour, idempotency on call_sid, replay and monitoring, with Node and Python code.

Source: https://edesy.in/blog/missed-call-webhook-best-practices

**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](https://missedcall.edesy.in/?utm_source=edesy.in&utm_medium=blog&utm_campaign=missed-call)
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:

```json
{
  "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](https://edesy.in/docs/missed-call) and on the [API page](https://edesy.in/missed-call-service/api).

## 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)

```js
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)

```python
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 "", 200
```

To debug a mismatch, paste a payload, secret and header into the
[webhook signature verifier](https://edesy.in/tools/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:

1. A retry after a timeout where your server actually processed the first request.
2. A manual or bulk replay from the delivery log.
3. Your own worker retrying after a partial failure.

Every retry and replay carries the **same** `data.call_sid`. Use it as the idempotency key:

```sql
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

1. Set the webhook URL and secret in the portal.
2. Click **Send test event**. You will receive a `webhook.test` event signed the same way.
3. Confirm your handler verifies it and returns 200 (ignore non-`call.missed` events in processing).
4. Give a real missed call to your number from a phone and watch it arrive.
5. 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](https://edesy.in/blog/missed-call-to-crm)), or triggering an automatic outbound call through the
[Voice API](https://edesy.in/voice-api) (see [missed call to AI voice callback](https://edesy.in/blog/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](https://missedcall.edesy.in/?utm_source=edesy.in&utm_medium=blog&utm_campaign=missed-call)
and wire your endpoint up with the patterns above.

## Frequently Asked Questions

### How do I verify a missed call webhook signature?

Compute HMAC-SHA256 of the raw request body using your webhook secret, hex-encode it, prefix it with sha256= and compare it with the X-Edesy-Signature header using a constant-time comparison. Always use the raw bytes, never re-serialised JSON.

### What happens if my endpoint is down?

Any non-2xx response or timeout counts as a failure. The event is retried up to 6 attempts in total with exponential backoff starting at 30 seconds, roughly 15 minutes overall. Failed deliveries can be replayed from the delivery log, and you are not charged for events that could not be delivered.

### Can I receive the same missed call twice?

Yes, through retries or a manual replay. Every retry and replay carries the same call_sid, so store it with a unique constraint and ignore duplicates.

### How fast must my endpoint respond?

Each attempt has a short timeout of a few seconds. Verify the signature, enqueue the event and return 200 immediately; do the CRM write, callback or other slow work in a background job.

### Is a webhook required to use the missed call service?

No. The call log and CSV export are free without a webhook. The webhook is ₹0.30 per delivered event and is for real-time delivery into your own systems.
