# Webhook signing

> Secure your webhook endpoints with HMAC-SHA256 signatures following the Standard Webhooks convention

Source: https://emailconnect.eu/help/webhook-signing/

Webhook signing adds a cryptographic signature to each webhook request, allowing your server to verify that requests genuinely came from EmailConnect. This prevents attackers from sending forged webhook payloads to your endpoint.

EmailConnect follows the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) convention, an open specification adopted by services like Svix, Resend, and others. You can use any Standard Webhooks-compatible library to verify signatures.

> **Does your secret start with `whsec_`?** If not, it was issued before the Standard Webhooks migration and is used as raw key bytes instead of being base64-decoded — **no off-the-shelf Standard Webhooks library can verify a delivery signed with it**, because they all base64-decode the secret unconditionally. [Regenerate the secret](#regenerating-a-secret) to get a `whsec_` one before following the library path on this page.

> **Still reading `X-Webhook-Signature`?** The legacy `X-Webhook-Signature` / `X-Webhook-Timestamp` headers were retired on 9 August 2026 and are no longer sent. Only the `webhook-id` / `webhook-timestamp` / `webhook-signature` headers described below are sent today.

## How it works

When webhook signing is enabled:

1. EmailConnect generates a base64-encoded secret key for your webhook (prefixed with `whsec_`)
2. For each request, EmailConnect constructs a signed content string from the message ID, timestamp, and body
3. An HMAC-SHA256 signature is computed and included as an HTTP header
4. Your server verifies the signature using the shared secret

Signing on its own blocks nothing. EmailConnect signs every delivery once signing is on, and your endpoint still receives every request sent to it, signed or not. The protection starts when your endpoint checks the signature and rejects a request that fails.

## Signature headers

EmailConnect sends the following headers with signed webhooks:

| Header | Description |
|--------|-------------|
| `webhook-id` | Unique identifier for the email being delivered (e.g., `msg_2KWP...`). The same on every retry and replay of that email — see [Deduplicating deliveries](#deduplicating-deliveries) |
| `webhook-timestamp` | Unix timestamp (seconds) when the request was signed |
| `webhook-signature` | Signature in format `v1,<base64-signature>` (may contain multiple space-separated signatures during key rotation) |

## The signing scheme

The signed content is the concatenation of the message ID, timestamp, and JSON body, separated by dots:

```
{webhook-id}.{webhook-timestamp}.{body}
```

This content is signed with HMAC-SHA256 using the base64-decoded secret (strip the `whsec_` prefix before decoding). The resulting signature is base64-encoded and prefixed with `v1,`.

## Enabling webhook signing

### For new webhooks

1. Create a new webhook in your dashboard
2. Toggle "Webhook signing" in the Advanced options section
3. Click "Create webhook"
4. Copy the generated secret (starts with `whsec_`) immediately — it won't be shown again

### For existing webhooks

1. Edit an existing webhook
2. Toggle "Webhook signing" to enable it
3. Click "Save"
4. Copy the generated secret immediately

### Regenerating a secret

If you need a new secret (e.g., after a potential compromise):

1. Edit your webhook
2. Click "Regenerate" next to the signing status
3. Copy the new secret
4. Update your server with the new secret

## Verifying signatures

### Step-by-step process

1. Extract the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers
2. Reject the request if the timestamp is too far from the current time (recommended: 5 minutes)
3. Construct the signed content: `{webhook-id}.{webhook-timestamp}.{raw-body}`
4. Base64-decode your secret (after removing the `whsec_` prefix)
5. Compute HMAC-SHA256 of the signed content using the decoded secret
6. Base64-encode the result and compare against the signature(s) in the header (after the `v1,` prefix)

### Using a Standard Webhooks library

The easiest approach is to use an off-the-shelf Standard Webhooks library:

```bash
npm install standardwebhooks     # Node.js
pip install standardwebhooks     # Python
```

### Node.js / Express

```javascript
const crypto = require('crypto');

function verifyWebhook(req, secret) {
  const msgId = req.headers['webhook-id'];
  const timestamp = req.headers['webhook-timestamp'];
  const signatures = req.headers['webhook-signature'];

  if (!msgId || !timestamp || !signatures) {
    return false;
  }

  // Reject old timestamps (5-minute tolerance)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return false;
  }

  // Construct signed content. The signature covers the exact bytes we
  // received — re-serialising the parsed body will not reproduce them, since
  // key order and character escaping can differ. Fail loudly rather than
  // verifying against the wrong bytes.
  if (!req.rawBody) {
    throw new Error('Raw body unavailable — check the express.json verify hook below');
  }
  const signedContent = `${msgId}.${timestamp}.${req.rawBody}`;

  // Decode secret (strip whsec_ prefix, then base64-decode)
  const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');

  // Compute expected signature
  const expected = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent, 'utf8')
    .digest('base64');

  // Check against all provided signatures (handles key rotation)
  const expectedBuf = Buffer.from(expected);

  return signatures
    .split(' ')
    .some(sig => {
      const [version, value] = sig.split(',');
      if (version !== 'v1' || !value) return false;

      // timingSafeEqual throws unless both buffers are the same length, so
      // guard first — otherwise a malformed signature crashes the handler
      // instead of being rejected.
      const valueBuf = Buffer.from(value);
      return valueBuf.length === expectedBuf.length
        && crypto.timingSafeEqual(expectedBuf, valueBuf);
    });
}

// Express middleware to preserve raw body
app.use('/webhook', express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));

app.post('/webhook/emails', (req, res) => {
  if (!verifyWebhook(req, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const emailData = req.body;
  console.log('Verified email from:', emailData.message.sender.email);

  res.status(200).json({ success: true });
});
```

### Python / Flask

```python
import hmac
import hashlib
import base64
import time
import os
from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')

def verify_webhook(request, secret):
    msg_id = request.headers.get('webhook-id')
    timestamp = request.headers.get('webhook-timestamp')
    signatures = request.headers.get('webhook-signature')

    if not all([msg_id, timestamp, signatures]):
        return False

    # Reject old timestamps (5-minute tolerance)
    now = int(time.time())
    if abs(now - int(timestamp)) > 300:
        return False

    # Construct signed content
    raw_body = request.get_data(as_text=True)
    signed_content = f'{msg_id}.{timestamp}.{raw_body}'

    # Decode secret (strip whsec_ prefix, then base64-decode)
    secret_str = secret.removeprefix('whsec_')
    secret_bytes = base64.b64decode(secret_str)

    # Compute expected signature
    expected = base64.b64encode(
        hmac.new(secret_bytes, signed_content.encode('utf-8'), hashlib.sha256).digest()
    ).decode()

    # Check against all provided signatures (handles key rotation)
    for sig in signatures.split(' '):
        version, _, value = sig.partition(',')
        if version == 'v1' and hmac.compare_digest(expected, value):
            return True

    return False

@app.route('/webhook/emails', methods=['POST'])
def handle_webhook():
    if not verify_webhook(request, WEBHOOK_SECRET):
        return jsonify({'error': 'Invalid signature'}), 401

    email_data = request.json
    print(f"Verified email from: {email_data['message']['sender']['email']}")

    return jsonify({'success': True})
```

## n8n

The [EmailConnect community node for n8n](https://www.npmjs.com/package/n8n-nodes-emailconnect) (version 1.4.2 or later) verifies signatures for you. Each EmailConnect Trigger has its own secret, so each webhook can have its own:

1. Generate a secret in `whsec_` + base64 format, for example with `echo "whsec_$(openssl rand -base64 24)"`, or copy the one EmailConnect generated for the webhook.
2. In the EmailConnect Trigger, turn on **Verify Signatures** and create an **EmailConnect Webhook Signing API** credential that holds the secret. Use one such credential per trigger.
3. Activate the workflow, or deactivate and reactivate it if it is already active.

On activation the trigger stores its secret on its webhook, replacing any secret the webhook had, so you do not need to enable signing in the dashboard first. From then on, a request without a valid signature gets HTTP 401 and does not start a run. Deliveries from EmailConnect, including **Send test** from the dashboard, pass.

To rotate, change the secret in that credential and reactivate the workflow. This works on every plan, Free included.

## Deduplicating deliveries

A delivery can reach you more than once: we retry when your endpoint answers with a 5xx or does not answer in time, and an endpoint that processed a request but was slow to reply will see the retry as a second copy.

**Every delivery carries a `webhook-id` header, signed or not**, and it is your deduplication key. The same value is in the JSON body as the top-level `id`.

- It identifies the **email**, not the attempt. Every retry of the same email carries the same `webhook-id`.
- A **manual replay** from the logs (or `POST /api/v1/logs/{messageId}/retry`) carries the same `webhook-id` as the original delivery, with a fresh `webhook-timestamp` and signature.
- Two different emails never share one.

So the usual pattern works: store the `webhook-id` of every delivery you have processed, and acknowledge a repeat with a `2xx` without processing it again.

One consequence to plan for: **if you deduplicate, a replay of an email you already accepted will be skipped by your own handler.** That is correct when the replay is accidental. If you are replaying on purpose, because you lost the data on your side, clear that id from your store first or bypass the check for that request.

**On an unsigned webhook, `webhook-id` identifies the delivery and proves nothing.** Anyone who can reach your endpoint can send a request with a `webhook-id` header. It is safe to deduplicate on, but it only tells you the request came from EmailConnect together with a valid `webhook-signature`.

Two cases where the body has no `id`, while the header is still sent:

- **A custom payload template** produces exactly the body you defined. Add `"id": "{{id}}"` to the template if you want it in the body.
- **A replay of an email received before `id` was introduced** (October 2026) re-sends the payload as it was stored then.

Prefer `webhook-id` over `envelope.messageId` for deduplication. The Message-ID is chosen by the sender: it can be missing, and nothing stops two different emails from carrying the same one.

## Security best practices

### Store secrets securely
- Use environment variables, never hardcode secrets
- Use a secrets manager in production (AWS Secrets Manager, HashiCorp Vault, etc.)
- Rotate secrets periodically or after any potential exposure

### Verify timestamps
- Reject requests where `webhook-timestamp` is more than 5 minutes from the current time
- This prevents replay attacks

### Use timing-safe comparison
- Always use constant-time string comparison (`crypto.timingSafeEqual`, `hmac.compare_digest`, etc.)
- Regular string comparison can leak information through timing differences
- Node's `crypto.timingSafeEqual` **throws** when the two buffers differ in length — compare lengths first, or a malformed signature crashes your handler instead of being rejected. Python's `hmac.compare_digest` handles this for you

### Log verification failures
- Log failed signature verifications for security monitoring
- Include relevant details (timestamp, source IP) but never log the secret

### Use HTTPS
- Always use HTTPS for webhook endpoints
- HTTPS encrypts the entire request including headers and signature

## Troubleshooting

### "Invalid signature" errors

**Using n8n?**
- The EmailConnect Trigger checks against the secret in its own signing credential. If you changed that secret, or regenerated the secret in the dashboard, reactivate the workflow so the trigger stores its secret on the webhook again.

**Check your secret**
- Ensure you're using the correct `whsec_`-prefixed secret for this webhook
- Secrets are shown only once — regenerate if lost

**Check raw body handling**
- The signature is computed on the raw JSON request body
- Parsing JSON before verification can change whitespace/formatting
- Use middleware that preserves the raw body

**Check signed content construction**
- The signed content must be `{webhook-id}.{webhook-timestamp}.{body}` — all three parts, dot-separated
- Use the raw body string, not a re-serialised object

### Testing signature verification

You can test your verification logic with a simple script:

```javascript
const crypto = require('crypto');

const secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'; // example
const msgId = 'msg_test123';
const timestamp = Math.floor(Date.now() / 1000).toString();
const body = '{"test": "payload"}';

const signedContent = `${msgId}.${timestamp}.${body}`;
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');

const signature = 'v1,' + crypto
  .createHmac('sha256', secretBytes)
  .update(signedContent, 'utf8')
  .digest('base64');

console.log('webhook-id:', msgId);
console.log('webhook-timestamp:', timestamp);
console.log('webhook-signature:', signature);
console.log('Body:', body);
```

## Need help?

Having issues with webhook signing?
- Contact support at support@emailconnect.eu
- Include your webhook ID and any error messages
- See the [Standard Webhooks specification](https://github.com/standard-webhooks/standard-webhooks) for protocol details
