> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sendpost.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Signature Verification

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:

| 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)   |

### 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.

<Warning>
  Always use constant-time comparison when comparing signatures to prevent timing attacks. Never use simple string equality (`==` or `===`).
</Warning>

## Code Examples

### JavaScript (Node.js)

```javascript theme={null}
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

```python theme={null}
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

```go theme={null}
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

```ruby theme={null}
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)

```java theme={null}
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 theme={null}
<?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

<Warning>
  **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.
</Warning>

* **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:**

```json theme={null}
{
  "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": "support@example.com",
      "name": "Support Team"
    },
    "to": [
      {
        "email": "customer@example.com",
        "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.

<Info>
  For more information about webhook events and payloads, see [SendPost Webhook Object](/api-reference/webhook/sendpost-webhook-object).
</Info>
