Webhooks
Webhooks let you know when something happens to your orders in real time. Instead of polling the API, DoorPay sends an HTTP POST to your server whenever an event occurs.
Setting Up Webhooks
Create an endpoint on your server that accepts POST requests with a JSON body. Example: https://yoursite.com/webhooks/doorpay
Configure the URL in your Dashboard → Settings. Set separate URLs for sandbox and production.
Return a 2xx status from your endpoint. Any non-2xx response (or timeout) triggers automatic retries.
Webhook Secret
Every webhook is signed with an HMAC-SHA256 secret so you can verify it came from DoorPay. The secret is auto-generated when you first save a webhook URL, and shown only once.
Set your webhook URL in Dashboard → Settings and click Save. A webhook secret (starting with whsec_) is auto-generated.
Copy the secret immediately from the popup dialog. It is only shown once — DoorPay stores it encrypted and cannot retrieve it later.
Store it securely in your server's environment variables (e.g. DOORPAY_WEBHOOK_SECRET). Never hard-code it in source.
Rotating the Secret
If your secret is compromised, go to Dashboard → Settings and click Rotate next to the webhook secret status. The old secret is permanently invalidated — update your server immediately with the new one. Separate secrets are maintained for sandbox and production environments.
Event Types
DoorPay sends these webhook events throughout the order lifecycle:
| Event | When It Fires | Order Status After |
|---|---|---|
ORDER_CREATED | You create a new order via the API | ACCEPTED |
PAYMENT_SUCCESS | Buyer completes payment | PAID |
PAYMENT_FAILED | Buyer's payment attempt fails | ACCEPTED |
DELIVERY_CONFIRMED | You mark the order as delivered | DELIVERED |
DISPUTE_RAISED | Buyer raises a dispute | DISPUTE_* |
DISPUTE_RESOLVED | You resolve a dispute | DELIVERED |
ORDER_COMPLETED | 24-hour window passes with no dispute. Funds released. | COMPLETED |
REFUND_PROCESSED | Buyer refunded (dispute not resolved in time) | REFUNDED |
ORDER_CANCELLED | Order cancelled (by you or payment link expired) | CANCELLED |
Payload Format
Every webhook sends a JSON POST body with this structure:
{
"event": "PAYMENT_SUCCESS",
"timestamp": "2026-03-13T10:30:00Z",
"environment": "sandbox",
"data": {
"order_number": "DP-20260313-A7X9K2",
"merchant_order_id": "SHOP-456",
"amount": 2500.00,
"currency": "INR",
"status": "PAID",
"dispute_count": 0,
"buyer": {
"email": "rahul@example.com",
"name": "Rahul Sharma"
}
}
}| Field | Type | Description |
|---|---|---|
event | string | The event type (see table above) |
timestamp | string | ISO-8601 UTC timestamp of when the event occurred |
environment | string | "sandbox" or "production" |
data.order_number | string | DoorPay order number |
data.merchant_order_id | string | Your internal order ID (if provided at creation) |
data.amount | number | Order amount in INR |
data.currency | string | Always "INR" |
data.status | string | Current order status after this event |
data.dispute_count | number | Total number of disputes raised on this order |
data.buyer.email | string | Buyer's email address |
data.buyer.name | string | Buyer's full name |
Webhook Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
Content-Type | Always application/json |
X-DoorPay-Signature | HMAC-SHA256 hex digest of timestamp.body, signed with your webhook secret. Used with the timestamp header for replay protection. |
X-DoorPay-Timestamp | Unix epoch seconds when the webhook was sent. Reject webhooks older than 5 minutes to prevent replay attacks. |
X-DoorPay-Event | The event type (same as the event field in the body) |
Verifying Signatures
Always verify the X-DoorPay-Signature header to ensure the webhook came from DoorPay and wasn't tampered with. The signature is computed as HMAC-SHA256(webhook_secret, timestamp + "." + raw_request_body) where timestamp is the value from the X-DoorPay-Timestamp header. Always reject webhooks where the timestamp is older than 5 minutes to prevent replay attacks.
const crypto = require("crypto");
function verifyWebhook(rawBody, signature, timestamp, webhookSecret) {
// Reject webhooks older than 5 minutes (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return false;
}
const signedPayload = timestamp + "." + rawBody;
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(signedPayload, "utf8")
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expected, "hex")
);
}
// In your Express handler:
app.post("/webhooks/doorpay", (req, res) => {
const signature = req.headers["x-doorpay-signature"];
const timestamp = req.headers["x-doorpay-timestamp"];
const rawBody = req.rawBody; // make sure to capture raw body
if (!verifyWebhook(rawBody, signature, timestamp, process.env.DOORPAY_WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
const event = req.body;
console.log("Received event:", event.event, event.data.order_number);
// Handle the event based on type
switch (event.event) {
case "PAYMENT_SUCCESS":
// Mark the order as paid in your system
break;
case "DISPUTE_RAISED":
// Alert your support team
break;
case "ORDER_COMPLETED":
// Funds released — finalize the order
break;
}
res.status(200).send("OK");
});import hmac
import hashlib
import time
def verify_webhook(raw_body: bytes, signature: str, timestamp: str, webhook_secret: str) -> bool:
# Reject webhooks older than 5 minutes (replay protection)
if abs(time.time() - int(timestamp)) > 300:
return False
signed_payload = f"{timestamp}.".encode("utf-8") + raw_body
expected = hmac.new(
webhook_secret.encode("utf-8"),
signed_payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# In your Flask handler:
@app.route("/webhooks/doorpay", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-DoorPay-Signature")
timestamp = request.headers.get("X-DoorPay-Timestamp")
if not verify_webhook(request.data, signature, timestamp, WEBHOOK_SECRET):
return "Invalid signature", 401
event = request.json
print(f"Received: {event['event']} for {event['data']['order_number']}")
return "OK", 200Retry Policy
If your endpoint returns a non-2xx status code or doesn't respond, DoorPay retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st | Immediate |
| 2nd | 5 minutes |
| 3rd | 30 minutes |
| 4th | 2 hours |
| 5th | 12 hours |
After 5 failed attempts, the webhook is marked as FAILED. You can manually retry failed webhooks from the Dashboard → Webhooks page.
Best Practices
Return 200 quickly
Process the webhook asynchronously. Return 200 immediately and handle the event in a background job. If your endpoint takes too long, the request may time out and trigger a retry.
Handle duplicates
Due to retries, you may receive the same event more than once. Use the order_number + event type to deduplicate. Make your handlers idempotent.
Always verify signatures
Never trust a webhook without verifying the X-DoorPay-Signature header. This prevents attackers from sending fake events to your endpoint.
Use HTTPS
Production webhook URLs must use HTTPS. Sandbox allows HTTP for local testing. URLs cannot point to localhost, private IPs, or cloud metadata endpoints.
Reject stale webhooks
Check the X-DoorPay-Timestamp header. Reject any webhook where the timestamp is more than 5 minutes old — this prevents replay attacks.
Don't rely solely on webhooks
Use webhooks for real-time updates, but also poll GET /orders/{id} as a fallback. Network issues can delay or drop webhooks.