Webhooks
DropPay sends a signed HTTP POST to your webhook URL when a payment confirms on-chain.
Set your webhook URL and copy your webhook secret in Dashboard > API > Webhooks.
Signing
Every webhook request includes two headers:
| Header | Description |
|---|---|
X-DropPay-Timestamp |
Unix timestamp (seconds) when the event was sent |
X-DropPay-Signature |
sha256=<hex> - HMAC-SHA256 of "{timestamp}.{raw_body}" using your webhook secret |
Verify both the signature and the timestamp before processing.
Verification
webhook_handler.py
import hmac
import hashlib
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret"
@app.route("/webhook/droppay", methods=["POST"])
def droppay_webhook():
timestamp = request.headers.get("X-DropPay-Timestamp", "")
signature = request.headers.get("X-DropPay-Signature", "")
body = request.get_data()
# Reject if timestamp is more than 5 minutes old
if abs(int(time.time()) - int(timestamp)) > 300:
abort(400)
# Compute expected signature
message = timestamp.encode() + b"." + body
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(),
message,
hashlib.sha256
).hexdigest()
# Use constant-time comparison to prevent timing attacks
if not hmac.compare_digest(expected, signature):
abort(400)
event = request.json
handle_event(event)
return "", 200
def handle_event(event):
if event["event"] == "payment.confirmed":
data = event["data"]
order_id = data["order_id"]
if not already_fulfilled(order_id):
fulfill_order(order_id)
webhook.js
import crypto from "crypto";
import express from "express";
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
// Use express.raw() -- signature is over the raw body, not parsed JSON
app.post("/webhook/droppay",
express.raw({ type: "application/json" }),
(req, res) => {
const timestamp = req.headers["x-droppay-timestamp"];
const signature = req.headers["x-droppay-signature"];
const body = req.body;
// Reject if timestamp is more than 5 minutes old
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).end();
}
// Compute expected signature
const expected = "sha256=" + crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(timestamp + "." + body)
.digest("hex");
// Use timing-safe comparison
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(400).end();
}
const event = JSON.parse(body);
handleEvent(event);
res.status(200).end();
}
);
function handleEvent(event) {
if (event.event === "payment.confirmed") {
const { order_id } = event.data;
if (!alreadyFulfilled(order_id)) {
fulfillOrder(order_id);
}
}
}
Payload
{
"event": "payment.confirmed",
"timestamp": "2026-07-15T12:00:00Z",
"data": {
"request_id": "dp_xxxxxxxxxx",
"status": "paid",
"amount": "10.0",
"currency": "XRP",
"xrpl_tx_hash": "A1B2C3D4...",
"order_id": "order_1234",
"customer_id": "cust_5678",
"customer_name": "Jane Smith",
"customer_email": "jane@example.com",
"description": "Order #1234",
"merchant_reference": null,
"xrp_usd_rate": "1 XRP = $2.18 USD (coingecko)",
"usd_value": "21.80",
"paid_at": "2026-07-15T12:00:08.000Z",
"expires_at": null
}
}
Data fields
| Field | Type | Description |
|---|---|---|
request_id |
string | DropPay payment ID (dp_xxxxxxxxxx) |
status |
string | Always "paid" in this event |
amount |
string | Requested amount as a string |
currency |
string | "XRP" or "USD" |
xrpl_tx_hash |
string | XRPL transaction hash |
order_id |
string | The order_id you passed at creation. Null if not set. |
customer_id |
string | The customer_id you passed at creation. Null if not set. |
customer_name |
string | Customer name if captured. Null if not set. |
customer_email |
string | Customer email if captured. Null if not set. |
description |
string | Payment description |
merchant_reference |
string | The merchant_reference you passed at creation. Null if not set. |
xrp_usd_rate |
string | XRP/USD rate at time of payment creation |
usd_value |
string | USD equivalent at time of payment creation. Null for USD invoices. |
paid_at |
string | ISO 8601 - payment confirmed on-chain |
expires_at |
string | ISO 8601 - when the payment request expires. Null if no expiry. |
Idempotency
Webhooks may be delivered more than once. Use request_id to deduplicate:
if db.already_processed(event["data"]["request_id"]):
return "", 200 # Already handled -- return 200 to prevent retries
db.mark_processed(event["data"]["request_id"])
fulfill_order(event["data"]["order_id"])
Security checklist
- Verify the signature on every request. Never process unsigned webhooks.
- Check the timestamp. Reject events older than 5 minutes (replay attack protection).
- Use raw body for verification. Parsing JSON before verifying will cause signature failures.
- Use constant-time comparison (
hmac.compare_digest/crypto.timingSafeEqual). Never use==. - Return 200 quickly. DropPay expects a 200 within 10 seconds. Do heavy work asynchronously.
- Deduplicate on
request_id. Webhooks can be delivered more than once. - Do not fulfill on redirect. Only fulfill from the webhook.