Webhooks

Webhooks

Receive real-time notifications when payment events occur.

Overview

Webhooks allow Harpoon to push real-time notifications to your server when events happen, such as when a payment is verified. Instead of polling the API for updates, webhooks deliver events to your endpoint as they occur.

How Webhooks Work

  1. You register a webhook endpoint URL in your dashboard
  2. When a payment event occurs, Harpoon sends an HTTP POST request to your URL
  3. Your server receives the payload and processes the event
  4. You respond with a 2xx status code to acknowledge receipt

Setting Up Webhooks

Via Dashboard

  1. Go to Settings → Webhooks
  2. Click “Add Webhook Endpoint”
  3. Enter your endpoint URL (must be HTTPS)
  4. Select the events you want to receive
  5. Click “Create”

Via API

Create webhooks programmatically using your API key:

Bash
POST /v1/webhooks
Authorization: Bearer hpn_live_sk_xxxxxxxxxxxxx
Content-Type: application/json

{
  "url": "https://your-server.com/webhooks/harpoon",
  "events": ["transaction.completed", "transaction.failed", "transaction.expired"],
  "description": "Production webhook"
}

Required scope: webhooks:manage

Response

JSON
{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "url": "https://your-server.com/webhooks/harpoon",
    "events": ["transaction.completed", "transaction.failed", "transaction.expired"],
    "secret": "whsec_xxxxxxxxxxxxxxxxxxxxx",
    "is_active": true,
    "created_at": "2024-01-08T12:00:00Z"
  },
  "message": "Webhook endpoint created. Save the secret now - it won't be shown again."
}

Note: The secret is only shown once. Save it immediately for signature verification.

Sandbox mode is coming later. Today every webhook endpoint receives every matching event. A dedicated sandbox environment — with isolated webhook endpoints and hpn_test_* API keys — will be rolled out in a future release.


Webhook Events

EventDescription
transaction.completedPayment was successfully verified (SUCCESS, PARTIAL, or OVERPAID)
transaction.failedPayment verification failed
transaction.expiredPayment request expired without payment
transaction.disputedTransaction is disputed and requires review
transaction.resolvedDisputed transaction was resolved
transaction.refundedPayment was refunded to customer
transaction.cancelledTransaction was cancelled

Webhook Payload

All webhook payloads follow this structure:

JSON
{
  "event": "transaction.completed",
  "webhook_id": "whk_abc123xyz",
  "timestamp": "2024-01-08T12:05:32Z",
  "data": {
    // Event-specific data
  }
}

transaction.completed

Sent when a payment is successfully verified (includes SUCCESS, PARTIAL, and OVERPAID statuses).

JSON
{
  "event": "transaction.completed",
  "webhook_id": "whk_abc123xyz",
  "timestamp": "2024-01-08T12:05:32Z",
  "data": {
    "reference": "ref_abc123xyz789",
    "hpn_code": "KXRT5M2PA3",
    "status": "SUCCESS",
    "expected_amount": "150.00",
    "actual_amount": "150.00",
    "difference": null,
    "difference_type": "EXACT",
    "currency": "GHS",
    "payer_phone": "+233244123456",
    "telco_provider": "mtn",
    "telco_transaction_id": "1234567890",
    "meta": {
      "order_id": "1234",
      "customer_email": "[email protected]"
    },
    "client_reference": "order_1234"
  }
}

transaction.expired

Sent when a payment request expires without receiving payment.

JSON
{
  "event": "transaction.expired",
  "webhook_id": "whk_ghi789def",
  "timestamp": "2024-01-08T13:00:00Z",
  "data": {
    "reference": "ref_abc123xyz789",
    "hpn_code": "KXRT5M2PA3",
    "status": "EXPIRED",
    "expected_amount": "150.00",
    "actual_amount": null,
    "difference": null,
    "difference_type": null,
    "currency": "GHS",
    "payer_phone": "+233244123456",
    "telco_provider": null,
    "telco_transaction_id": null,
    "meta": {},
    "client_reference": "order_1234"
  }
}

Verifying Webhook Signatures

All webhooks include a signature for verification. Always verify signatures to ensure webhooks came from Harpoon.

Headers

Webhooks include these headers for signature verification:

HeaderDescription
X-Harpoon-SignatureHMAC-SHA256 signature (format: sha256={hex})
X-Harpoon-TimestampUnix timestamp when the webhook was sent
X-Harpoon-Webhook-IDUnique ID for this webhook delivery

Verification Steps

  1. Extract the timestamp from X-Harpoon-Timestamp header
  2. Extract the signature from X-Harpoon-Signature header (format: sha256={hex})
  3. Construct the signed payload: {timestamp}.{request_body}
  4. Compute HMAC-SHA256 using your webhook secret
  5. Compare: sha256={computed_hex} matches the received signature
  6. Verify the timestamp is within 5 minutes of current time

Example: Node.js

JavaScript
const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, timestamp, secret) {
  // Check timestamp is within 5 minutes
  const currentTime = Math.floor(Date.now() / 1000);
  if (Math.abs(currentTime - timestamp) > 300) {
    throw new Error('Webhook timestamp too old');
  }

  // Extract hex from signature (format: sha256={hex})
  const receivedSig = signature.replace('sha256=', '');

  // Compute expected signature
  const signedPayload = timestamp + '.' + payload;
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Compare signatures (constant-time)
  if (!crypto.timingSafeEqual(
    Buffer.from(receivedSig),
    Buffer.from(expectedSig)
  )) {
    throw new Error('Invalid webhook signature');
  }

  return true;
}

// Usage in Express handler:
app.post('/webhooks/harpoon', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-harpoon-signature'];
  const timestamp = parseInt(req.headers['x-harpoon-timestamp'], 10);
  const payload = req.body.toString();

  verifyWebhookSignature(payload, signature, timestamp, process.env.WEBHOOK_SECRET);
  // ... process webhook
});

Example: Python

Python
import hmac
import hashlib
import time

def verify_webhook_signature(payload: str, signature: str, timestamp: int, secret: str) -> bool:
    # Check timestamp is within 5 minutes
    current_time = int(time.time())
    if abs(current_time - timestamp) > 300:
        raise ValueError('Webhook timestamp too old')

    # Extract hex from signature (format: sha256={hex})
    received_sig = signature.replace('sha256=', '')

    # Compute expected signature
    signed_payload = f"{timestamp}.{payload}"
    expected_sig = hmac.new(
        secret.encode(),
        signed_payload.encode(),
        hashlib.sha256
    ).hexdigest()

    # Compare signatures (constant-time)
    if not hmac.compare_digest(received_sig, expected_sig):
        raise ValueError('Invalid webhook signature')

    return True

# Usage in Flask:
@app.route('/webhooks/harpoon', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Harpoon-Signature')
    timestamp = int(request.headers.get('X-Harpoon-Timestamp'))
    payload = request.get_data(as_text=True)

    verify_webhook_signature(payload, signature, timestamp, os.environ['WEBHOOK_SECRET'])
    # ... process webhook

Handling Webhooks

Best Practices

  1. Respond quickly - Return a 2xx response within 30 seconds
  2. Process asynchronously - Queue heavy processing for background workers
  3. Handle duplicates - Use idempotent operations

Example Handler

JavaScript
const express = require('express');
const app = express();

app.post('/webhooks/harpoon', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-harpoon-signature'];
  const payload = req.body.toString();

  try {
    // Verify signature
    verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET);

    // Parse and process event
    const event = JSON.parse(payload);

    switch (event.event) {
      case 'transaction.completed':
        // Check the specific status
        if (event.data.status === 'SUCCESS') {
          fulfillOrder(event.data.meta.order_id);
        } else if (event.data.status === 'PARTIAL') {
          notifyPartialPayment(event.data);
        } else if (event.data.status === 'OVERPAID') {
          fulfillOrderAndLogOverpayment(event.data);
        }
        break;
      case 'transaction.failed':
        // Handle failed payment
        notifyPaymentFailed(event.data);
        break;
      case 'transaction.expired':
        // Cancel the order
        cancelOrder(event.data.meta.order_id);
        break;
    }

    res.status(200).json({ received: true });
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(400).json({ error: error.message });
  }
});

Retry Policy

If your endpoint returns a non-2xx status code or times out, Harpoon will retry the webhook with exponential backoff:

AttemptDelay After Failure
1st retry5 minutes
2nd retry15 minutes
3rd retry1 hour
4th retry4 hours
5th retry8 hours
6th retry12 hours
7th retry24 hours
8th retry24 hours

After 8 failed attempts (approximately 44 hours total), the webhook is added to the dead letter queue. You can view failed webhooks and manually replay them from your dashboard under Settings → Webhooks → Dead Letters.


Managing Webhooks

All webhook management endpoints require the webhooks:manage scope.

List Webhooks

Bash
GET /v1/webhooks
Authorization: Bearer hpn_live_sk_xxxxxxxxxxxxx

Update Webhook

Bash
PUT /v1/webhooks/{webhook_id}
Authorization: Bearer hpn_live_sk_xxxxxxxxxxxxx
Content-Type: application/json

{
  "url": "https://new-server.com/webhooks/harpoon",
  "events": ["transaction.completed", "transaction.failed"]
}

Delete Webhook

Bash
DELETE /v1/webhooks/{webhook_id}
Authorization: Bearer hpn_live_sk_xxxxxxxxxxxxx

Test Webhook

Bash
POST /v1/webhooks/{webhook_id}/test
Authorization: Bearer hpn_live_sk_xxxxxxxxxxxxx

For local development, use ngrok or similar to expose your local server.

Next Steps