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 attempts

Webhook Events

Payment Events

EventDescription
payment.completedPayment was successfully processed
payment.failedPayment failed (declined, timeout, etc.)
payment.voidedPayment was cancelled before completion
payment.expiredPayment expired (default: 1 hour)

Payout Events

EventDescription
payout.completedPayout was successfully delivered
payout.failedPayout failed to process
payout.reversedPayout 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:

HeaderDescription
Content-Typeapplication/json
User-AgentSnippe-Webhook/1.0
X-Webhook-EventEvent type (e.g., payment.completed)
X-Webhook-TimestampUnix timestamp of the event
X-Webhook-SignatureHMAC-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

  1. Snippe creates a message by concatenating the Unix timestamp and JSON body: {timestamp}.{payload}
  2. Computes HMAC-SHA256 using your signing key
  3. Hex-encodes the result (64 characters)
  4. Sends the signature in the X-Webhook-Signature header
X-Webhook-Signature = hex(HMAC-SHA256(signing_key, "{timestamp}.{json_body}"))

Verification Steps

  1. Extract X-Webhook-Timestamp and X-Webhook-Signature from the request headers
  2. Read the raw request body as a string (do not parse or re-serialize — the exact bytes must match)
  3. Construct the message: {timestamp}.{raw_body}
  4. Compute HMAC-SHA256 with your signing key and hex-encode the result
  5. Compare your computed signature with the X-Webhook-Signature header using constant-time comparison (prevents timing attacks)
  6. 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

AttemptDelay After Failure
1Immediate
23 minutes
36 minutes
412 minutes
524 minutes

After 5 failed attempts, the webhook is marked as abandoned.

Retry Status Codes

StatusDescription
pendingNewly created, not yet attempted
retryingCurrently being retried
successSuccessfully delivered (2xx response)
failedDelivery failed, will retry
abandonedMax 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/snippe

Webhook 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

  1. Verify your webhook_url is correct and accessible
  2. Check your server logs for incoming requests
  3. Ensure your firewall allows requests from Snippe IPs
  4. Verify HTTPS certificate is valid

Signature Verification Failed

  1. Ensure you're using the correct signing key
  2. Verify you're reading the raw request body (not parsed JSON)
  3. Check timestamp format matches expected format
  4. Ensure no middleware modifies the request body

Repeated Retries

  1. Ensure your endpoint returns 2xx status code
  2. Check response time is under 30 seconds
  3. 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-Signature header against your signing key
  • Use constant-time comparison — Do not use == to compare signatures; use crypto.timingSafeEqual (Node.js), hmac.compare_digest (Python), hash_equals (PHP), or hmac.Equal (Go)
  • Validate timestamp freshness — Reject webhooks where X-Webhook-Timestamp is 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 id or payment reference to 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/regenerate to issue a new key immediately

On this page