Skip to content

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.