If your store runs on your own backend, or on a platform without a ready integration, COD confirmation comes down to two HTTPS requests per order: you tell us about a COD order, and we tell you what the customer answered. Everything between those two requests (when to call, retries, the call itself in Hindi or English) is handled by the confirmation service.
This guide covers the exact payloads and the signing scheme for Edesy's COD confirmation API, plus the details that usually go wrong: raw-body signing, duplicates and idempotent handlers.
Want access? The custom store API is live. Request your webhook URL and signing secret.
The shape of the integration
- We create your connection. You get a unique webhook URL and a signing secret. You can also give us an action URL to receive results.
- You send order events.
order.createdwhen a COD order is placed,order.cancelledif it's cancelled before we call. - We call the customer within your calling window and rules, with retries if they don't pick up.
- We POST the result to your action URL, signed the same way.
No plugin and no store credentials are involved. Your system stays the source of truth for the order.
Step 1: send the order
POST JSON to your webhook URL when a COD order is placed. Amounts are in paise.
{
"event": "order.created",
"event_id": "evt_7f3a9c",
"order": {
"id": "10452",
"name": "#10452",
"customer_name": "Priya Sharma",
"phone": "+919800000000",
"payment_method": "cod",
"total_paise": 111000,
"cod_amount_paise": 111000,
"cod_confirmed": false,
"items": [{ "title": "Cotton Kurta", "quantity": 1 }]
}
}Field notes:
eventisorder.created,order.cancelledororder.paid. Sendorder.paidif a COD order is paid online before we call (a customer switching to prepaid, for example), and the pending call is dropped.event_idmust be unique per event. It's how duplicate deliveries are ignored, so your retries are safe. A database id plus the event type works well.idis your order id. It is what we send back in the result, so use the value your system looks orders up by.nameis the human-readable order number that is read out on the call.phoneis the customer's mobile number with country code, for example+919800000000.cod_amount_paiseis the amount read out to the customer. Set it when only part of the order is cash on delivery (after a wallet or partial prepayment); otherwisetotal_paiseis used.payment_methodlets your call rules skip prepaid orders. Most stores call onlycod.cod_confirmedtells us the order is already confirmed (by your team, say), so it isn't called.
To stop a call, send order.cancelled for the same id. If the call hasn't been placed yet, it's skipped.
Step 2: sign the request
Add two headers. The signature is a hex HMAC-SHA256 of the timestamp, a dot, and the raw request body, using your signing secret. Requests older than 5 minutes are rejected.
X-Edesy-Timestamp: 1760256000
X-Edesy-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + raw_body)>In Node.js:
import crypto from "node:crypto";
const body = JSON.stringify(event);
const ts = Math.floor(Date.now() / 1000).toString();
const signature = "sha256=" + crypto
.createHmac("sha256", process.env.EDESY_WEBHOOK_SECRET)
.update(ts + "." + body)
.digest("hex");
await fetch(process.env.EDESY_WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Edesy-Timestamp": ts,
"X-Edesy-Signature": signature,
},
body,
});Three mistakes account for most signature failures:
- Signing a different string than you send. Serialise the JSON once, sign that exact string, and send that exact string. Re-serialising after signing (or letting an HTTP client pretty-print it) changes the bytes.
- Milliseconds instead of seconds. The timestamp is Unix time in seconds.
- Clock drift. With a 5-minute window, a server clock that is a few minutes off will fail intermittently. Keep NTP on.
Send the webhook from a background job rather than inside the checkout request, and retry on network
errors or 5xx. Because of event_id, a retry that lands twice is harmless.
Step 3: receive the result on your action URL
When the customer answers, we POST to your action URL:
{
"action": "confirm",
"order_id": "10452",
"order_name": "#10452",
"call_sid": "c4b1e2d0-...",
"at": "2026-10-12T11:42:05+05:30"
}actionisconfirmorcancel. Depending on your rules you may also receivetagornoteactions, for example to mark an order unreachable after the last attempt.order_idandorder_nameare the values you sent.call_sididentifies the call, useful for matching against the call log and recording.atis when the answer was given, in ISO 8601 with offset.
The request carries X-Edesy-Timestamp and X-Edesy-Signature in the same format as above. Results
are signed with your account's signing secret, which we share during setup; keep it in its own variable
rather than assuming it is the same value as the webhook secret. Verify the request before you touch the
order:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/edesy/cod-result",
express.raw({ type: "application/json" }),
async (req, res) => {
const ts = req.get("X-Edesy-Timestamp") || "";
const sig = req.get("X-Edesy-Signature") || "";
const raw = req.body.toString("utf8");
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.EDESY_RESULT_SECRET)
.update(ts + "." + raw)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
const valid =
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!fresh || !valid) return res.status(401).end();
const result = JSON.parse(raw);
await applyResult(result); // must be safe to run twice
res.status(200).end();
}
);Note express.raw: the signature covers the raw body, so verify against the bytes you received, not a
re-serialised parsed object.
Make the handler idempotent
If your endpoint times out or returns a 5xx, we retry. That means the same result can arrive twice, so
applyResult should be a no-op when the order is already in the target state:
confirmon an order already confirmed: return 200, change nothing.cancelon an order already cancelled: return 200, change nothing.cancelon an order that has already shipped: return 200, and flag it for a human instead of failing the request. The customer's answer is still worth recording.
Respond quickly and do slow work (emails, ERP sync) after responding. If your store can't accept the update when the customer answers, the answer is kept and delivery of the result is retried in the background.
No action URL?
It's optional. Without one, every outcome (confirmed, cancelled, no input, unreachable) is recorded in your dashboard with the order timeline, and your team acts on it there. Many stores start this way and add the action URL once the flow is trusted.
What runs on our side
You don't build any of this, but it explains the behaviour you'll see:
- Calling rules. Default: first call 2 minutes after the order, between 9 am and 9 pm IST, up to three attempts (30 minutes, then 2 hours apart), give up after 48 hours. All adjustable per connection.
- Script. Hindi or English, with the customer name, store name, order number and COD amount filled in for each order. Press 1 to confirm, 2 to cancel.
- Event log and replay. Every incoming event is logged with its result, and can be replayed. When a call didn't happen, the log answers "did the webhook even arrive?"
Using a webhook plugin or an automation tool instead of code
The same API works for platforms without a ready integration. Magento, PrestaShop and OpenCart stores typically use a webhook plugin plus a small bridge, and automation tools such as Zapier, Make or n8n can send the request if they can compute the HMAC signature. See the Magento guide and the PrestaShop and OpenCart guide for platform specifics.
Pricing
Calls are paid as you go from a prepaid wallet. Share your monthly COD volume with us for pricing.
Next step: Get access to the COD confirmation API. We'll set up your connection and share your webhook URL and signing secret.