Skip to main content
Danipa
Docs · 010 / 010

Webhooks

Subscribe to platform events — endpoint setup, HMAC signature verification, retry behaviour, and the event catalog.

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

  1. Navigate to Settings → Webhooks.
  2. Click Add Endpoint.
  3. Enter your endpoint URL (must be HTTPS).
  4. Select the events you want to receive.
  5. 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...
HeaderDescription
X-Danipa-Signaturesha256=<hex> — HMAC-SHA256 signature of the request
X-Danipa-TimestampUnix epoch seconds when the webhook was sent
X-Danipa-EventEvent type (e.g. payment.completed)
X-Danipa-DeliveryUnique delivery ID (UUID) for deduplication
Content-TypeAlways 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:

AttemptDelay (after previous)
1(the initial delivery)
21 minute
35 minutes
430 minutes
52 hours
66 hours
724 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

EventWhen
transfer.completedTransfer settled.
transfer.failedTransfer rejected by provider or platform.
transfer.reversedTransfer manually reversed by admin.
wallet.fundedWallet balance increased.
wallet.debitedWallet balance decreased.

Remittance events

EventWhen
remittance.pendingRemittance created, awaiting processing.
remittance.processingFunds deducted, payout initiated.
remittance.completedRecipient received funds.
remittance.failedTransaction failed.
remittance.refundedFunds returned to sender.

Payment-link & invoice events

EventWhen
payment_link.paidCustomer paid via a payment link.
payment.completedPayment link payment completed successfully (alias).
payment.failedPayment link payment failed.
payment.refundedPayment refunded to payer.
invoice.createdInvoice created.
invoice.sentInvoice emailed to customer.
invoice.paidInvoice was paid.
invoice.overdueInvoice passed dueAt.
invoice.cancelledInvoice cancelled by merchant.

Checkout, KYC, dispute, system

EventWhen
checkout.session.completedHosted-checkout session finished successfully.
dispute.openedA chargeback / reversal claim was filed.
dispute.resolvedDispute closed (any outcome).
kyc.tier_changedUser's KYC tier changed (up or down).
rate.updatedExchange rate changed significantly.
test.pingTest 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.ping event 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 verificationkyc.tier_changed is the high-value event for KYC integrators.