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

# Verify webhook signatures

> Create a signed webhook, then verify the HMAC-SHA256 signature on each delivery before you trust it.

A webhook URL is a public, unauthenticated endpoint. Anyone who learns it can send it a forged request. Signed webhooks let your receiver confirm that a delivery actually came from Salesforge and was not altered in transit.

<Warning>
  Only webhooks created through the public API are signed today. Webhooks created in the app UI, Zapier, or Make are not signed yet.
</Warning>

## Prerequisites

* `WORKSPACE_ID`
* `SALESFORGE_API_KEY`
* A publicly reachable URL that answers `2xx` to receive deliveries

For the shared authentication pattern, see [Authentication](/authentication).

## How it works

```mermaid theme={null}
flowchart LR
  A[Create a webhook] --> B[Salesforge returns a signing secret once]
  B --> C[An event occurs]
  C --> D[Salesforge signs and sends the delivery]
  D --> E[Your endpoint recomputes the signature and compares it]
```

Each webhook gets its own secret, generated once and returned only in the create response. Every delivery is signed with HMAC-SHA256 over the event ID, the send timestamp, and the raw request body.

<Steps>
  <Step title="Create a signed webhook">
    `POST /workspaces/{workspaceID}/integrations/webhooks`

    ```json theme={null}
    {
      "name": "Reply notifications",
      "type": "email_replied",
      "url": "https://example.com/webhooks/salesforge"
    }
    ```

    `type` is the event that triggers this webhook. Omit `sequenceIds` to receive the event from every sequence, or set it to scope the webhook to specific sequences.

    The response includes `signingSecret`:

    ```json theme={null}
    {
      "id": "wh_config_9f8e7d6c5b4a",
      "name": "Reply notifications",
      "url": "https://example.com/webhooks/salesforge",
      "type": "email_replied",
      "sequenceIds": [],
      "sentCount": 0,
      "signingSecret": "whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
    }
    ```

    <Warning>
      `signingSecret` is returned exactly once. Store it immediately — it is never included in later responses. If you lose it, delete the webhook and create a new one; there is no rotation or retrieval endpoint.
    </Warning>
  </Step>

  <Step title="Read what arrives with each delivery">
    | Header                             | Example                                       | Use                                                                                     |
    | ---------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------- |
    | `X-SalesforgeAI-Webhook-Event-ID`  | `wh_0123456789abcdef`                         | First field of the signed content, and your deduplication key                           |
    | `X-SalesforgeAI-Webhook-Signature` | `t=1754563200,v1=d35d4326...`                 | `t` is the Unix timestamp that was signed. `v1` is the lowercase hex HMAC-SHA256 digest |
    | `User-Agent`                       | `SalesforgeAI/salesforge-webhook-service-1.1` | Identifies the sender. Never use this to authenticate a request                         |

    Example delivery used throughout this guide:

    ```json theme={null}
    {
      "webhookInfo": {
        "type": "email_sent"
      }
    }
    ```

    Treat the body as opaque bytes until the signature check passes — do not parse it first.
  </Step>

  <Step title="Verify the signature">
    1. Split the signature header on `,`, then each part on its first `=`, to read `t` and `v1`.
    2. Reject the delivery if `t` is more than 300 seconds from your current time.
    3. Rebuild the signed content by joining the event ID, the `t` value exactly as received, and the raw request body with literal `.` characters: `event_id + "." + t + "." + raw_body`.
    4. Compute HMAC-SHA256 of that content using the full secret — including the `whsec_` prefix — as the key, and compare the result to `v1` with a constant-time comparison.

    <Warning>
      Reject any delivery with no signature header. A missing header means the request either did not come from Salesforge, or came from a webhook created through a path that does not sign yet.
    </Warning>

    <CodeGroup>
      ```python verify_webhook.py theme={null}
      import hashlib
      import hmac
      import time


      def verify_webhook(secret, event_id, header, raw_body, tolerance_seconds=300):
          """raw_body must be the exact, unparsed request body (bytes)."""
          if not (secret and event_id and header):
              return False

          fields = {}
          for part in header.split(","):
              key, sep, value = part.partition("=")
              if sep:
                  fields[key] = value

          timestamp, signature = fields.get("t"), fields.get("v1")
          if not timestamp or not signature or not timestamp.isdigit():
              return False

          if abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
              return False

          signed_content = f"{event_id}.{timestamp}.".encode() + raw_body
          expected = hmac.new(secret.encode(), signed_content, hashlib.sha256).digest()

          try:
              received = bytes.fromhex(signature)
          except ValueError:
              return False

          return hmac.compare_digest(expected, received)
      ```

      ```javascript verifyWebhook.js theme={null}
      const crypto = require('node:crypto');

      function verifyWebhook({ secret, eventId, header, rawBody, toleranceSeconds = 300 }) {
        if (!secret || !eventId || !header) return false;

        const fields = {};
        for (const part of String(header).split(',')) {
          const i = part.indexOf('=');
          if (i > 0) fields[part.slice(0, i)] = part.slice(i + 1);
        }

        const { t, v1 } = fields;
        if (!t || !v1 || !/^\d+$/.test(t)) return false;
        if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSeconds) return false;

        const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8');
        const signedContent = Buffer.concat([Buffer.from(`${eventId}.${t}.`, 'utf8'), body]);
        const expected = crypto.createHmac('sha256', secret).update(signedContent).digest();

        const received = Buffer.from(v1, 'hex');
        return received.length === expected.length && crypto.timingSafeEqual(expected, received);
      }
      ```

      ```go verify_webhook.go theme={null}
      package webhook

      import (
        "crypto/hmac"
        "crypto/sha256"
        "crypto/subtle"
        "encoding/hex"
        "strconv"
        "strings"
        "time"
      )

      const toleranceSeconds = 300

      // VerifyWebhook checks a Salesforce webhook delivery. rawBody must be
      // the exact, unparsed request body.
      func VerifyWebhook(secret, eventID, header string, rawBody []byte) bool {
        if secret == "" || eventID == "" || header == "" {
          return false
        }

        fields := map[string]string{}
        for _, part := range strings.Split(header, ",") {
          key, value, found := strings.Cut(part, "=")
          if found {
            fields[key] = value
          }
        }

        timestamp, signature := fields["t"], fields["v1"]
        if timestamp == "" || signature == "" {
          return false
        }

        ts, err := strconv.ParseInt(timestamp, 10, 64)
        if err != nil {
          return false
        }
        if age := time.Now().Unix() - ts; age > toleranceSeconds || age < -toleranceSeconds {
          return false
        }

        mac := hmac.New(sha256.New, []byte(secret))
        mac.Write([]byte(eventID + "." + timestamp + "."))
        mac.Write(rawBody)
        expected := mac.Sum(nil)

        received, err := hex.DecodeString(signature)
        if err != nil {
          return false
        }

        return subtle.ConstantTimeCompare(expected, received) == 1
      }
      ```

      ```java WebhookVerifier.java theme={null}
      import javax.crypto.Mac;
      import javax.crypto.spec.SecretKeySpec;
      import java.nio.charset.StandardCharsets;
      import java.security.MessageDigest;
      import java.util.HashMap;
      import java.util.Map;

      public final class WebhookVerifier {

          private static final int TOLERANCE_SECONDS = 300;

          // rawBody must be the exact, unparsed request body.
          public static boolean verify(String secret, String eventId, String header, byte[] rawBody) {
              if (secret == null || secret.isEmpty() || eventId == null || eventId.isEmpty()
                      || header == null || header.isEmpty()) {
                  return false;
              }

              Map<String, String> fields = new HashMap<>();
              for (String part : header.split(",")) {
                  int i = part.indexOf('=');
                  if (i > 0) {
                      fields.put(part.substring(0, i), part.substring(i + 1));
                  }
              }

              String timestamp = fields.get("t");
              String signature = fields.get("v1");
              if (timestamp == null || signature == null || !timestamp.chars().allMatch(Character::isDigit)) {
                  return false;
              }

              long now = System.currentTimeMillis() / 1000;
              if (Math.abs(now - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) {
                  return false;
              }

              try {
                  Mac mac = Mac.getInstance("HmacSHA256");
                  mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
                  mac.update((eventId + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8));
                  byte[] expected = mac.doFinal(rawBody);

                  byte[] received = hexToBytes(signature);
                  return received.length == expected.length && MessageDigest.isEqual(expected, received);
              } catch (Exception e) {
                  return false;
              }
          }

          private static byte[] hexToBytes(String hex) {
              byte[] bytes = new byte[hex.length() / 2];
              for (int i = 0; i < bytes.length; i++) {
                  bytes[i] = (byte) ((Character.digit(hex.charAt(i * 2), 16) << 4)
                          + Character.digit(hex.charAt(i * 2 + 1), 16));
              }
              return bytes;
          }
      }
      ```
    </CodeGroup>

    <Tip>
      Always compare signatures with a constant-time function — `hmac.compare_digest`, `crypto.timingSafeEqual`, `subtle.ConstantTimeCompare`, or `MessageDigest.isEqual`. A regular `==` leaks timing information an attacker can use to guess the signature byte by byte.
    </Tip>

    Read the raw body before any JSON-parsing middleware touches it, or the signature will never match:

    | Framework     | How to get the raw body                                                |
    | ------------- | ---------------------------------------------------------------------- |
    | Flask         | `request.get_data()`                                                   |
    | Express       | `express.raw({ type: 'application/json' })` on the route               |
    | Go `net/http` | `io.ReadAll(r.Body)` — already raw, no extra config needed             |
    | Java Servlet  | `request.getInputStream()` — avoid a filter that parses the body first |
  </Step>

  <Step title="Test your implementation">
    Run this known-good vector through your verifier before pointing it at live traffic:

    ```plaintext theme={null}
    secret     = whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
    event_id   = wh_0123456789abcdef
    header     = t=1754563200,v1=d35d432699cc9e59eb73da4396b9a3a96003ba6a9ae259256d270ff3c8f24207
    raw_body   = {"webhookInfo":{"type":"email_sent"}}
    ```

    With the timestamp check disabled (the vector is old), it must return `true`. Flip one byte of the secret, event ID, or body, and it must return `false`. If a mutation still passes, your verifier is reading something other than what it signs.
  </Step>
</Steps>

## Common mistakes

| Mistake                                      | Symptom                                     | Fix                                                      |
| -------------------------------------------- | ------------------------------------------- | -------------------------------------------------------- |
| Framework parsed the body before you read it | Every signature fails                       | Read raw bytes before any JSON-parsing middleware runs   |
| Stripped the `whsec_` prefix from the secret | Every signature fails                       | Use the secret exactly as returned, including the prefix |
| Reformatted the timestamp before signing     | Fails only on some deliveries               | Reuse the `t` value from the header as a string          |
| Compared signatures with `==`                | Passes, but leaks timing information        | Use a constant-time comparison                           |
| Receiver clock drift                         | Valid deliveries rejected as stale          | Keep the receiver's clock synced (NTP)                   |
| Accepted requests with no signature header   | No visible symptom until an event is forged | Reject any delivery missing the signature header         |

## Related resources

* [Authentication](/authentication)
* [Security best practices](/authentication/security-best-practices)
* [API endpoints](/salesforge-api/api-endpoints)
