SendPost includes HMAC-SHA256 signatures in all webhook requests, allowing you to verify that webhooks truly came from SendPost and that the payload hasn’t been altered in transit.
Overview
Every webhook request from SendPost includes signature headers that you can use to verify authenticity. The signature is computed using HMAC-SHA256 with your Account API Key as the secret, ensuring that only requests with the correct secret can generate valid signatures.
SendPost includes the following headers in every webhook request:
| Header | Description |
|---|
X-SendPost-Signature | HMAC-SHA256 signature of the webhook payload (hex-encoded) |
X-SendPost-Signature-Alg | Algorithm identifier (hmac-sha256) |
X-SendPost-Webhook-Id | Unique webhook UUID for tracking |
X-SendPost-Webhook-Attempt | Attempt number for retry tracking (useful for debugging) |
X-SendPost-Signature: a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456
X-SendPost-Signature-Alg: hmac-sha256
X-SendPost-Webhook-Id: 550e8400-e29b-41d4-a716-446655440000
X-SendPost-Webhook-Attempt: 1
Verification Process
To verify a webhook request:
- Extract the signature from the
X-SendPost-Signature header
- Read the raw request body (as bytes/string, before any JSON parsing)
- Compute HMAC-SHA256 of the request body using your Account API Key as the secret
- Hex-encode the computed signature
- Compare the computed signature with the header value using a constant-time comparison
If the signatures match, the webhook is authentic and unaltered.
Always use constant-time comparison when comparing signatures to prevent timing attacks. Never use simple string equality (== or ===).
Code Examples
JavaScript (Node.js)
const crypto = require('crypto');
function verifyWebhookSignature(requestBody, signature, apiKey) {
// Compute HMAC-SHA256 signature
const computedSignature = crypto
.createHmac('sha256', apiKey)
.update(requestBody, 'utf8')
.digest('hex');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(computedSignature, 'hex'),
Buffer.from(signature, 'hex')
);
}
// Express.js example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-sendpost-signature'];
const apiKey = process.env.SENDPOST_API_KEY; // Your Account API Key
if (!signature) {
return res.status(401).json({ error: 'Missing signature' });
}
const isValid = verifyWebhookSignature(
req.body.toString(),
signature,
apiKey
);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse JSON and process webhook
const webhookData = JSON.parse(req.body);
// ... process webhook data
res.status(200).json({ received: true });
});
Python
import hmac
import hashlib
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
def verify_webhook_signature(request_body, signature, api_key):
"""
Verify webhook signature using HMAC-SHA256.
Args:
request_body: Raw request body as bytes or string
signature: Signature from X-SendPost-Signature header
api_key: Your SendPost Account API Key
Returns:
bool: True if signature is valid, False otherwise
"""
# Ensure request_body is bytes
if isinstance(request_body, str):
request_body = request_body.encode('utf-8')
# Compute HMAC-SHA256 signature
computed_signature = hmac.new(
api_key.encode('utf-8'),
request_body,
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(computed_signature, signature)
@app.route('/webhook', methods=['POST'])
def webhook_handler():
signature = request.headers.get('X-SendPost-Signature')
api_key = os.environ.get('SENDPOST_API_KEY') # Your Account API Key
if not signature:
return jsonify({'error': 'Missing signature'}), 401
# Get raw request body
request_body = request.get_data()
if not verify_webhook_signature(request_body, signature, api_key):
return jsonify({'error': 'Invalid signature'}), 401
# Parse JSON and process webhook
webhook_data = request.get_json()
# ... process webhook data
return jsonify({'received': True}), 200
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
)
func verifyWebhookSignature(body []byte, signature, apiKey string) bool {
// Compute HMAC-SHA256 signature
mac := hmac.New(sha256.New, []byte(apiKey))
mac.Write(body)
computedSignature := hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison
return hmac.Equal([]byte(computedSignature), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
// Get signature from header
signature := r.Header.Get("X-SendPost-Signature")
if signature == "" {
http.Error(w, "Missing signature", http.StatusUnauthorized)
return
}
// Get Account API Key from environment
apiKey := os.Getenv("SENDPOST_API_KEY")
if apiKey == "" {
http.Error(w, "API key not configured", http.StatusInternalServerError)
return
}
// Read raw request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
// Verify signature
if !verifyWebhookSignature(body, signature, apiKey) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Parse JSON and process webhook
// ... process webhook data
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received": true}`))
}
func main() {
http.HandleFunc("/webhook", webhookHandler)
http.ListenAndServe(":8080", nil)
}
Ruby
require 'sinatra'
require 'openssl'
require 'json'
def secure_compare(a, b)
return false unless a.bytesize == b.bytesize
result = 0
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
result == 0
end
def verify_webhook_signature(request_body, signature, api_key)
# Compute HMAC-SHA256 signature
computed_signature = OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha256'),
api_key,
request_body
)
# Constant-time comparison
secure_compare(computed_signature, signature)
rescue
false
end
post '/webhook' do
signature = request.env['HTTP_X_SENDPOST_SIGNATURE']
api_key = ENV['SENDPOST_API_KEY'] # Your Account API Key
if signature.nil? || signature.empty?
status 401
return { error: 'Missing signature' }.to_json
end
# Get raw request body
request_body = request.body.read
unless verify_webhook_signature(request_body, signature, api_key)
status 401
return { error: 'Invalid signature' }.to_json
end
# Parse JSON and process webhook
webhook_data = JSON.parse(request_body)
# ... process webhook data
status 200
{ received: true }.to_json
end
Java (Spring Boot)
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
@RestController
public class WebhookController {
private static final String ALGORITHM = "HmacSHA256";
@Value("${sendpost.api.key}")
private String apiKey;
private boolean verifyWebhookSignature(byte[] body, String signature, String apiKey) {
try {
Mac mac = Mac.getInstance(ALGORITHM);
SecretKeySpec secretKeySpec = new SecretKeySpec(
apiKey.getBytes(StandardCharsets.UTF_8),
ALGORITHM
);
mac.init(secretKeySpec);
byte[] computedSignature = mac.doFinal(body);
String computedSignatureHex = HexFormat.of().formatHex(computedSignature);
// Constant-time comparison
return MessageDigest.isEqual(
computedSignatureHex.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8)
);
} catch (Exception e) {
return false;
}
}
@PostMapping("/webhook")
public ResponseEntity<?> handleWebhook(
@RequestHeader(value = "X-SendPost-Signature", required = false) String signature,
@RequestBody byte[] body) {
if (signature == null || signature.isEmpty()) {
return ResponseEntity.status(401).body(Map.of("error", "Missing signature"));
}
if (!verifyWebhookSignature(body, signature, apiKey)) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid signature"));
}
// Parse JSON and process webhook
// String jsonBody = new String(body, StandardCharsets.UTF_8);
// ... process webhook data
return ResponseEntity.ok(Map.of("received", true));
}
}
PHP
<?php
function verifyWebhookSignature($requestBody, $signature, $apiKey) {
// Compute HMAC-SHA256 signature
$computedSignature = hash_hmac('sha256', $requestBody, $apiKey);
// Constant-time comparison
return hash_equals($computedSignature, $signature);
}
// Example using raw input
$signature = $_SERVER['HTTP_X_SENDPOST_SIGNATURE'] ?? null;
$apiKey = getenv('SENDPOST_API_KEY'); // Your Account API Key
if (!$signature) {
http_response_code(401);
echo json_encode(['error' => 'Missing signature']);
exit;
}
// Get raw request body
$requestBody = file_get_contents('php://input');
if (!verifyWebhookSignature($requestBody, $signature, $apiKey)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Parse JSON and process webhook
$webhookData = json_decode($requestBody, true);
// ... process webhook data
http_response_code(200);
echo json_encode(['received' => true]);
?>
Important Considerations
Request Body Handling
Critical: Always use the raw request body (as bytes/string) for signature verification, not the parsed JSON object. The signature is computed on the exact bytes sent by SendPost.
- Do: Read the raw body before parsing JSON
- Don’t: Parse JSON first and then try to verify the signature
- Do: Preserve the exact byte sequence received
- Don’t: Modify, prettify, or re-encode the body
Account API Key
The signature is computed using your Account API Key, not your Sub-Account API Key. Make sure you’re using the correct key for verification.
Retry Attempts
The X-SendPost-Webhook-Attempt header indicates which retry attempt this is. SendPost will retry failed webhook deliveries for up to 10 hours. Each retry will have the same payload but a different attempt number.
Security Best Practices
- Always verify signatures before processing webhook data
- Use constant-time comparison to prevent timing attacks
- Store your API key securely (environment variables, secret management services)
- Log verification failures for security monitoring
- Reject requests without signatures immediately
Example Webhook Payload
Here’s an example of a webhook request with signature headers:
Headers:
Content-Type: application/json
X-SendPost-Signature: a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456
X-SendPost-Signature-Alg: hmac-sha256
X-SendPost-Webhook-Id: 550e8400-e29b-41d4-a716-446655440000
X-SendPost-Webhook-Attempt: 1
Body:
{
"event": {
"eventID": "edhg-123gh-afasdf-124egh",
"type": 5,
"messageID": "mjhl-1401-sasdf-129324",
"eventMetadata": {
"userAgent": {
"Family": "Chrome",
"Major": "91"
},
"os": {
"Family": "Windows"
}
}
},
"emailMessage": {
"messageID": "mjhl-1401-sasdf-129324",
"from": {
"email": "[email protected]",
"name": "Support Team"
},
"to": [
{
"email": "[email protected]",
"name": "John Doe"
}
],
"subject": "Welcome to Our Service"
}
}
The signature in X-SendPost-Signature is computed from the exact JSON body above using your Account API Key.