Skip to main content
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.

Webhook Headers

SendPost includes the following headers in every webhook request:
HeaderDescription
X-SendPost-SignatureHMAC-SHA256 signature of the webhook payload (hex-encoded)
X-SendPost-Signature-AlgAlgorithm identifier (hmac-sha256)
X-SendPost-Webhook-IdUnique webhook UUID for tracking
X-SendPost-Webhook-AttemptAttempt number for retry tracking (useful for debugging)

Example Webhook Request Headers

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:
  1. Extract the signature from the X-SendPost-Signature header
  2. Read the raw request body (as bytes/string, before any JSON parsing)
  3. Compute HMAC-SHA256 of the request body using your Account API Key as the secret
  4. Hex-encode the computed signature
  5. 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

Go

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

  1. Always verify signatures before processing webhook data
  2. Use constant-time comparison to prevent timing attacks
  3. Store your API key securely (environment variables, secret management services)
  4. Log verification failures for security monitoring
  5. 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.
For more information about webhook events and payloads, see SendPost Webhook Object.