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
- You register a webhook endpoint URL in your dashboard
- When a payment event occurs, Harpoon sends an HTTP POST request to your URL
- Your server receives the payload and processes the event
- You respond with a 2xx status code to acknowledge receipt
Setting Up Webhooks
Via Dashboard
- Go to Settings → Webhooks
- Click “Add Webhook Endpoint”
- Enter your endpoint URL (must be HTTPS)
- Select the events you want to receive
- Click “Create”
Via API
Create webhooks programmatically using your API key:
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
{
"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
secretis 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
| Event | Description |
|---|---|
transaction.completed | Payment was successfully verified (SUCCESS, PARTIAL, or OVERPAID) |
transaction.failed | Payment verification failed |
transaction.expired | Payment request expired without payment |
transaction.disputed | Transaction is disputed and requires review |
transaction.resolved | Disputed transaction was resolved |
transaction.refunded | Payment was refunded to customer |
transaction.cancelled | Transaction was cancelled |
Webhook Payload
All webhook payloads follow this structure:
{
"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).
{
"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.
{
"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:
| Header | Description |
|---|---|
X-Harpoon-Signature | HMAC-SHA256 signature (format: sha256={hex}) |
X-Harpoon-Timestamp | Unix timestamp when the webhook was sent |
X-Harpoon-Webhook-ID | Unique ID for this webhook delivery |
Verification Steps
- Extract the timestamp from
X-Harpoon-Timestampheader - Extract the signature from
X-Harpoon-Signatureheader (format:sha256={hex}) - Construct the signed payload:
{timestamp}.{request_body} - Compute HMAC-SHA256 using your webhook secret
- Compare:
sha256={computed_hex}matches the received signature - Verify the timestamp is within 5 minutes of current time
Example: Node.js
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
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 webhookHandling Webhooks
Best Practices
- Respond quickly - Return a 2xx response within 30 seconds
- Process asynchronously - Queue heavy processing for background workers
- Handle duplicates - Use idempotent operations
Example Handler
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:
| Attempt | Delay After Failure |
|---|---|
| 1st retry | 5 minutes |
| 2nd retry | 15 minutes |
| 3rd retry | 1 hour |
| 4th retry | 4 hours |
| 5th retry | 8 hours |
| 6th retry | 12 hours |
| 7th retry | 24 hours |
| 8th retry | 24 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
GET /v1/webhooks
Authorization: Bearer hpn_live_sk_xxxxxxxxxxxxxUpdate Webhook
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
DELETE /v1/webhooks/{webhook_id}
Authorization: Bearer hpn_live_sk_xxxxxxxxxxxxxTest Webhook
POST /v1/webhooks/{webhook_id}/test
Authorization: Bearer hpn_live_sk_xxxxxxxxxxxxxFor local development, use ngrok or similar to expose your local server.
Next Steps
- Handle errors - Understand error codes and responses
- Payments API - Create and verify payments