Keplars

Webhooks

Receive real-time notifications about email delivery events

Get real-time notifications when emails are processed, delivered, bounced, opened, clicked, or flagged through webhook endpoints.

Real-time Events: Webhooks notify your application instantly when email events occur, enabling responsive user experiences.

Setup Webhooks

Access Webhook Settings

  1. Sign in to dash.keplars.com
  2. Navigate to Webhooks section
  3. Click Add New Webhook

Configure Webhook

  1. Enter your webhook endpoint URL
  2. Select events to receive across processing, delivery, engagement, compliance, and warning categories
  3. Add optional webhook secret for security
  4. Click Create Webhook

Test Webhook

  1. Click Test Endpoint to send sample event
  2. Verify your endpoint receives the event
  3. Check that webhook appears as active

Webhook Events

CategoryEventDescription
Processingemail.sentEmail processed and sent to the mail server
Processingemail.queuedEmail added to the delivery queue
Deliveryemail.deliveredEmail successfully delivered to recipient inbox
Deliveryemail.bouncedEmail bounced by recipient server (hard or soft)
Deliveryemail.failedEmail delivery failed
Deliveryemail.rejectedEmail rejected by spam filters or reputation checks
Engagementemail.openedRecipient opened the email (requires tracking pixel)
Engagementemail.clickedRecipient clicked a link (requires click tracking)
Complianceemail.spamEmail marked as spam by recipient
Complianceemail.complaintRecipient filed a spam complaint
Warningemail.warningEmail sent with warnings (reputation or content policy)

Event Payload

All webhook events follow this structure:

{
  "id": "evt_456_1705312500",
  "event_type": "email.delivered",
  "email_id": 456,
  "recipient_email": "[email protected]",
  "subject": "Welcome to Keplars!",
  "status": "delivered",
  "timestamp": "2024-01-15T10:35:00Z",
  "workspace_id": "ws_abc123"
}

Bounce, failure, and warning events include an additional reason field:

{
  "id": "evt_456_1705312501",
  "event_type": "email.bounced",
  "email_id": 456,
  "recipient_email": "[email protected]",
  "subject": "Welcome to Keplars!",
  "status": "bounced",
  "timestamp": "2024-01-15T10:36:00Z",
  "reason": "550 5.1.1 User unknown",
  "workspace_id": "ws_abc123"
}

Handle Webhooks

app.post('/webhooks/keplars', (req, res) => {
  const event = req.body;

  switch (event.event_type) {
    case 'email.sent':
      console.log(`Email ${event.email_id} sent to ${event.recipient_email}`);
      break;
    case 'email.queued':
      console.log(`Email ${event.email_id} queued for delivery`);
      break;
    case 'email.delivered':
      console.log(`Email ${event.email_id} delivered to ${event.recipient_email}`);
      break;
    case 'email.bounced':
    case 'email.failed':
    case 'email.rejected':
      console.log(`Delivery failed for ${event.recipient_email}: ${event.reason}`);
      break;
    case 'email.opened':
      console.log(`Email ${event.email_id} opened by ${event.recipient_email}`);
      break;
    case 'email.clicked':
      console.log(`Link clicked in email ${event.email_id}`);
      break;
    case 'email.spam':
    case 'email.complaint':
      console.log(`Compliance event for ${event.recipient_email}: ${event.event_type}`);
      break;
    case 'email.warning':
      console.log(`Warning for email ${event.email_id}: ${event.reason}`);
      break;
  }

  res.status(200).json({ received: true });
});
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/webhooks/keplars', methods=['POST'])
def handle_webhook():
    event = request.get_json()
    event_type = event.get('event_type')
    email_id = event.get('email_id')
    recipient = event.get('recipient_email')

    if event_type == 'email.sent':
        print(f"Email {email_id} sent to {recipient}")
    elif event_type == 'email.queued':
        print(f"Email {email_id} queued for delivery")
    elif event_type == 'email.delivered':
        print(f"Email {email_id} delivered to {recipient}")
    elif event_type in ('email.bounced', 'email.failed', 'email.rejected'):
        print(f"Delivery failed for {recipient}: {event.get('reason')}")
    elif event_type == 'email.opened':
        print(f"Email {email_id} opened by {recipient}")
    elif event_type == 'email.clicked':
        print(f"Link clicked in email {email_id}")
    elif event_type in ('email.spam', 'email.complaint'):
        print(f"Compliance event for {recipient}: {event_type}")
    elif event_type == 'email.warning':
        print(f"Warning for email {email_id}: {event.get('reason')}")

    return jsonify({'received': True})
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
)

type WebhookEvent struct {
    ID             string  `json:"id"`
    EventType      string  `json:"event_type"`
    EmailID        int     `json:"email_id"`
    RecipientEmail string  `json:"recipient_email"`
    Subject        string  `json:"subject"`
    Status         string  `json:"status"`
    Reason         *string `json:"reason,omitempty"`
    WorkspaceID    string  `json:"workspace_id"`
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    var event WebhookEvent
    if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }

    reason := ""
    if event.Reason != nil {
        reason = *event.Reason
    }

    switch event.EventType {
    case "email.sent":
        fmt.Printf("Email %d sent to %s\n", event.EmailID, event.RecipientEmail)
    case "email.queued":
        fmt.Printf("Email %d queued for delivery\n", event.EmailID)
    case "email.delivered":
        fmt.Printf("Email %d delivered to %s\n", event.EmailID, event.RecipientEmail)
    case "email.bounced", "email.failed", "email.rejected":
        fmt.Printf("Delivery failed for %s: %s\n", event.RecipientEmail, reason)
    case "email.opened":
        fmt.Printf("Email %d opened by %s\n", event.EmailID, event.RecipientEmail)
    case "email.clicked":
        fmt.Printf("Link clicked in email %d\n", event.EmailID)
    case "email.spam", "email.complaint":
        fmt.Printf("Compliance event for %s: %s\n", event.RecipientEmail, event.EventType)
    case "email.warning":
        fmt.Printf("Warning for email %d: %s\n", event.EmailID, reason)
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
<?php
$payload = file_get_contents('php://input');
$event = json_decode($payload, true);

$eventType = $event['event_type'] ?? '';
$emailId   = $event['email_id'] ?? '';
$recipient = $event['recipient_email'] ?? '';
$reason    = $event['reason'] ?? '';

switch ($eventType) {
    case 'email.sent':
        error_log("Email {$emailId} sent to {$recipient}");
        break;
    case 'email.queued':
        error_log("Email {$emailId} queued for delivery");
        break;
    case 'email.delivered':
        error_log("Email {$emailId} delivered to {$recipient}");
        break;
    case 'email.bounced':
    case 'email.failed':
    case 'email.rejected':
        error_log("Delivery failed for {$recipient}: {$reason}");
        break;
    case 'email.opened':
        error_log("Email {$emailId} opened by {$recipient}");
        break;
    case 'email.clicked':
        error_log("Link clicked in email {$emailId}");
        break;
    case 'email.spam':
    case 'email.complaint':
        error_log("Compliance event for {$recipient}: {$eventType}");
        break;
    case 'email.warning':
        error_log("Warning for email {$emailId}: {$reason}");
        break;
}

http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['received' => true]);
require 'json'
require 'sinatra'

post '/webhooks/keplars' do
  payload = request.body.read
  event   = JSON.parse(payload)

  email_id  = event['email_id']
  recipient = event['recipient_email']
  reason    = event['reason']

  case event['event_type']
  when 'email.sent'
    puts "Email #{email_id} sent to #{recipient}"
  when 'email.queued'
    puts "Email #{email_id} queued for delivery"
  when 'email.delivered'
    puts "Email #{email_id} delivered to #{recipient}"
  when 'email.bounced', 'email.failed', 'email.rejected'
    puts "Delivery failed for #{recipient}: #{reason}"
  when 'email.opened'
    puts "Email #{email_id} opened by #{recipient}"
  when 'email.clicked'
    puts "Link clicked in email #{email_id}"
  when 'email.spam', 'email.complaint'
    puts "Compliance event for #{recipient}: #{event['event_type']}"
  when 'email.warning'
    puts "Warning for email #{email_id}: #{reason}"
  end

  content_type :json
  { received: true }.to_json
end
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;

[HttpPost("/webhooks/keplars")]
public IActionResult HandleWebhook([FromBody] JsonElement payload)
{
    var eventType     = payload.GetProperty("event_type").GetString();
    var emailId       = payload.GetProperty("email_id").GetInt32();
    var recipient     = payload.GetProperty("recipient_email").GetString();
    var reason        = payload.TryGetProperty("reason", out var r) ? r.GetString() : null;

    switch (eventType)
    {
        case "email.sent":
            Console.WriteLine($"Email {emailId} sent to {recipient}");
            break;
        case "email.delivered":
            Console.WriteLine($"Email {emailId} delivered to {recipient}");
            break;
        case "email.bounced":
        case "email.failed":
        case "email.rejected":
            Console.WriteLine($"Delivery failed for {recipient}: {reason}");
            break;
        case "email.opened":
            Console.WriteLine($"Email {emailId} opened by {recipient}");
            break;
        case "email.clicked":
            Console.WriteLine($"Link clicked in email {emailId}");
            break;
        case "email.spam":
        case "email.complaint":
            Console.WriteLine($"Compliance event for {recipient}: {eventType}");
            break;
        case "email.warning":
            Console.WriteLine($"Warning for email {emailId}: {reason}");
            break;
    }

    return Ok(new { received = true });
}
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
use serde::Deserialize;

#[derive(Deserialize)]
struct WebhookEvent {
    event_type: String,
    email_id: i64,
    recipient_email: String,
    reason: Option<String>,
}

pub async fn handle_webhook(Json(event): Json<WebhookEvent>) -> impl IntoResponse {
    let reason = event.reason.as_deref().unwrap_or("");
    match event.event_type.as_str() {
        "email.sent" => println!("Email {} sent to {}", event.email_id, event.recipient_email),
        "email.delivered" => println!("Email {} delivered to {}", event.email_id, event.recipient_email),
        "email.bounced" | "email.failed" | "email.rejected" => {
            println!("Delivery failed for {}: {}", event.recipient_email, reason);
        }
        "email.opened" => println!("Email {} opened by {}", event.email_id, event.recipient_email),
        "email.clicked" => println!("Link clicked in email {}", event.email_id),
        "email.spam" | "email.complaint" => {
            println!("Compliance event for {}: {}", event.recipient_email, event.event_type);
        }
        "email.warning" => println!("Warning for email {}: {}", event.email_id, reason),
        _ => {}
    }
    (StatusCode::OK, Json(serde_json::json!({ "received": true })))
}
#include <crow.h>
#include <nlohmann/json.hpp>
#include <iostream>

int main() {
    crow::SimpleApp app;

    CROW_ROUTE(app, "/webhooks/keplars").methods(crow::HTTPMethod::Post)(
        [](const crow::request& req) {
            auto event = nlohmann::json::parse(req.body);
            std::string type      = event.value("event_type", "");
            int         emailId   = event.value("email_id", 0);
            std::string recipient = event.value("recipient_email", "");
            std::string reason    = event.value("reason", "");

            if (type == "email.delivered")
                std::cout << "Email " << emailId << " delivered to " << recipient << "\n";
            else if (type == "email.bounced" || type == "email.failed")
                std::cout << "Delivery failed for " << recipient << ": " << reason << "\n";
            else if (type == "email.opened")
                std::cout << "Email " << emailId << " opened by " << recipient << "\n";

            return crow::response(200, R"({"received":true})");
        });

    app.port(8080).run();
}
#include <microhttpd.h>
#include <cjson/cJSON.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

static enum MHD_Result handle_webhook(
    void* cls, struct MHD_Connection* conn,
    const char* url, const char* method,
    const char* version, const char* data,
    size_t* data_size, void** ptr)
{
    if (*data_size == 0) {
        cJSON* event     = cJSON_Parse(data);
        const char* type = cJSON_GetStringValue(cJSON_GetObjectItem(event, "event_type"));
        int emailId      = (int)cJSON_GetNumberValue(cJSON_GetObjectItem(event, "email_id"));
        const char* rcpt = cJSON_GetStringValue(cJSON_GetObjectItem(event, "recipient_email"));

        if (type && strcmp(type, "email.delivered") == 0)
            printf("Email %d delivered to %s\n", emailId, rcpt);
        else if (type && strcmp(type, "email.bounced") == 0)
            printf("Email %d bounced for %s\n", emailId, rcpt);

        cJSON_Delete(event);
        const char* resp = "{\"received\":true}";
        struct MHD_Response* r = MHD_create_response_from_buffer(
            strlen(resp), (void*)resp, MHD_RESPMEM_PERSISTENT);
        int ret = MHD_queue_response(conn, MHD_HTTP_OK, r);
        MHD_destroy_response(r);
        return ret;
    }
    *data_size = 0;
    return MHD_YES;
}

Security

Verify webhook signatures to ensure requests come from Keplars. The signature is sent in the x-webhook-signature header as sha256=<hex-digest>.

const crypto = require('crypto');

function verifySignature(req, secret) {
  const header = req.headers['x-webhook-signature'];
  if (!header || !header.startsWith('sha256=')) return false;

  const receivedDigest = header.slice('sha256='.length);
  const expectedDigest = crypto
    .createHmac('sha256', secret)
    .update(req.rawBody)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(receivedDigest, 'hex'),
    Buffer.from(expectedDigest, 'hex')
  );
}

app.post('/webhooks/keplars', (req, res) => {
  if (!verifySignature(req, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = req.body;
  // handle event...

  res.status(200).json({ received: true });
});
import hashlib
import hmac
import os
from flask import Flask, request, jsonify, abort

app = Flask(__name__)

def verify_signature(payload: bytes, header: str, secret: str) -> bool:
    if not header or not header.startswith('sha256='):
        return False
    received_digest = header[len('sha256='):]
    expected_digest = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(received_digest, expected_digest)

@app.route('/webhooks/keplars', methods=['POST'])
def handle_webhook():
    payload = request.get_data()
    signature = request.headers.get('X-Webhook-Signature', '')

    if not verify_signature(payload, signature, os.environ['WEBHOOK_SECRET']):
        abort(401)

    event = request.get_json()
    # handle event...

    return jsonify({'received': True})
package main

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

func verifySignature(payload []byte, header, secret string) bool {
    if !strings.HasPrefix(header, "sha256=") {
        return false
    }
    receivedHex := strings.TrimPrefix(header, "sha256=")
    receivedBytes, err := hex.DecodeString(receivedHex)
    if err != nil {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    return hmac.Equal(receivedBytes, mac.Sum(nil))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }

    sig := r.Header.Get("X-Webhook-Signature")
    if !verifySignature(body, sig, os.Getenv("WEBHOOK_SECRET")) {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }

    // handle event...
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"received":true}`))
}
<?php
function verifySignature(string $payload, string $header, string $secret): bool {
    if (!str_starts_with($header, 'sha256=')) {
        return false;
    }
    $received = substr($header, strlen('sha256='));
    $expected = hash_hmac('sha256', $payload, $secret);
    return hash_equals($expected, $received);
}

$payload = file_get_contents('php://input');
$header  = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

if (!verifySignature($payload, $header, getenv('WEBHOOK_SECRET'))) {
    http_response_code(401);
    header('Content-Type: application/json');
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

$event = json_decode($payload, true);
// handle event...

http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['received' => true]);
require 'openssl'
require 'json'
require 'sinatra'
require 'rack/utils'

post '/webhooks/keplars' do
  payload = request.body.read
  header  = request.env['HTTP_X_WEBHOOK_SIGNATURE'] || ''

  unless header.start_with?('sha256=')
    halt 401, { error: 'Invalid signature' }.to_json
  end

  received_digest = header.sub('sha256=', '')
  expected_digest = OpenSSL::HMAC.hexdigest('SHA256', ENV['WEBHOOK_SECRET'], payload)

  unless Rack::Utils.secure_compare(received_digest, expected_digest)
    halt 401, { error: 'Invalid signature' }.to_json
  end

  event = JSON.parse(payload)
  # handle event...

  content_type :json
  { received: true }.to_json
end
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;

bool VerifySignature(string rawBody, string header, string secret)
{
    if (!header.StartsWith("sha256=")) return false;
    var received = header["sha256=".Length..];
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToHexString(
        hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody))
    ).ToLower();
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected),
        Encoding.UTF8.GetBytes(received)
    );
}

app.MapPost("/webhooks/keplars", async (HttpContext ctx) =>
{
    using var reader = new StreamReader(ctx.Request.Body);
    var rawBody = await reader.ReadToEndAsync();
    var header  = ctx.Request.Headers["X-Webhook-Signature"].ToString();
    var secret  = Environment.GetEnvironmentVariable("WEBHOOK_SECRET")!;

    if (!VerifySignature(rawBody, header, secret))
        return Results.Unauthorized();

    // handle event...
    return Results.Ok(new { received = true });
});
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::env;

async fn verify_signature(request: Request, next: Next) -> Result<Response, StatusCode> {
    let secret = env::var("WEBHOOK_SECRET").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let header = request
        .headers()
        .get("x-webhook-signature")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("sha256="))
        .ok_or(StatusCode::UNAUTHORIZED)?;

    let (parts, body) = request.into_parts();
    let bytes = axum::body::to_bytes(body, usize::MAX)
        .await
        .map_err(|_| StatusCode::BAD_REQUEST)?;

    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    mac.update(&bytes);
    let expected = hex::encode(mac.finalize().into_bytes());

    if expected != header {
        return Err(StatusCode::UNAUTHORIZED);
    }

    Ok(next.run(Request::from_parts(parts, axum::body::Body::from(bytes))).await)
}
#include <openssl/hmac.h>
#include <iomanip>
#include <sstream>
#include <string>

std::string hmacSha256(const std::string& secret, const std::string& payload) {
    unsigned char digest[EVP_MAX_MD_SIZE];
    unsigned int  len = 0;
    HMAC(EVP_sha256(),
         secret.data(), static_cast<int>(secret.size()),
         reinterpret_cast<const unsigned char*>(payload.data()),
         static_cast<int>(payload.size()),
         digest, &len);
    std::ostringstream oss;
    for (unsigned int i = 0; i < len; ++i)
        oss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(digest[i]);
    return oss.str();
}

bool verifySignature(const std::string& payload,
                     const std::string& header,
                     const std::string& secret) {
    const std::string prefix = "sha256=";
    if (header.substr(0, prefix.size()) != prefix) return false;
    return hmacSha256(secret, payload) == header.substr(prefix.size());
}
#include <openssl/hmac.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

static int verify_signature(const char* payload, size_t payload_len,
                             const char* header, const char* secret) {
    const char* prefix = "sha256=";
    size_t prefix_len  = strlen(prefix);
    if (strncmp(header, prefix, prefix_len) != 0) return 0;
    const char* received = header + prefix_len;

    unsigned char digest[EVP_MAX_MD_SIZE];
    unsigned int  digest_len = 0;
    HMAC(EVP_sha256(),
         secret, (int)strlen(secret),
         (const unsigned char*)payload, (int)payload_len,
         digest, &digest_len);

    char expected[EVP_MAX_MD_SIZE * 2 + 1];
    for (unsigned int i = 0; i < digest_len; i++)
        sprintf(expected + i * 2, "%02x", digest[i]);
    expected[digest_len * 2] = '\0';

    return strcmp(expected, received) == 0;
}

Next Steps

  • AI Templates - Create smart email templates
  • Examples - See integration examples for your programming language

Webhooks keep your application updated with real-time email delivery status.

On this page