Webhooks
Receive real-time notifications for payment and payout events
Webhooks allow you to receive real-time notifications when events occur in your Snippe account, such as payment completions or payout status changes.
Overview
When an event occurs (e.g., a payment is completed), Snippe sends an HTTP POST request to your configured webhook URL with details about the event.
Webhook Flow
1. Event Occurs
└── Payment completed, payout failed, etc.
2. Snippe Sends Webhook
└── POST to your webhook URL
3. Your Server Responds
└── Return 2xx status code
4. Retry if Failed
└── Exponential backoff up to 5 attemptsWebhook Events
Payment Events
| Event | Description |
|---|---|
payment.completed | Payment was successfully processed |
payment.failed | Payment failed (declined, timeout, etc.) |
payment.voided | Payment was cancelled before completion |
payment.expired | Payment expired (default: 1 hour) |
Payout Events
| Event | Description |
|---|---|
payout.completed | Payout was successfully delivered |
payout.failed | Payout failed to process |
payout.reversed | Payout was reversed after completion |
Setting Up Webhooks
On Payment Creation
Provide a webhook_url when creating a payment intent:
{
"payment_type": "mobile",
"details": {
"amount": 50000,
"currency": "TZS"
},
"phone_number": "+255712345678",
"webhook_url": "https://yoursite.com/webhooks/snippe"
}Requirements
- HTTPS Required: Webhook URLs must use HTTPS in production
- Respond Quickly: Return a 2xx status within 30 seconds
- Idempotent Handling: You may receive the same event multiple times
Webhook Payload
Current Format (API Version 2026-01-25)
Request vs. webhook format difference: When creating a payment, amount is a plain integer (e.g., 50000). In webhook payloads, data.amount is an object: {"value": 50000, "currency": "TZS"}. Make sure your webhook handler parses data.amount.value and data.amount.currency separately.
Events use an envelope structure with event metadata:
{
"id": "evt_a1b2c3d4e5f6g7h8i9j0",
"type": "payment.completed",
"api_version": "2026-01-25",
"created_at": "2026-01-24T10:30:00Z",
"data": {
"reference": "pi_a1b2c3d4e5f6",
"external_reference": "SEL123456789",
"status": "completed",
"amount": {
"value": 50000,
"currency": "TZS"
},
"settlement": {
"gross": {
"value": 50000,
"currency": "TZS"
},
"fees": {
"value": 1000,
"currency": "TZS"
},
"net": {
"value": 49000,
"currency": "TZS"
}
},
"channel": {
"type": "mobile_money",
"provider": "mpesa"
},
"customer": {
"phone": "+255712345678",
"name": "John Doe",
"email": "john@example.com"
},
"metadata": {
"order_id": "ORD-12345"
},
"completed_at": "2026-01-24T10:30:00Z"
}
}If the customer paid through a payment link with a ?meta= query parameter,
the decoded JSON appears at data.metadata.url_metadata. Use it to identify
what the payment was for without a separate lookup.
Legacy Format (API Version 2026-01-01)
Flat structure for backwards compatibility:
{
"event": "payment.completed",
"reference": "pi_a1b2c3d4e5f6",
"external_reference": "SEL123456789",
"status": "completed",
"amount": {
"value": 50000,
"currency": "TZS"
},
"payment_channel": "MPESA",
"payment_fee": 1000,
"customer": {
"phone": "+255712345678",
"name": "John Doe",
"email": "john@example.com"
},
"metadata": {
"order_id": "ORD-12345"
},
"completed_at": "2026-01-24T10:30:00Z",
"created_at": "2026-01-24T10:00:00Z",
"timestamp": 1737711000
}Webhook Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | Snippe-Webhook/1.0 |
X-Webhook-Event | Event type (e.g., payment.completed) |
X-Webhook-Timestamp | Unix timestamp of the event |
X-Webhook-Signature | HMAC-SHA256 signature (if configured) |
Signature Verification
Verify webhook authenticity by checking the signature. This prevents attackers from spoofing webhook requests to your server.
Always verify webhook signatures in production to ensure requests are from Snippe.
Getting Your Signing Key
Every Snippe account is automatically assigned a unique webhook signing key. You can find it in two ways:
Via Dashboard: Navigate to Settings → Webhook Secret to view your key.
Via API:
GET /api/v1/settings/webhook-secret
Authorization: Bearer <your_jwt_token>Response:
{
"webhook_secret": "whsec_95dd8318fd25e7b0c541b1d604df978880477dfac5d5c1c4e933d02187c0f8d1"
}To regenerate your signing key (this invalidates the previous key immediately):
POST /api/v1/settings/webhook-secret/regenerate
Authorization: Bearer <your_jwt_token>Important: Store your signing key securely. Treat it like a password — never expose it in client-side code, public repositories, or logs.
How Signatures Work
- Snippe creates a message by concatenating the Unix timestamp and JSON body:
{timestamp}.{payload} - Computes HMAC-SHA256 using your signing key
- Hex-encodes the result (64 characters)
- Sends the signature in the
X-Webhook-Signatureheader
X-Webhook-Signature = hex(HMAC-SHA256(signing_key, "{timestamp}.{json_body}"))Verification Steps
- Extract
X-Webhook-TimestampandX-Webhook-Signaturefrom the request headers - Read the raw request body as a string (do not parse or re-serialize — the exact bytes must match)
- Construct the message:
{timestamp}.{raw_body} - Compute HMAC-SHA256 with your signing key and hex-encode the result
- Compare your computed signature with the
X-Webhook-Signatureheader using constant-time comparison (prevents timing attacks) - Recommended: Reject requests where the timestamp is more than 5 minutes old (prevents replay attacks)
Critical: You must use the raw request body exactly as received. Do NOT parse the JSON and re-serialize it (e.g., JSON.stringify(JSON.parse(body))). Re-serialization can change whitespace or key ordering, causing signature verification to fail.
Code Examples
const crypto = require("crypto");
function verifyWebhook(payload, headers, signingKey) {
const timestamp = headers["x-webhook-timestamp"];
const signature = headers["x-webhook-signature"];
// Prevent replay attacks (optional: reject if > 5 minutes old)
const eventTime = parseInt(timestamp, 10);
const currentTime = Math.floor(Date.now() / 1000);
if (currentTime - eventTime > 300) {
throw new Error("Webhook timestamp too old");
}
// Compute expected signature
const message = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac("sha256", signingKey)
.update(message)
.digest("hex");
// Constant-time comparison
if (
!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
)
) {
throw new Error("Invalid webhook signature");
}
return JSON.parse(payload);
}
// Express.js example
app.post(
"/webhooks/snippe",
express.raw({ type: "application/json" }),
(req, res) => {
try {
const event = verifyWebhook(
req.body.toString(),
req.headers,
process.env.SNIPPE_WEBHOOK_SECRET,
);
// Process the event
switch (event.type) {
case "payment.completed":
handlePaymentCompleted(event.data);
break;
case "payment.failed":
handlePaymentFailed(event.data);
break;
// ... other events
}
res.status(200).send("OK");
} catch (err) {
console.error("Webhook error:", err.message);
res.status(400).send("Invalid signature");
}
},
);import hmac
import hashlib
import json
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_KEY = os.environ.get('SNIPPE_WEBHOOK_SECRET')
def verify_webhook(payload, headers, signing_key):
timestamp = headers.get('X-Webhook-Timestamp')
signature = headers.get('X-Webhook-Signature')
# Prevent replay attacks
event_time = int(timestamp)
current_time = int(time.time())
if current_time - event_time > 300:
raise ValueError('Webhook timestamp too old')
# Compute expected signature
message = f"{timestamp}.{payload}"
expected_signature = hmac.new(
signing_key.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(signature, expected_signature):
raise ValueError('Invalid webhook signature')
return json.loads(payload)
@app.route('/webhooks/snippe', methods=['POST'])
def handle_webhook():
try:
event = verify_webhook(
request.data.decode(),
request.headers,
SIGNING_KEY
)
event_type = event.get('type')
if event_type == 'payment.completed':
handle_payment_completed(event['data'])
elif event_type == 'payment.failed':
handle_payment_failed(event['data'])
# ... other events
return 'OK', 200
except ValueError as e:
print(f'Webhook error: {e}')
abort(400)<?php
function verifyWebhook($payload, $headers, $signingKey) {
$timestamp = $headers['X-Webhook-Timestamp'] ?? '';
$signature = $headers['X-Webhook-Signature'] ?? '';
// Prevent replay attacks
$eventTime = intval($timestamp);
$currentTime = time();
if ($currentTime - $eventTime > 300) {
throw new Exception('Webhook timestamp too old');
}
// Compute expected signature
$message = "{$timestamp}.{$payload}";
$expectedSignature = hash_hmac('sha256', $message, $signingKey);
// Constant-time comparison
if (!hash_equals($signature, $expectedSignature)) {
throw new Exception('Invalid webhook signature');
}
return json_decode($payload, true);
}
// Usage
$payload = file_get_contents('php://input');
$headers = getallheaders();
try {
$event = verifyWebhook($payload, $headers, $_ENV['SNIPPE_WEBHOOK_SECRET']);
switch ($event['type']) {
case 'payment.completed':
handlePaymentCompleted($event['data']);
break;
case 'payment.failed':
handlePaymentFailed($event['data']);
break;
}
http_response_code(200);
echo 'OK';
} catch (Exception $e) {
http_response_code(400);
echo 'Invalid signature';
}package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func verifyWebhook(payload []byte, timestamp, signature, signingKey string) (map[string]interface{}, error) {
// Prevent replay attacks
eventTime, _ := strconv.ParseInt(timestamp, 10, 64)
currentTime := time.Now().Unix()
if currentTime-eventTime > 300 {
return nil, fmt.Errorf("webhook timestamp too old")
}
// Compute expected signature
message := fmt.Sprintf("%s.%s", timestamp, string(payload))
mac := hmac.New(sha256.New, []byte(signingKey))
mac.Write([]byte(message))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
return nil, fmt.Errorf("invalid webhook signature")
}
var event map[string]interface{}
json.Unmarshal(payload, &event)
return event, nil
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
payload, _ := io.ReadAll(r.Body)
event, err := verifyWebhook(
payload,
r.Header.Get("X-Webhook-Timestamp"),
r.Header.Get("X-Webhook-Signature"),
os.Getenv("SNIPPE_WEBHOOK_SECRET"),
)
if err != nil {
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
// Process event
eventType := event["type"].(string)
data := event["data"].(map[string]interface{})
switch eventType {
case "payment.completed":
handlePaymentCompleted(data)
case "payment.failed":
handlePaymentFailed(data)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}Retry Logic
If your endpoint returns a non-2xx status or times out, Snippe will retry the webhook with exponential backoff.
Retry Schedule
| Attempt | Delay After Failure |
|---|---|
| 1 | Immediate |
| 2 | 3 minutes |
| 3 | 6 minutes |
| 4 | 12 minutes |
| 5 | 24 minutes |
After 5 failed attempts, the webhook is marked as abandoned.
Retry Status Codes
| Status | Description |
|---|---|
pending | Newly created, not yet attempted |
retrying | Currently being retried |
success | Successfully delivered (2xx response) |
failed | Delivery failed, will retry |
abandoned | Max retries exceeded |
Best Practices
Return Quickly
Return a 2xx response immediately, then process the event asynchronously:
app.post("/webhooks/snippe", (req, res) => {
// Acknowledge receipt immediately
res.status(200).send("OK");
// Process asynchronously
setImmediate(() => {
processWebhook(req.body);
});
});Handle Duplicates
The same event may be delivered multiple times. Use the event id or payment reference to deduplicate:
async function handleWebhook(event) {
const processed = await db.webhookEvents.findOne({
eventId: event.id,
});
if (processed) {
console.log("Duplicate event, skipping");
return;
}
// Process the event
await processEvent(event);
// Mark as processed
await db.webhookEvents.insert({
eventId: event.id,
processedAt: new Date(),
});
}Verify Signatures
Always verify the webhook signature in production to prevent spoofed requests.
Use HTTPS
Always use HTTPS for your webhook endpoints to ensure the payload is encrypted in transit.
Implement Logging
Log all webhook events for debugging and audit purposes:
app.post("/webhooks/snippe", (req, res) => {
console.log("Webhook received:", {
event: req.headers["x-webhook-event"],
timestamp: req.headers["x-webhook-timestamp"],
reference: req.body.data?.reference,
});
// ... process webhook
});Testing Webhooks
Local Development
Use a tunneling service to expose your local server:
# Using ngrok
ngrok http 3000
# Use the HTTPS URL for your webhook_url
# https://abc123.ngrok.io/webhooks/snippeWebhook Simulator
You can simulate webhook events using cURL:
# Simulate payment.completed event
curl -X POST https://yoursite.com/webhooks/snippe \
-H "Content-Type: application/json" \
-H "X-Webhook-Event: payment.completed" \
-H "X-Webhook-Timestamp: $(date +%s)" \
-d '{
"id": "evt_test123",
"type": "payment.completed",
"api_version": "2026-01-25",
"created_at": "2026-01-24T10:30:00Z",
"data": {
"reference": "pi_test123",
"status": "completed",
"amount": {
"value": 50000,
"currency": "TZS"
}
}
}'Event Payload Reference
payment.completed
{
"id": "evt_abc123",
"type": "payment.completed",
"api_version": "2026-01-25",
"created_at": "2026-01-24T10:30:00Z",
"data": {
"reference": "pi_a1b2c3d4e5f6",
"external_reference": "SEL123456789",
"status": "completed",
"amount": {
"value": 50000,
"currency": "TZS"
},
"settlement": {
"gross": { "value": 50000, "currency": "TZS" },
"fees": { "value": 1000, "currency": "TZS" },
"net": { "value": 49000, "currency": "TZS" }
},
"channel": {
"type": "mobile_money",
"provider": "mpesa"
},
"customer": {
"phone": "+255712345678",
"name": "John Doe",
"email": "john@example.com"
},
"metadata": {},
"completed_at": "2026-01-24T10:30:00Z"
}
}payment.failed
{
"id": "evt_def456",
"type": "payment.failed",
"api_version": "2026-01-25",
"created_at": "2026-01-24T10:30:00Z",
"data": {
"reference": "pi_x9y8z7w6v5u4",
"status": "failed",
"failure_reason": "Transaction declined by user",
"amount": {
"value": 50000,
"currency": "TZS"
},
"channel": {
"type": "mobile_money",
"provider": "airtel"
},
"customer": {
"phone": "+255712345678"
},
"metadata": {},
"failed_at": "2026-01-24T10:30:00Z"
}
}Troubleshooting
Webhook Not Received
- Verify your
webhook_urlis correct and accessible - Check your server logs for incoming requests
- Ensure your firewall allows requests from Snippe IPs
- Verify HTTPS certificate is valid
Signature Verification Failed
- Ensure you're using the correct signing key
- Verify you're reading the raw request body (not parsed JSON)
- Check timestamp format matches expected format
- Ensure no middleware modifies the request body
Repeated Retries
- Ensure your endpoint returns 2xx status code
- Check response time is under 30 seconds
- Verify your server isn't rate-limiting webhook requests
Security Checklist
Before going to production, ensure your webhook integration follows these security practices:
- Always verify signatures — Never trust a webhook payload without verifying the
X-Webhook-Signatureheader against your signing key - Use constant-time comparison — Do not use
==to compare signatures; usecrypto.timingSafeEqual(Node.js),hmac.compare_digest(Python),hash_equals(PHP), orhmac.Equal(Go) - Validate timestamp freshness — Reject webhooks where
X-Webhook-Timestampis more than 5 minutes old to prevent replay attacks - Read the raw body — Always verify against the raw request body, not a parsed-and-reserialized version (JSON key ordering or whitespace changes will break the signature)
- Use HTTPS — Your webhook endpoint must use HTTPS so payloads are encrypted in transit
- Handle duplicates — Use the event
idor paymentreferenceto deduplicate, as the same event may be delivered more than once - Respond with 2xx quickly — Return a 200 status immediately, then process the event asynchronously. Snippe will retry if your endpoint takes longer than 30 seconds
- Keep your signing key secret — Store it in environment variables or a secret manager, never in source code or client-side applications
- Rotate keys if compromised — Use
POST /api/v1/settings/webhook-secret/regenerateto issue a new key immediately