This section explains how users can configure webhooks in the Easify application to receive SMS status events. It also outlines how to securely verify the integrity of these events using the provided Signature Verification Secret.
Adding a Webhook URL
Steps to Add a Webhook:
Go to Settings -> API Settings -> SMS -> Status Update.
Webhook Event Structure:
When an event is triggered, the application will send a POST request to the configured webhook URL with the following headers and body:
Headers
X-Easify-Signature: A Base64-encoded signature of the payload, generated using the verification secret.
X-Easify-Timestamp: The UNIX timestamp of the request.
X-Easify-Signature and X-Easify-Timestamp from the request headers, and the raw JSON payload from the request body.
Read the body as raw bytes. Re-serialising a payload you have already parsed can reorder keys or change escaping, and the signature will no longer match.
Recreate the Signature
Compute an HMAC-SHA256 of the raw payload using your webhook secret. The digest is then written as a lowercase hex string, and it is that hex string which is Base64-encoded. Base64-encoding the raw digest bytes instead is the most common cause of a signature that never matches.
Compare the Signatures
Compare your computed value against the one received in the X-Easify-Signature header using a constant-time comparison.
Validate the Timestamp
To prevent replay attacks, ensure the timestamp in X-Easify-Timestamp is within an acceptable range of the current time (±15 minutes / 900 seconds).
Pass the raw body, both header values and your webhook secret into a helper like the one below, and reject the request unless it returns true.
Validate the webhook signature
php
// Get the raw request body.
// Use the original body for signature verification without modifying it.
$rawBody = file_get_contents('php://input');
// Read the signature and timestamp sent in the webhook headers.
$receivedSignature = $_SERVER['HTTP_X_EASIFY_SIGNATURE'] ?? '';
$receivedTimestamp = (int) ($_SERVER['HTTP_X_EASIFY_TIMESTAMP'] ?? 0);
// Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
$webhookSecret = getenv('EASIFY_WEBHOOK_SECRET');
// Reject requests older than 15 minutes to prevent replay attacks.
if (abs(time() - $receivedTimestamp) > 900) {
http_response_code(408);
exit;
}
// Generate the expected signature using the webhook secret.
$computedSignature = base64_encode(hash_hmac('sha256', $rawBody, $webhookSecret));
// Compare the received signature with the expected signature.
if (!hash_equals($computedSignature, $receivedSignature)) {
http_response_code(403);
exit;
}
// Signature is valid, now decode and handle the event.
$payload = json_decode($rawBody, true);
// ... handle the event ...
http_response_code(200);
const crypto = require('node:crypto')
const http = require('node:http')
http.createServer((req, res) => {
// Get the raw request body.
// Use the original body for signature verification without modifying it.
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('end', () => {
const rawBody = Buffer.concat(chunks)
// Read the signature and timestamp sent in the webhook headers.
const receivedSignature = req.headers['x-easify-signature'] || ''
const receivedTimestamp = Number(req.headers['x-easify-timestamp'] || 0)
// Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
const webhookSecret = process.env.EASIFY_WEBHOOK_SECRET
// Reject requests older than 15 minutes to prevent replay attacks.
if (Math.abs(Math.floor(Date.now() / 1000) - receivedTimestamp) > 900) {
res.writeHead(408).end()
return
}
// Generate the expected signature using the webhook secret.
const hex = crypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex')
const computedSignature = Buffer.from(hex).toString('base64')
// Compare the received signature with the expected signature.
const computed = Buffer.from(computedSignature)
const received = Buffer.from(receivedSignature)
if (computed.length !== received.length || !crypto.timingSafeEqual(computed, received)) {
res.writeHead(403).end()
return
}
// Signature is valid, now decode and handle the event.
const payload = JSON.parse(rawBody.toString('utf8'))
// ... handle the event ...
res.writeHead(200).end()
})
}).listen(8000)
import base64
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
# Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
WEBHOOK_SECRET = os.environ["EASIFY_WEBHOOK_SECRET"]
class EasifyWebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
# Get the raw request body.
# Use the original body for signature verification without modifying it.
content_length = int(self.headers.get("Content-Length", 0))
raw_body = self.rfile.read(content_length)
# Read the signature and timestamp sent in the webhook headers.
received_signature = self.headers.get("X-Easify-Signature", "")
received_timestamp = int(self.headers.get("X-Easify-Timestamp", 0))
# Reject requests older than 15 minutes to prevent replay attacks.
if abs(int(time.time()) - received_timestamp) > 900:
self.send_response(408)
self.end_headers()
return
# Generate the expected signature using the webhook secret.
hex_digest = hmac.new(WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
computed_signature = base64.b64encode(hex_digest.encode()).decode()
# Compare the received signature with the expected signature.
if not hmac.compare_digest(computed_signature, received_signature):
self.send_response(403)
self.end_headers()
return
# Signature is valid, now decode and handle the event.
payload = json.loads(raw_body)
# ... handle the event ...
self.send_response(200)
self.end_headers()
HTTPServer(("", 8000), EasifyWebhookHandler).serve_forever()
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class EasifyWebhook {
static void handle(HttpExchange exchange) throws Exception {
// Get the raw request body.
// Use the original body for signature verification without modifying it.
String rawBody =
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
// Read the signature and timestamp sent in the webhook headers.
String receivedSignature = exchange.getRequestHeaders().getFirst("X-Easify-Signature");
long receivedTimestamp =
Long.parseLong(exchange.getRequestHeaders().getFirst("X-Easify-Timestamp"));
// Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
String webhookSecret = System.getenv("EASIFY_WEBHOOK_SECRET");
// Reject requests older than 15 minutes to prevent replay attacks.
if (Math.abs(Instant.now().getEpochSecond() - receivedTimestamp) > 900) {
exchange.sendResponseHeaders(408, -1);
return;
}
// Generate the expected signature using the webhook secret.
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(rawBody.getBytes(StandardCharsets.UTF_8));
String hex = HexFormat.of().formatHex(digest);
String computedSignature =
Base64.getEncoder().encodeToString(hex.getBytes(StandardCharsets.UTF_8));
// Compare the received signature with the expected signature.
boolean valid = MessageDigest.isEqual(
computedSignature.getBytes(StandardCharsets.UTF_8),
receivedSignature.getBytes(StandardCharsets.UTF_8));
if (!valid) {
exchange.sendResponseHeaders(403, -1);
return;
}
// Signature is valid, now decode and handle the event.
// ... handle the event ...
exchange.sendResponseHeaders(200, -1);
}
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(8000), 0);
server.createContext("/webhooks/easify", exchange -> {
try {
handle(exchange);
} catch (Exception e) {
exchange.sendResponseHeaders(500, -1);
} finally {
exchange.close();
}
});
server.start();
}
}
using System;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Text;
class EasifyWebhook
{
static void Main()
{
// Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
var webhookSecret = Environment.GetEnvironmentVariable("EASIFY_WEBHOOK_SECRET");
var listener = new HttpListener();
listener.Prefixes.Add("http://localhost:8000/webhooks/easify/");
listener.Start();
while (true)
{
var context = listener.GetContext();
// Get the raw request body.
// Use the original body for signature verification without modifying it.
string rawBody;
using (var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8))
rawBody = reader.ReadToEnd();
// Read the signature and timestamp sent in the webhook headers.
var receivedSignature = context.Request.Headers["X-Easify-Signature"] ?? "";
var receivedTimestamp = long.Parse(context.Request.Headers["X-Easify-Timestamp"] ?? "0");
// Reject requests older than 15 minutes to prevent replay attacks.
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - receivedTimestamp) > 900)
{
context.Response.StatusCode = 408;
context.Response.Close();
continue;
}
// Generate the expected signature using the webhook secret.
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(webhookSecret));
var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody));
var hex = Convert.ToHexString(digest).ToLowerInvariant();
var computedSignature = Convert.ToBase64String(Encoding.UTF8.GetBytes(hex));
// Compare the received signature with the expected signature.
var valid = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computedSignature),
Encoding.UTF8.GetBytes(receivedSignature));
// Signature is valid, now decode and handle the event.
// ... handle the event ...
context.Response.StatusCode = valid ? 200 : 403;
context.Response.Close();
}
}
}
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"time"
)
func easifyWebhook(w http.ResponseWriter, r *http.Request) {
// Get the raw request body.
// Use the original body for signature verification without modifying it.
rawBody, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Read the signature and timestamp sent in the webhook headers.
receivedSignature := r.Header.Get("X-Easify-Signature")
receivedTimestamp, _ := strconv.ParseInt(r.Header.Get("X-Easify-Timestamp"), 10, 64)
// Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
webhookSecret := os.Getenv("EASIFY_WEBHOOK_SECRET")
// Reject requests older than 15 minutes to prevent replay attacks.
if delta := time.Now().Unix() - receivedTimestamp; delta > 900 || delta < -900 {
w.WriteHeader(http.StatusRequestTimeout)
return
}
// Generate the expected signature using the webhook secret.
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(rawBody)
hexDigest := hex.EncodeToString(mac.Sum(nil))
computedSignature := base64.StdEncoding.EncodeToString([]byte(hexDigest))
// Compare the received signature with the expected signature.
if subtle.ConstantTimeCompare([]byte(computedSignature), []byte(receivedSignature)) != 1 {
w.WriteHeader(http.StatusForbidden)
return
}
// Signature is valid, now decode and handle the event.
// ... handle the event ...
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/webhooks/easify", easifyWebhook)
http.ListenAndServe(":8000", nil)
}
Incoming SMS Webhook Configuration
This section explains how users can configure webhooks in the Easify application to handle incoming SMS messages. It also details how to securely verify the authenticity of each request using the provided Signature Verification Secret.
Adding a Webhook URL
Steps to Add a Webhook:
Go to Settings -> API Settings -> SMS -> Incoming.
Webhook Structure:
When an incoming SMS is received, the application will send a POST request to the configured webhook URL with the following headers and body:
Headers
X-Easify-Signature: A Base64-encoded signature of the payload, generated using the verification secret.
X-Easify-Timestamp: The UNIX timestamp of the request.
X-Easify-Signature and X-Easify-Timestamp from the request headers, and the raw JSON payload from the request body.
Read the body as raw bytes. Re-serialising a payload you have already parsed can reorder keys or change escaping, and the signature will no longer match.
Recreate the Signature
Compute an HMAC-SHA256 of the raw payload using your webhook secret. The digest is then written as a lowercase hex string, and it is that hex string which is Base64-encoded. Base64-encoding the raw digest bytes instead is the most common cause of a signature that never matches.
Compare the Signatures
Compare your computed value against the one received in the X-Easify-Signature header using a constant-time comparison.
Validate the Timestamp
To prevent replay attacks, ensure the timestamp in X-Easify-Timestamp is within an acceptable range of the current time (±15 minutes / 900 seconds).
Pass the raw body, both header values and your webhook secret into a helper like the one below, and reject the request unless it returns true.
Validate the webhook signature
php
// Get the raw request body.
// Use the original body for signature verification without modifying it.
$rawBody = file_get_contents('php://input');
// Read the signature and timestamp sent in the webhook headers.
$receivedSignature = $_SERVER['HTTP_X_EASIFY_SIGNATURE'] ?? '';
$receivedTimestamp = (int) ($_SERVER['HTTP_X_EASIFY_TIMESTAMP'] ?? 0);
// Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
$webhookSecret = getenv('EASIFY_WEBHOOK_SECRET');
// Reject requests older than 15 minutes to prevent replay attacks.
if (abs(time() - $receivedTimestamp) > 900) {
http_response_code(408);
exit;
}
// Generate the expected signature using the webhook secret.
$computedSignature = base64_encode(hash_hmac('sha256', $rawBody, $webhookSecret));
// Compare the received signature with the expected signature.
if (!hash_equals($computedSignature, $receivedSignature)) {
http_response_code(403);
exit;
}
// Signature is valid, now decode and handle the event.
$payload = json_decode($rawBody, true);
// ... handle the event ...
http_response_code(200);
const crypto = require('node:crypto')
const http = require('node:http')
http.createServer((req, res) => {
// Get the raw request body.
// Use the original body for signature verification without modifying it.
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('end', () => {
const rawBody = Buffer.concat(chunks)
// Read the signature and timestamp sent in the webhook headers.
const receivedSignature = req.headers['x-easify-signature'] || ''
const receivedTimestamp = Number(req.headers['x-easify-timestamp'] || 0)
// Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
const webhookSecret = process.env.EASIFY_WEBHOOK_SECRET
// Reject requests older than 15 minutes to prevent replay attacks.
if (Math.abs(Math.floor(Date.now() / 1000) - receivedTimestamp) > 900) {
res.writeHead(408).end()
return
}
// Generate the expected signature using the webhook secret.
const hex = crypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex')
const computedSignature = Buffer.from(hex).toString('base64')
// Compare the received signature with the expected signature.
const computed = Buffer.from(computedSignature)
const received = Buffer.from(receivedSignature)
if (computed.length !== received.length || !crypto.timingSafeEqual(computed, received)) {
res.writeHead(403).end()
return
}
// Signature is valid, now decode and handle the event.
const payload = JSON.parse(rawBody.toString('utf8'))
// ... handle the event ...
res.writeHead(200).end()
})
}).listen(8000)
import base64
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
# Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
WEBHOOK_SECRET = os.environ["EASIFY_WEBHOOK_SECRET"]
class EasifyWebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
# Get the raw request body.
# Use the original body for signature verification without modifying it.
content_length = int(self.headers.get("Content-Length", 0))
raw_body = self.rfile.read(content_length)
# Read the signature and timestamp sent in the webhook headers.
received_signature = self.headers.get("X-Easify-Signature", "")
received_timestamp = int(self.headers.get("X-Easify-Timestamp", 0))
# Reject requests older than 15 minutes to prevent replay attacks.
if abs(int(time.time()) - received_timestamp) > 900:
self.send_response(408)
self.end_headers()
return
# Generate the expected signature using the webhook secret.
hex_digest = hmac.new(WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
computed_signature = base64.b64encode(hex_digest.encode()).decode()
# Compare the received signature with the expected signature.
if not hmac.compare_digest(computed_signature, received_signature):
self.send_response(403)
self.end_headers()
return
# Signature is valid, now decode and handle the event.
payload = json.loads(raw_body)
# ... handle the event ...
self.send_response(200)
self.end_headers()
HTTPServer(("", 8000), EasifyWebhookHandler).serve_forever()
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class EasifyWebhook {
static void handle(HttpExchange exchange) throws Exception {
// Get the raw request body.
// Use the original body for signature verification without modifying it.
String rawBody =
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
// Read the signature and timestamp sent in the webhook headers.
String receivedSignature = exchange.getRequestHeaders().getFirst("X-Easify-Signature");
long receivedTimestamp =
Long.parseLong(exchange.getRequestHeaders().getFirst("X-Easify-Timestamp"));
// Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
String webhookSecret = System.getenv("EASIFY_WEBHOOK_SECRET");
// Reject requests older than 15 minutes to prevent replay attacks.
if (Math.abs(Instant.now().getEpochSecond() - receivedTimestamp) > 900) {
exchange.sendResponseHeaders(408, -1);
return;
}
// Generate the expected signature using the webhook secret.
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(rawBody.getBytes(StandardCharsets.UTF_8));
String hex = HexFormat.of().formatHex(digest);
String computedSignature =
Base64.getEncoder().encodeToString(hex.getBytes(StandardCharsets.UTF_8));
// Compare the received signature with the expected signature.
boolean valid = MessageDigest.isEqual(
computedSignature.getBytes(StandardCharsets.UTF_8),
receivedSignature.getBytes(StandardCharsets.UTF_8));
if (!valid) {
exchange.sendResponseHeaders(403, -1);
return;
}
// Signature is valid, now decode and handle the event.
// ... handle the event ...
exchange.sendResponseHeaders(200, -1);
}
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(8000), 0);
server.createContext("/webhooks/easify", exchange -> {
try {
handle(exchange);
} catch (Exception e) {
exchange.sendResponseHeaders(500, -1);
} finally {
exchange.close();
}
});
server.start();
}
}
using System;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Text;
class EasifyWebhook
{
static void Main()
{
// Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
var webhookSecret = Environment.GetEnvironmentVariable("EASIFY_WEBHOOK_SECRET");
var listener = new HttpListener();
listener.Prefixes.Add("http://localhost:8000/webhooks/easify/");
listener.Start();
while (true)
{
var context = listener.GetContext();
// Get the raw request body.
// Use the original body for signature verification without modifying it.
string rawBody;
using (var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8))
rawBody = reader.ReadToEnd();
// Read the signature and timestamp sent in the webhook headers.
var receivedSignature = context.Request.Headers["X-Easify-Signature"] ?? "";
var receivedTimestamp = long.Parse(context.Request.Headers["X-Easify-Timestamp"] ?? "0");
// Reject requests older than 15 minutes to prevent replay attacks.
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - receivedTimestamp) > 900)
{
context.Response.StatusCode = 408;
context.Response.Close();
continue;
}
// Generate the expected signature using the webhook secret.
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(webhookSecret));
var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(rawBody));
var hex = Convert.ToHexString(digest).ToLowerInvariant();
var computedSignature = Convert.ToBase64String(Encoding.UTF8.GetBytes(hex));
// Compare the received signature with the expected signature.
var valid = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computedSignature),
Encoding.UTF8.GetBytes(receivedSignature));
// Signature is valid, now decode and handle the event.
// ... handle the event ...
context.Response.StatusCode = valid ? 200 : 403;
context.Response.Close();
}
}
}
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"time"
)
func easifyWebhook(w http.ResponseWriter, r *http.Request) {
// Get the raw request body.
// Use the original body for signature verification without modifying it.
rawBody, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Read the signature and timestamp sent in the webhook headers.
receivedSignature := r.Header.Get("X-Easify-Signature")
receivedTimestamp, _ := strconv.ParseInt(r.Header.Get("X-Easify-Timestamp"), 10, 64)
// Your webhook signing secret from the Easify -> API Settings -> Webhooks -> Secret Key
webhookSecret := os.Getenv("EASIFY_WEBHOOK_SECRET")
// Reject requests older than 15 minutes to prevent replay attacks.
if delta := time.Now().Unix() - receivedTimestamp; delta > 900 || delta < -900 {
w.WriteHeader(http.StatusRequestTimeout)
return
}
// Generate the expected signature using the webhook secret.
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(rawBody)
hexDigest := hex.EncodeToString(mac.Sum(nil))
computedSignature := base64.StdEncoding.EncodeToString([]byte(hexDigest))
// Compare the received signature with the expected signature.
if subtle.ConstantTimeCompare([]byte(computedSignature), []byte(receivedSignature)) != 1 {
w.WriteHeader(http.StatusForbidden)
return
}
// Signature is valid, now decode and handle the event.
// ... handle the event ...
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/webhooks/easify", easifyWebhook)
http.ListenAndServe(":8000", nil)
}