Keplars

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

EventFired when
email.queuedEmail accepted into the priority queue
email.sentHanded off to the mail provider
email.deliveredDelivery confirmed by provider
email.openedRecipient opened the email
email.clickedRecipient clicked a tracked link
email.bouncedHard or soft bounce received
email.failedDelivery failed after all retries
email.scheduledEmail accepted for future delivery
email.cancelledScheduled 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.

On this page