Webhooks
Receive real-time notifications for email lifecycle events. All payloads are signed with HMAC-SHA256.
Configure a webhook URL in the Keplars dashboard. Keplars sends a POST request to your URL for every email event.
Payload
{
"event": "email.delivered",
"email_id": "em_7f3a9b2c1d4e",
"workspace_id": "ws_abc123",
"to": "[email protected]",
"subject": "Your order is confirmed",
"timestamp": "2025-06-15T14:23:01Z",
"metadata": {}
}Events
| Event | Fired when |
|---|---|
email.queued | Email accepted into the priority queue |
email.sent | Handed off to the mail provider |
email.delivered | Delivery confirmed by provider |
email.opened | Recipient opened the email |
email.clicked | Recipient clicked a tracked link |
email.bounced | Hard or soft bounce received |
email.failed | Delivery failed after all retries |
email.scheduled | Email accepted for future delivery |
email.cancelled | Scheduled email was cancelled |
Signature Verification
Every request includes an X-Keplars-Signature header - an HMAC-SHA256 signature of the raw request body signed with your webhook secret.
import crypto from "crypto";
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
app.post("/webhook/keplars", express.raw({ type: "application/json" }), (req, res) => {
const valid = verifyWebhook(
req.body,
req.headers["x-keplars-signature"],
process.env.KEPLARS_WEBHOOK_SECRET
);
if (!valid) return res.status(401).send("Invalid signature");
const event = JSON.parse(req.body);
res.sendStatus(200);
});import hashlib
import hmac
import os
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
expected = hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(header, expected)
@app.route("/webhook/keplars", methods=["POST"])
def handle_webhook():
raw = request.get_data()
sig = request.headers.get("X-Keplars-Signature", "")
if not verify_webhook(raw, sig, os.environ["KEPLARS_WEBHOOK_SECRET"]):
abort(401)
event = request.get_json()
return jsonify({"received": True})package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func verifyWebhook(payload []byte, header, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(header), []byte(expected))
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := r.Header.Get("X-Keplars-Signature")
if !verifyWebhook(body, sig, os.Getenv("KEPLARS_WEBHOOK_SECRET")) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"received":true}`))
}<?php
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_KEPLARS_SIGNATURE'] ?? '';
$secret = getenv('KEPLARS_WEBHOOK_SECRET');
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $header)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['received' => true]);using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
[HttpPost("/webhook/keplars")]
public IActionResult HandleWebhook(
[FromBody] string rawBody,
[FromHeader(Name = "X-Keplars-Signature")] string signature)
{
var secret = Environment.GetEnvironmentVariable("KEPLARS_WEBHOOK_SECRET")!;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody))
).ToLower();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signature)))
{
return Unauthorized();
}
return Ok(new { received = true });
}use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::env;
fn verify_webhook(payload: &[u8], header: &str, secret: &str) -> bool {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(payload);
let expected = hex::encode(mac.finalize().into_bytes());
expected == header
}
#[tokio::main]
async fn main() {
let secret = env::var("KEPLARS_WEBHOOK_SECRET").unwrap();
// In your Axum/Actix handler:
// let sig = headers.get("x-keplars-signature").unwrap().to_str().unwrap();
// if !verify_webhook(body.as_bytes(), sig, &secret) { return StatusCode::UNAUTHORIZED; }
}#include <openssl/hmac.h>
#include <cstdlib>
#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 verifyWebhook(const std::string& payload,
const std::string& header,
const std::string& secret) {
return hmacSha256(secret, payload) == header;
}#include <openssl/hmac.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int verify_webhook(const char* payload, size_t payload_len,
const char* header, const char* secret) {
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 hex[EVP_MAX_MD_SIZE * 2 + 1];
for (unsigned int i = 0; i < digest_len; i++)
sprintf(hex + i * 2, "%02x", digest[i]);
hex[digest_len * 2] = '\0';
return strcmp(hex, header) == 0;
}Use express.raw() (Node.js) or read the raw body before parsing to preserve the exact bytes for signature verification.