# COD Confirmation by Webhook and API: A Developer Guide (2026)

> Add automated COD confirmation calls to a custom store: send a signed order webhook, verify X-Edesy-Signature HMAC-SHA256, and handle confirm or cancel on your action URL. Payloads and code included.

Source: https://edesy.in/blog/cod-confirmation-webhook-api-guide-2026

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](https://edesy.in/cod-order-confirmation/custom-store-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](https://edesy.in/cod-order-confirmation/custom-store-api).

## The shape of the integration

1. **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.
2. **You send order events.** `order.created` when a COD order is placed, `order.cancelled` if it's
   cancelled before we call.
3. **We call the customer** within your calling window and rules, with retries if they don't pick up.
4. **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.

```json
{
  "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:

- **`event`** is `order.created`, `order.cancelled` or `order.paid`. Send `order.paid` if a COD order is
  paid online before we call (a customer switching to prepaid, for example), and the pending call is
  dropped.
- **`event_id`** must 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.
- **`id`** is your order id. It is what we send back in the result, so use the value your system looks
  orders up by. **`name`** is the human-readable order number that is read out on the call.
- **`phone`** is the customer's mobile number with country code, for example `+919800000000`.
- **`cod_amount_paise`** is 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); otherwise `total_paise` is used.
- **`payment_method`** lets your call rules skip prepaid orders. Most stores call only `cod`.
- **`cod_confirmed`** tells 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:

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

1. **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.
2. **Milliseconds instead of seconds.** The timestamp is Unix time in seconds.
3. **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:

```json
{
  "action": "confirm",
  "order_id": "10452",
  "order_name": "#10452",
  "call_sid": "c4b1e2d0-...",
  "at": "2026-10-12T11:42:05+05:30"
}
```

- **`action`** is `confirm` or `cancel`. Depending on your rules you may also receive `tag` or `note`
  actions, for example to mark an order unreachable after the last attempt.
- **`order_id`** and **`order_name`** are the values you sent.
- **`call_sid`** identifies the call, useful for matching against the call log and recording.
- **`at`** is 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:

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

- `confirm` on an order already confirmed: return 200, change nothing.
- `cancel` on an order already cancelled: return 200, change nothing.
- `cancel` on 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](https://edesy.in/blog/magento-cod-order-confirmation-guide-2026) and the
[PrestaShop and OpenCart guide](https://edesy.in/blog/prestashop-opencart-cod-confirmation-guide-2026) 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](https://edesy.in/cod-order-confirmation/custom-store-api). We'll set up your connection and share your webhook URL and signing secret.

## Related reading

- [How COD confirmation calls work, step by step](https://edesy.in/cod-order-confirmation/how-it-works)
- [Webhooks that silently stop: delivery health checks](https://edesy.in/blog/webhook-silently-stopped-delivery-health-2026)
- [WooCommerce COD order confirmation guide](https://edesy.in/blog/woocommerce-cod-order-confirmation-guide-2026)
- [Shopify COD order confirmation guide](https://edesy.in/blog/shopify-cod-order-confirmation-guide-2026)

## Frequently Asked Questions

### What do I need to build to add COD confirmation calls to my own store?

Two things: code that sends a signed webhook when a COD order is placed (and when it is cancelled), and optionally an endpoint, the action URL, that receives confirm or cancel and updates the order. Everything in between, including calling rules, retries and the call itself, runs on our side.

### How is the webhook signed?

With a hex HMAC-SHA256 of the timestamp, a dot and the raw request body, using your signing secret. Send the timestamp in X-Edesy-Timestamp and the signature as sha256=<hex> in X-Edesy-Signature. Requests older than 5 minutes are rejected.

### What happens if I send the same order twice?

Duplicate events are ignored using event_id, and each order gets only one confirmation journey. Retrying from your side is safe.

### Do I have to expose an action URL?

No. Without one, outcomes are recorded in your dashboard and your team acts on them there. With one, your system is updated automatically.

### Will the result be delivered more than once?

It can be. If your endpoint times out or returns a 5xx, we retry, so make your handler safe to run twice for the same order.

### Is the custom store API available today?

Yes. The webhook connection for custom stores is live. We create your connection and share your webhook URL and signing secret.
