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
- Sign in to dash.keplars.com
- Navigate to Webhooks section
- Click Add New Webhook
Configure Webhook
- Enter your webhook endpoint URL
- Select events to receive across processing, delivery, engagement, compliance, and warning categories
- Add optional webhook secret for security
- Click Create Webhook
Test Webhook
- Click Test Endpoint to send sample event
- Verify your endpoint receives the event
- Check that webhook appears as active
Webhook Events
| Category | Event | Description |
|---|---|---|
| Processing | email.sent | Email processed and sent to the mail server |
| Processing | email.queued | Email added to the delivery queue |
| Delivery | email.delivered | Email successfully delivered to recipient inbox |
| Delivery | email.bounced | Email bounced by recipient server (hard or soft) |
| Delivery | email.failed | Email delivery failed |
| Delivery | email.rejected | Email rejected by spam filters or reputation checks |
| Engagement | email.opened | Recipient opened the email (requires tracking pixel) |
| Engagement | email.clicked | Recipient clicked a link (requires click tracking) |
| Compliance | email.spam | Email marked as spam by recipient |
| Compliance | email.complaint | Recipient filed a spam complaint |
| Warning | email.warning | Email 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
endusing 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
endusing 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.