> For the complete documentation index, see [llms.txt](https://docs.revenium.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.revenium.io/integrations/webhook-signing.md).

# Webhook Signing

Outbound webhooks from Revenium can be cryptographically signed so receivers verify the payload originated from Revenium and was not tampered with in transit. Signing is opt-in per webhook: enable it from the dashboard, copy the secret once, verify every delivery against that secret.

## How it works

When a webhook has signing enabled, every dispatch carries two headers and a deterministic signature over the request body. The receiver recomputes the signature locally and compares against the one on the wire. If they match, the payload is authentic.

Webhooks without signing enabled keep being delivered unchanged. No headers are added, and the receiver does not need to do anything different.

## Headers

Two headers appear on every signed delivery.

| Header                         | Meaning                                                                                                                                                     |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Revenium-Signature-256`     | `sha256=<hex>`. During a 24h rotation overlap, two signatures are present: `sha256=<hex_new>, sha256=<hex_previous>` (RFC 7230 multi-value, comma + space). |
| `X-Revenium-Webhook-Timestamp` | Unix epoch seconds at signing time. Used both as part of the signed payload and to reject replays.                                                          |

## Verification algorithm

```
signed_payload = <X-Revenium-Webhook-Timestamp> + "." + raw_request_body_bytes
expected       = HMAC-SHA256(secret, signed_payload)
header_value   = "sha256=" + lowercase_hex(expected)
```

Three rules a correct verifier must follow:

1. **Sign the raw wire bytes**, not a re-serialised copy. If your framework parses JSON then stringifies it back, whitespace and key order differ from what was signed. Read the body as bytes before JSON-decoding.
2. **Reject the request if the timestamp drifts more than 300 seconds** from your local clock. This is the replay defence.
3. **Use a constant-time comparison.** Python: `hmac.compare_digest`. Node: `crypto.timingSafeEqual`. Go: `hmac.Equal`. Direct `==` comparison leaks the signature through timing side-channels.

## Multi-signature during rotation

When a customer rotates the signing secret in the dashboard, the previous secret remains valid for **24 hours** so receivers can roll forward without downtime. During that window, every delivery is signed with **both** secrets, and the header carries both signatures comma-separated:

```
X-Revenium-Signature-256: sha256=8e7f3a..., sha256=2b4c91...
```

Receivers verify by trying their currently-deployed secret against every signature in the header. As long as one matches, the delivery is authentic. After the 24h window expires, the header returns to a single signature on the new secret.

An immediate rotation (used when a secret is known to be compromised) skips the overlap window: the previous secret stops verifying right away.

## Testing your receiver

The API exposes two endpoints that together let you produce a signed delivery on demand, useful during integration:

1. **Enable signing or rotate the secret.** `POST /profitstream/v2/api/export-configurations/{id}/rotate-signing-secret?teamId=...` returns `{ "signingSecret": "<value>" }`. The secret is shown exactly once. Body `{ "immediate": true }` skips the rotation overlap; `{}` (default) starts the 24h graceful window.
2. **Trigger a synthetic delivery.** `POST /profitstream/v2/api/export-configurations/{id}/send-test-event?teamId=...` dispatches a synthetic event through the same code path used in production. The receiver sees the same headers and signing behaviour as a real event.

Configure your receiver to log the headers and body, capture one synthetic delivery, run it through your verifier, and confirm the signature matches.

## Code examples

Reference implementations using language standard libraries.

### Python

```python
import hmac
import hashlib
import time

def verify_signature(payload: bytes, signature_header: str, timestamp_header: str, secrets: list[str], tolerance_seconds: int = 300) -> bool:
    if abs(time.time() - int(timestamp_header)) > tolerance_seconds:
        return False
    signed_payload = f"{timestamp_header}.".encode() + payload
    received = [s.strip().removeprefix("sha256=") for s in signature_header.split(",") if s.strip().startswith("sha256=")]
    for secret in secrets:
        expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, r) for r in received):
            return True
    return False
```

### Node

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

function verifySignature({ payload, signatureHeader, timestampHeader, secrets, toleranceSeconds = 300 }) {
  if (Math.abs(Date.now() / 1000 - parseInt(timestampHeader, 10)) > toleranceSeconds) return false;
  const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
  const signedPayload = Buffer.concat([Buffer.from(`${timestampHeader}.`), body]);
  const received = signatureHeader.split(',').map(s => s.trim()).filter(s => s.startsWith('sha256=')).map(s => s.slice(7));
  for (const secret of secrets) {
    const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
    const expectedBuf = Buffer.from(expected, 'hex');
    if (received.some(r => {
      const rBuf = Buffer.from(r, 'hex');
      return rBuf.length === expectedBuf.length && crypto.timingSafeEqual(expectedBuf, rBuf);
    })) return true;
  }
  return false;
}
```

### Go

```go
package main

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

func VerifySignature(payload []byte, signatureHeader, timestampHeader string, secrets []string, toleranceSeconds int) bool {
    if toleranceSeconds == 0 {
        toleranceSeconds = 300
    }
    ts, err := strconv.ParseInt(timestampHeader, 10, 64)
    if err != nil {
        return false
    }
    if math.Abs(float64(time.Now().Unix()-ts)) > float64(toleranceSeconds) {
        return false
    }
    signedPayload := append([]byte(fmt.Sprintf("%s.", timestampHeader)), payload...)
    var received []string
    for _, s := range strings.Split(signatureHeader, ",") {
        s = strings.TrimSpace(s)
        if strings.HasPrefix(s, "sha256=") {
            received = append(received, strings.TrimPrefix(s, "sha256="))
        }
    }
    for _, secret := range secrets {
        mac := hmac.New(sha256.New, []byte(secret))
        mac.Write(signedPayload)
        expected := hex.EncodeToString(mac.Sum(nil))
        for _, r := range received {
            if hmac.Equal([]byte(expected), []byte(r)) {
                return true
            }
        }
    }
    return false
}
```

## Recommended receiver behavior

1. **Read the body as bytes before parsing.** Frameworks that auto-decode JSON may discard the original byte stream. If you can only get the parsed object, you cannot verify.
2. **Verify before any side effect.** Reject unverified deliveries early; do not write to your database or trigger downstream calls until the signature checks out.
3. **Keep both secrets while you rotate.** During the 24h overlap, your receiver should hold the new secret and the previous one. Drop the previous secret only after the window closes.
4. **Persist a small dedup window.** Two retries can deliver the same event; the signature confirms authenticity but not uniqueness. Use the event identifier in the payload (or the timestamp + body hash) to deduplicate on your side.

If you have a use case that needs broader signing coverage or alternative algorithms, [contact support](mailto:support@revenium.io).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.revenium.io/integrations/webhook-signing.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
