This guide walks the webhooks setup end-to-end: registering an endpoint, verifying the signature on inbound deliveries, handling the retry behaviour, and what events are available.
Why webhooks beat polling
For asynchronous events (transfer settled, invoice paid, chargeback received), polling is wasteful and slow. Webhooks push the event to your endpoint within seconds of the state change, with retry on failure.
Register an endpoint
You can register an endpoint two ways — via the merchant dashboard or the API.
Via the dashboard
- Navigate to Settings → Webhooks.
- Click Add Endpoint.
- Enter your endpoint URL (must be HTTPS).
- Select the events you want to receive.
- Copy the Webhook Secret (starts with
whsec_) for signature verification.
Via the API
curl -X POST https://api.sandbox.danipa.com/ms/v1/merchants/me/webhooks \
-H "Authorization: Bearer $DANIPA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/danipa/webhook",
"events": ["transfer.completed", "transfer.failed", "invoice.paid"]
}'
The response includes the signing secret — store this securely; it's the only thing standing between you and someone forging events.
{
"webhook": {
"id": "we_01HG...",
"url": "https://your-app.example.com/danipa/webhook",
"events": ["transfer.completed", "transfer.failed", "invoice.paid"],
"secret": "whsec_replace_with_your_secret",
"status": "ACTIVE"
}
}
The secret is shown once. Store it; you cannot retrieve
it later (you can rotate it, which generates a new one).
Receive a delivery
Each delivery is a POST to your endpoint with these headers:
Content-Type: application/json
X-Danipa-Event: transfer.completed
X-Danipa-Delivery: del_01HG...
X-Danipa-Timestamp: 1730649600
X-Danipa-Signature: sha256=4f3a2b1c...
| Header | Description |
|---|---|
X-Danipa-Signature | sha256=<hex> — HMAC-SHA256 signature of the request |
X-Danipa-Timestamp | Unix epoch seconds when the webhook was sent |
X-Danipa-Event | Event type (e.g. payment.completed) |
X-Danipa-Delivery | Unique delivery ID (UUID) for deduplication |
Content-Type | Always application/json |
And a body like:
{
"id": "evt_01HG...",
"type": "transfer.completed",
"createdAt": "2026-05-06T14:00:00Z",
"data": {
"transfer": {
"id": "txn_01HG...",
"status": "COMPLETED",
"amount": "10.00",
"currency": "USD"
}
}
}
Verify the signature — DO THIS
Without verification, anyone who knows your endpoint URL can
forge events. The signature is HMAC-SHA256 over
<timestamp>.<raw body> keyed by your signing secret.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(
rawBody: Buffer,
signatureHeader: string,
timestampHeader: string,
secret: string,
): boolean {
const sig = signatureHeader.replace(/^sha256=/, '');
const expected = createHmac('sha256', secret)
.update(`${timestampHeader}.${rawBody.toString('utf8')}`)
.digest('hex');
// Constant-time compare — prevents timing-leak attacks
if (sig.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'));
}
Python
import hmac, hashlib
def verify_webhook(raw_body: bytes, signature_header: str, timestamp_header: str, secret: str) -> bool:
sig = signature_header.removeprefix('sha256=')
expected = hmac.new(
secret.encode('utf-8'),
f"{timestamp_header}.{raw_body.decode('utf-8')}".encode('utf-8'),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(sig, expected)
Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
public boolean verifyWebhook(String signature, String timestamp,
String body, String secret) {
long age = Instant.now().getEpochSecond() - Long.parseLong(timestamp);
if (age > 300) return false;
String payload = timestamp + "." + body;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(
mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))
);
return MessageDigest.isEqual(
signature.getBytes(StandardCharsets.UTF_8),
expected.getBytes(StandardCharsets.UTF_8)
);
}
PHP
function verifyWebhook(string $signature, string $timestamp,
string $body, string $secret): bool {
// Check replay window (5 minutes)
if (abs(time() - (int)$timestamp) > 300) {
return false;
}
$payload = $timestamp . '.' . $body;
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
return hash_equals($signature, $expected);
}
// Usage in a controller
$signature = $_SERVER['HTTP_X_DANIPA_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_DANIPA_TIMESTAMP'] ?? '';
$body = file_get_contents('php://input');
if (!verifyWebhook($signature, $timestamp, $body, $webhookSecret)) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($body, true);
// Process event...
Replay protection
The X-Danipa-Timestamp is in seconds. After verifying the
signature, also check the timestamp is recent:
const ageSec = Math.floor(Date.now() / 1000) - Number(timestampHeader);
if (ageSec > 300) {
// > 5 minutes old — stale delivery, possible replay
return res.status(401).send();
}
Five-minute window is the standard. Anything older has likely been intercepted or replayed.
Retry behaviour
If your endpoint returns anything other than 2xx, the platform
retries with exponential backoff:
| Attempt | Delay (after previous) |
|---|---|
| 1 | (the initial delivery) |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 24 hours |
After 7 failed attempts, the delivery moves to FAILED and the
endpoint enters a degraded state. If your endpoint stays bad for
24 hours of failed deliveries, the platform disables it; you'll
get an email and need to fix-then-re-enable in the dashboard.
Be idempotent. A retry can deliver an event your handler
already processed (you returned 5xx after committing). Use
evt_… (or the X-Danipa-Delivery header) as a deduplication
key.
Event catalog
Transfer & wallet events
| Event | When |
|---|---|
transfer.completed | Transfer settled. |
transfer.failed | Transfer rejected by provider or platform. |
transfer.reversed | Transfer manually reversed by admin. |
wallet.funded | Wallet balance increased. |
wallet.debited | Wallet balance decreased. |
Remittance events
| Event | When |
|---|---|
remittance.pending | Remittance created, awaiting processing. |
remittance.processing | Funds deducted, payout initiated. |
remittance.completed | Recipient received funds. |
remittance.failed | Transaction failed. |
remittance.refunded | Funds returned to sender. |
Payment-link & invoice events
| Event | When |
|---|---|
payment_link.paid | Customer paid via a payment link. |
payment.completed | Payment link payment completed successfully (alias). |
payment.failed | Payment link payment failed. |
payment.refunded | Payment refunded to payer. |
invoice.created | Invoice created. |
invoice.sent | Invoice emailed to customer. |
invoice.paid | Invoice was paid. |
invoice.overdue | Invoice passed dueAt. |
invoice.cancelled | Invoice cancelled by merchant. |
Checkout, KYC, dispute, system
| Event | When |
|---|---|
checkout.session.completed | Hosted-checkout session finished successfully. |
dispute.opened | A chargeback / reversal claim was filed. |
dispute.resolved | Dispute closed (any outcome). |
kyc.tier_changed | User's KYC tier changed (up or down). |
rate.updated | Exchange rate changed significantly. |
test.ping | Test event from the webhook tester. |
Subscribe to only the events you handle — narrow filters are cheaper than processing-and-discarding.
Testing webhooks
Two options:
- Webhook Playground in the developer portal
(
/webhooks/playground) — fires a happy-path event and an intentionally-bad-signature event at your endpoint so you can verify your reject path. - Dashboard test button — Settings → Webhooks → click
Test on any endpoint. A
test.pingevent is sent to your URL and the delivery result (status code, response) appears immediately.
Common pitfalls
What's next
- Errors & limits — the standard error envelope used in webhook delivery responses too.
- Receive a payment — most webhook consumers also do invoices / payment links.
- KYC verification —
kyc.tier_changedis the high-value event for KYC integrators.