Webhooks
Webhooks let DPT notify your server in real time when events happen in your business. Instead of polling the API, you register an endpoint and DPT sends an HTTP POST to it whenever an event fires.
Setup
- Go to Dashboard → Webhooks
- Click Add Webhook
- Enter your endpoint URL and select which events to subscribe to
- After creation, copy the signing secret from the banner — it is also visible at any time in the webhook list via the eye icon
Event Types
| Event | Fires when |
|---|---|
checkout.created | A checkout link is created |
checkout.paid | Payment detected on-chain — fires immediately, ideal for instant merchants |
checkout.completed | Payment confirmed, KYT passed, and settled to your balance (~1 min after checkout.paid) |
checkout.refunded | A checkout is fully or partially refunded |
checkout.expired | A checkout reaches its expiry time unpaid |
checkout.cancelled | A checkout is cancelled via the API or dashboard |
invoice.paid | An invoice is fully paid |
invoice.sent | An invoice is activated and sent to the customer |
payout.completed | A payout to an external account is confirmed |
payout.failed | A payout fails |
card.created | A card is issued to a cardholder you manage |
card.transaction | A card transaction event (authorization, update, or settlement) |
Payload
All webhook deliveries share the same envelope:
{
"id": "delivery-uuid",
"event": "checkout.completed",
"data": {
"id": "uuid",
"merchant_id": "uuid",
"amount": 50000000,
"currency": "USDC",
"status": "completed"
}
}The top-level id is the delivery ID — a stable UUID that stays the same across all retry attempts. Use it to deduplicate events on your end.
The data object contains the full resource at the time of the event.
checkout.paid Payload
Fires as soon as the incoming transfer is detected on-chain. Because on-chain crypto transactions are practically irreversible, this event is safe to act on immediately for most use cases — especially instant merchants (in-person, POS, vending) where speed matters.
Funds are not yet credited to your balance at this point — that happens with checkout.completed roughly 1 minute later.
{
"id": "delivery-uuid",
"event": "checkout.paid",
"data": {
"id": "uuid",
"merchant_id": "uuid",
"amount": 50000000,
"currency": "USDC",
"status": "paid"
}
}When to act on checkout.paid:
- In-person / POS payments where instant confirmation matters
- Digital goods delivery where speed is expected
- Showing a "payment received" status to your customer immediately
checkout.completed Payload
Fires approximately 1 minute after checkout.paid, once the payment is confirmed on-chain, passes KYT risk checks, and is credited to your merchant balance. Use this event to trigger accounting, payouts, or any flow that depends on settled funds.
{
"id": "delivery-uuid",
"event": "checkout.completed",
"data": {
"id": "uuid",
"merchant_id": "uuid",
"amount": 50000000,
"currency": "USDC",
"status": "completed",
"reseller_fee": 1000000,
"platform_fee": 100000,
"reseller_id": "uuid | null",
"referral_fee": 50000,
"referred_by": "uuid | null"
}
}| Field | Description |
|---|---|
reseller_fee | Amount credited to the reseller. 0 when no reseller |
platform_fee | DPT's platform cut. 0 when no platform rate is set |
reseller_id | UUID of the reseller merchant, or null |
referral_fee | Referral commission deducted. 0 when no referral |
referred_by | UUID of the referring merchant, or null |
The merchant's net is amount − reseller_fee − platform_fee − referral_fee.
checkout.created Payload
Fires immediately after a checkout link is created, before any payment is made. The payload includes payment info so your server can act without making an extra API call.
{
"id": "delivery-uuid",
"event": "checkout.created",
"data": {
"id": "uuid",
"merchant_id": "uuid",
"hosted_url": "https://pay.dpt.xyz/checkout/uuid",
"amount": 50000000,
"currency": "USDC",
"method": "any",
"reference": "order-1234",
"customer_id": "uuid",
"reseller_id": "uuid | null",
"expires_at": "2026-04-01T00:00:00Z | null",
"status": "active",
"crypto_chains": [
{ "chain": "Ethereum", "address": "0xabc..." }
],
"fiat": {
"session_url": "https://checkout.tazapay.com/...",
"success_url": "https://your-site.com/success",
"cancel_url": "https://your-site.com/cancel"
}
}
}| Field | Description |
|---|---|
hosted_url | DPT-hosted payment page URL |
crypto_chains | Deposit addresses per chain. Empty array if method is "fiat" |
fiat | Tazapay session URL and redirect URLs. null if method is "crypto" |
checkout.refunded Payload
Fires after a full or partial refund is recorded. status will be "refunded" (fully refunded) or "partially_refunded".
{
"id": "delivery-uuid",
"event": "checkout.refunded",
"data": {
"id": "uuid",
"merchant_id": "uuid",
"refund_amount": 10000000,
"refunded_amount": 10000000,
"refund_fee": 500000,
"remaining_amount": 0,
"currency": "USDC",
"reason": "Customer request",
"status": "refunded"
}
}| Field | Description |
|---|---|
refund_amount | Amount refunded in this transaction (gross, before gas) |
refunded_amount | Running total refunded across all refund operations |
refund_fee | On-chain gas fee in 6dp. 0 for fiat refunds. When gas_fee_payer is "merchant" this is charged on top of refund_amount; otherwise it is deducted from it |
remaining_amount | Amount still refundable after this refund. 0 when fully refunded |
status | "refunded" when fully refunded, "partially_refunded" otherwise |
card.created Payload
Fires when a card is successfully issued to a cardholder managed by your merchant account. Use this event to record the card ID in your system or trigger onboarding flows.
{
"event": "card.created",
"data": {
"id": "uuid",
"cardholder_id": "uuid",
"status": "active",
"ctype": "virtual",
"last4": "4242",
"exp_month": "03",
"exp_year": "2029",
"created_at": "2026-04-20T10:00:00Z"
}
}| Field | Description |
|---|---|
id | Card UUID |
cardholder_id | UUID of the cardholder who owns the card |
ctype | virtual or physical |
status | Card status at creation time — normally active for virtual cards |
card.transaction Payload
Fires at each stage of the card transaction lifecycle. Every event for the same underlying transaction shares the same data.id.
{
"event": "card.transaction",
"data": {
"id": "uuid",
"card_id": "uuid",
"amount": -5000000,
"local_amount": -500,
"local_currency": "USD",
"merchant_name": "Starbucks #1234",
"type": "card_payment",
"status": "completed",
"created_at": "2026-04-20T10:00:00Z"
}
}| Field | Description |
|---|---|
id | Transaction UUID — stable across all lifecycle events for this transaction |
card_id | UUID of the card used |
amount | Amount in 6dp micro-units. Negative for payments/withdrawals; positive for deposits/refunds |
local_amount | Amount in the transaction's local currency (2dp cents). Negative for payments/withdrawals |
local_currency | ISO 4217 currency code of the transaction (e.g. USD, EUR) |
merchant_name | Merchant or counterparty name |
type | Transaction type — see table below |
status | Transaction status — see table below |
created_at | When the transaction record was first created |
Transaction Types
type | Description |
|---|---|
card_payment | Card swipe or online payment — cardholder spent funds |
card_refund | Merchant refunded the cardholder — funds returned to the card |
card_deposit | Funds loaded onto a physical (Wasabi) card |
card_withdraw | Funds pulled off a physical (Wasabi) card back to the wallet |
Transaction Statuses
status | Description |
|---|---|
requested / pending | Authorization hold placed — funds frozen, settlement expected |
paid | Transaction created by the card network (Rain virtual cards) |
completed | Transaction settled — funds debited or credited |
declined | Authorization was declined — hold released, no funds moved |
reversed | Authorization was voided before settlement — hold released |
Lifecycle
A typical card payment fires these events in order:
card.transaction (status=requested) ← authorization hold placed
card.transaction (status=completed) ← settledA declined authorization:
card.transaction (status=requested) ← hold placed
card.transaction (status=declined) ← declined, hold releasedA voided authorization:
card.transaction (status=requested) ← hold placed
card.transaction (status=reversed) ← voided, hold releasedDeposit and withdrawal events fire a single card.transaction each:
card.transaction (type=card_deposit, status=completed)
card.transaction (type=card_withdraw, status=completed)Request Headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-DPT-Event | Event type, e.g. checkout.completed |
X-DPT-Delivery-Id | Delivery UUID (same as top-level id in the body) |
X-DPT-Signature | sha256={hex_signature} |
Verifying the Signature
Every delivery is signed with HMAC-SHA256 using your webhook's signing secret. Always verify the signature before processing the event.
Algorithm
- Read the raw request body as a string (do not parse it first)
- Compute
HMAC-SHA256(secret, raw_body)and hex-encode the result - Compare with the value after
sha256=inX-DPT-Signature - If they match, the payload is authentic
Examples
Node.js
const crypto = require('crypto');
function verifySignature(secret, rawBody, signatureHeader) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
// Express handler
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-dpt-signature'];
if (!verifySignature(process.env.WEBHOOK_SECRET, req.body, sig)) {
return res.status(401).send('Invalid signature');
}
const { event, data } = JSON.parse(req.body);
// process event...
res.sendStatus(200);
});Python
import hmac, hashlib
def verify_signature(secret: str, raw_body: bytes, signature_header: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
# Flask handler
@app.route('/webhooks', methods=['POST'])
def webhook():
sig = request.headers.get('X-DPT-Signature', '')
if not verify_signature(os.environ['WEBHOOK_SECRET'], request.data, sig):
abort(401)
payload = request.get_json()
event = payload['event']
data = payload['data']
# process event...
return '', 200Go
func verifySignature(secret, rawBody, signatureHeader string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(rawBody))
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signatureHeader))
}Retry Policy
If your endpoint returns a non-2xx HTTP status (or times out after 10 seconds), DPT retries the delivery with exponential backoff:
| Attempt | Delay after previous failure |
|---|---|
| 1 | Immediate |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 5 hours |
After 5 failed attempts, the delivery is marked failed and no further retries occur. You can view the full delivery history, including HTTP status codes and response bodies, in Dashboard → Webhooks.
Delivery History
Each event delivery is logged with:
- Status:
pending,delivered,failed - Attempt number
- HTTP response status from your server
- Timestamp
Access the log from the dashboard by clicking on any webhook endpoint.
Best Practices
- Return 2xx quickly — do heavy processing asynchronously (queue the event and acknowledge immediately)
- Handle duplicates — use the top-level
id(delivery ID) to deduplicate retries;data.idis the resource ID, not the delivery ID - Check the event type first — ignore events your code doesn't handle
- Use HTTPS — only register endpoints with valid TLS certificates
- Rotate secrets periodically — delete and recreate the webhook to get a new secret
