Skip to main content
Webhooks allow your application to receive real-time notifications when events occur in Blnk. This enables you to build reactive workflows, update your UI, send notifications, and trigger business logic.

How webhooks work

  1. Configure webhook URL - Set your endpoint in blnk.json
  2. Events occur - Transactions, balance changes, reconciliations
  3. Blnk sends HTTP POST - To your configured URL
  4. Verify signature - Ensure webhook authenticity
  5. Process event - Update your application state

Configuring webhooks

Add webhook configuration to your blnk.json:
{
  "notification": {
    "webhook": {
      "url": "https://your-app.com/webhooks/blnk",
      "headers": {
        "Authorization": "Bearer your-secret-token"
      }
    }
  }
}
The webhook URL must be publicly accessible and accept POST requests.

Webhook events

Transaction events

transaction.queued

{
  "event": "transaction.queued",
  "data": {
    "transaction_id": "txn_abc123xyz",
    "amount": 100.00,
    "reference": "payment_001",
    "currency": "USD",
    "source": "bln_customer_wallet",
    "destination": "bln_merchant_wallet",
    "status": "QUEUED",
    "created_at": "2024-01-15T10:30:00Z"
  }
}
Triggered when a transaction is queued for processing.

transaction.applied

{
  "event": "transaction.applied",
  "data": {
    "transaction_id": "txn_abc123xyz",
    "amount": 100.00,
    "reference": "payment_001",
    "currency": "USD",
    "source": "bln_customer_wallet",
    "destination": "bln_merchant_wallet",
    "status": "APPLIED",
    "created_at": "2024-01-15T10:30:00Z"
  }
}
Triggered when a transaction is successfully processed.

transaction.inflight

{
  "event": "transaction.inflight",
  "data": {
    "transaction_id": "txn_hold_xyz789",
    "amount": 200.00,
    "status": "INFLIGHT",
    "inflight": true,
    "created_at": "2024-01-15T11:00:00Z"
  }
}
Triggered when an inflight (authorization hold) transaction is created.

transaction.void

{
  "event": "transaction.void",
  "data": {
    "transaction_id": "txn_void_abc123",
    "parent_transaction": "txn_hold_xyz789",
    "amount": 200.00,
    "status": "VOID",
    "created_at": "2024-01-15T12:00:00Z"
  }
}
Triggered when an inflight transaction is voided.

transaction.rejected

{
  "event": "transaction.rejected",
  "data": {
    "transaction_id": "txn_rejected_def456",
    "amount": 500.00,
    "status": "REJECTED",
    "meta_data": {
      "blnk_rejection_reason": "insufficient balance"
    },
    "created_at": "2024-01-15T13:00:00Z"
  }
}
Triggered when a transaction is rejected (e.g., insufficient funds).

Balance events

balance.created

{
  "event": "balance.created",
  "data": {
    "balance_id": "bln_new_wallet",
    "ledger_id": "ldg_123456",
    "currency": "USD",
    "balance": 0,
    "created_at": "2024-01-15T09:00:00Z"
  }
}
Triggered when a new balance is created.

balance.monitor

{
  "event": "balance.monitor",
  "data": {
    "monitor_id": "mon_abc123xyz",
    "balance_id": "bln_customer_wallet",
    "description": "Low balance alert",
    "condition": {
      "field": "balance",
      "operator": "<",
      "value": 100.00
    },
    "current_balance": {
      "balance": "9500"
    },
    "triggered_at": "2024-01-15T14:30:00Z"
  }
}
Triggered when a balance monitor condition is met.

Webhook signature verification

Blnk signs all webhooks with HMAC SHA256 to ensure authenticity:

Signature headers

X-Blnk-Signature: abcdef1234567890...
X-Blnk-Timestamp: 1705320600

Verifying signatures (Node.js)

const crypto = require('crypto');

function verifyWebhookSignature(req, secret) {
  const signature = req.headers['x-blnk-signature'];
  const timestamp = req.headers['x-blnk-timestamp'];
  const payload = JSON.stringify(req.body);

  // Construct signed payload
  const signedPayload = `${timestamp}.${payload}`;

  // Compute HMAC
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Compare signatures
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// Usage in Express
app.post('/webhooks/blnk', (req, res) => {
  const secret = process.env.BLNK_SECRET_KEY;

  if (!verifyWebhookSignature(req, secret)) {
    return res.status(401).send('Invalid signature');
  }

  // Process webhook
  const { event, data } = req.body;
  console.log('Received event:', event);

  res.status(200).send('OK');
});

Verifying signatures (Python)

import hmac
import hashlib
import time

def verify_webhook_signature(request, secret):
    signature = request.headers.get('X-Blnk-Signature')
    timestamp = request.headers.get('X-Blnk-Timestamp')
    payload = request.body.decode('utf-8')

    # Construct signed payload
    signed_payload = f"{timestamp}.{payload}"

    # Compute HMAC
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Compare signatures (timing-safe)
    return hmac.compare_digest(signature, expected_signature)

# Usage in Flask
@app.route('/webhooks/blnk', methods=['POST'])
def handle_webhook():
    secret = os.environ['BLNK_SECRET_KEY']

    if not verify_webhook_signature(request, secret):
        return 'Invalid signature', 401

    # Process webhook
    event = request.json['event']
    data = request.json['data']
    print(f'Received event: {event}')

    return 'OK', 200

Verifying signatures (Go)

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io/ioutil"
    "net/http"
)

func verifyWebhookSignature(r *http.Request, secret string) bool {
    signature := r.Header.Get("X-Blnk-Signature")
    timestamp := r.Header.Get("X-Blnk-Timestamp")

    body, _ := ioutil.ReadAll(r.Body)
    signedPayload := timestamp + "." + string(body)

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedPayload))
    expectedSignature := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(signature), []byte(expectedSignature))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    secret := os.Getenv("BLNK_SECRET_KEY")

    if !verifyWebhookSignature(r, secret) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    // Process webhook
    var webhook map[string]interface{}
    json.NewDecoder(r.Body).Decode(&webhook)

    event := webhook["event"].(string)
    fmt.Println("Received event:", event)

    w.WriteHeader(http.StatusOK)
}

Implementation code (from webhooks.go:67-124)

How Blnk sends webhooks:
func processHTTP(data NewWebhook, client *http.Client) error {
    conf, err := config.Fetch()
    if err != nil {
        return err
    }

    payloadBytes, err := json.Marshal(data)
    if err != nil {
        return err
    }

    secret := conf.Server.SecretKey
    timestamp := strconv.FormatInt(time.Now().Unix(), 10)

    // Create signature
    signatureData := timestamp + "." + string(payloadBytes)
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signatureData))
    signature := hex.EncodeToString(mac.Sum(nil))

    req, err := http.NewRequest(
        "POST",
        conf.Notification.Webhook.Url,
        bytes.NewBuffer(payloadBytes),
    )
    if err != nil {
        return err
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("X-Blnk-Signature", signature)
    req.Header.Set("X-Blnk-Timestamp", timestamp)

    // Add custom headers from config
    for key, value := range conf.Notification.Webhook.Headers {
        req.Header.Set(key, value)
    }

    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        log.Printf("Webhook failed with status %d", resp.StatusCode)
        return nil
    }

    return nil
}

Common use cases

Send email on transaction

app.post('/webhooks/blnk', async (req, res) => {
  const { event, data } = req.body;

  if (event === 'transaction.applied') {
    await sendEmail({
      to: data.meta_data.user_email,
      subject: 'Payment Received',
      body: `You received $${data.amount} from ${data.source}`
    });
  }

  res.status(200).send('OK');
});

Update user balance in database

app.post('/webhooks/blnk', async (req, res) => {
  const { event, data } = req.body;

  if (event === 'transaction.applied') {
    // Update your database
    await db.users.update({
      where: { wallet_id: data.destination },
      data: { 
        balance: data.amount,
        last_transaction: data.transaction_id
      }
    });
  }

  res.status(200).send('OK');
});

Trigger payout on balance threshold

app.post('/webhooks/blnk', async (req, res) => {
  const { event, data } = req.body;

  if (event === 'balance.monitor') {
    if (data.description === 'Ready for payout') {
      // Trigger payout process
      await initiatePayout({
        balance_id: data.balance_id,
        amount: data.current_balance.balance
      });
    }
  }

  res.status(200).send('OK');
});

Notify customer of low balance

app.post('/webhooks/blnk', async (req, res) => {
  const { event, data } = req.body;

  if (event === 'balance.monitor') {
    if (data.description === 'Low balance alert') {
      await sendPushNotification({
        user_id: data.meta_data.user_id,
        title: 'Low Balance',
        message: `Your wallet balance is below $${data.condition.value}. Please top up.`
      });
    }
  }

  res.status(200).send('OK');
});

Handle rejected transactions

app.post('/webhooks/blnk', async (req, res) => {
  const { event, data } = req.body;

  if (event === 'transaction.rejected') {
    const reason = data.meta_data.blnk_rejection_reason;
    
    // Log for debugging
    console.error('Transaction rejected:', {
      transaction_id: data.transaction_id,
      reason: reason
    });

    // Notify user
    await notifyUser({
      user_id: data.meta_data.user_id,
      message: `Payment failed: ${reason}`
    });
  }

  res.status(200).send('OK');
});

Best practices

Verify signatures

Always verify webhook signatures to prevent spoofing

Return 200 quickly

Process webhooks asynchronously and return 200 immediately

Implement idempotency

Handle duplicate webhooks gracefully using event IDs

Use HTTPS

Only accept webhooks over HTTPS in production

Log all events

Keep logs of webhook events for debugging

Monitor failures

Set up alerts for webhook endpoint failures

Webhook retry logic

Blnk uses Asynq for reliable webhook delivery:
  • Automatic retries: Failed webhooks are retried with exponential backoff
  • Max retries: Configurable (default: 3 attempts)
  • Queue persistence: Webhooks are queued in Redis for reliability

Implementing idempotency

Handle duplicate webhooks:
const processedEvents = new Set();

app.post('/webhooks/blnk', async (req, res) => {
  const { event, data } = req.body;
  const eventKey = `${event}_${data.transaction_id}_${data.created_at}`;

  // Check if already processed
  if (processedEvents.has(eventKey)) {
    console.log('Duplicate webhook ignored:', eventKey);
    return res.status(200).send('OK');
  }

  // Mark as processed
  processedEvents.add(eventKey);

  // Process webhook
  await handleWebhookEvent(event, data);

  res.status(200).send('OK');
});
For production, use a database or cache instead of in-memory Set.

Troubleshooting

Webhooks not being received

Checklist:
  1. Verify webhook URL is publicly accessible
  2. Check firewall allows incoming POST requests
  3. Ensure endpoint returns 200 status
  4. Review Blnk logs for webhook errors
  5. Test endpoint with curl:
curl -X POST https://your-app.com/webhooks/blnk \
  -H "Content-Type: application/json" \
  -d '{"event": "test", "data": {}}'

Signature verification failing

Common issues:
  1. Using wrong secret key
  2. Body parsing modifying payload
  3. Timestamp drift (check server time)
  4. Character encoding issues
Solution: Log the raw body and computed signature:
const rawSignature = req.headers['x-blnk-signature'];
const computedSignature = crypto
  .createHmac('sha256', secret)
  .update(signedPayload)
  .digest('hex');

console.log('Received:', rawSignature);
console.log('Computed:', computedSignature);

Webhook timeouts

Problem: Endpoint takes too long to respond Solution: Process asynchronously:
app.post('/webhooks/blnk', async (req, res) => {
  // Return 200 immediately
  res.status(200).send('OK');

  // Process in background
  processWebhookAsync(req.body).catch(err => {
    console.error('Webhook processing error:', err);
  });
});

async function processWebhookAsync(payload) {
  // Heavy processing here
  await updateDatabase(payload);
  await sendNotifications(payload);
}

Next steps

Balance Monitoring

Set up balance monitors to trigger webhooks

Authentication

Secure your webhook endpoints with API keys

Build docs developers (and LLMs) love