> For the complete documentation index, see [llms.txt](https://developers.share.inc/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developers.share.inc/webhooks/verification.md).

# Signature verification

Every signed webhook delivery carries an HMAC-SHA256 signature so you can prove the request came from Share, not a spoofer that knows your URL. The signature also covers a timestamp, so an attacker who captures a real delivery cannot replay it later.

## The contract

For every signed delivery, Share sets two headers:

| Header              | Value                                                                                   |
| ------------------- | --------------------------------------------------------------------------------------- |
| `X-Share-Timestamp` | Unix seconds at the moment Share signed the request                                     |
| `X-Share-Signature` | `sha256=<hex>` where `<hex>` is `hex(HMAC-SHA256(signingSecret, "<timestamp>.<body>"))` |

The signed string is the timestamp, a single dot, then the **raw** request body bytes — exactly as they appear on the wire, before any JSON parser touches them.

To verify a delivery:

1. Read `X-Share-Timestamp` and `X-Share-Signature` from the headers.
2. Read the raw body as bytes. Do not parse it yet.
3. Compute `hex(HMAC-SHA256(signingSecret, "<timestamp>.<rawBody>"))`.
4. Strip the `sha256=` prefix from `X-Share-Signature` and compare the two hex strings with a **constant-time** equality function. A regular `==` comparison leaks information through timing.
5. Reject the request if the signature does not match — return `401` without reading the body.
6. Optionally, reject the request if `X-Share-Timestamp` is more than five minutes from your own clock. This narrows the replay window even if the secret ever leaks.

If you skip any of steps 2, 3, 4, you have not actually verified the signature — you have only verified that someone could compute one.

## Node.js

```javascript
import crypto from 'crypto';
import express from 'express';

const app = express();
const SECRET = process.env.SHARE_SIGNING_SECRET;
const MAX_SKEW_SECONDS = 5 * 60;

app.post(
  '/webhooks/share',
  // Capture the raw body — do NOT use express.json() upstream of this.
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const timestamp = req.header('X-Share-Timestamp');
    const signatureHeader = req.header('X-Share-Signature') ?? '';
    const provided = signatureHeader.replace(/^sha256=/, '');

    if (!timestamp || !provided) {
      return res.status(401).end();
    }

    const ageSeconds = Math.abs(
      Math.floor(Date.now() / 1000) - Number(timestamp),
    );
    if (Number.isNaN(ageSeconds) || ageSeconds > MAX_SKEW_SECONDS) {
      return res.status(401).end();
    }

    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${timestamp}.${req.body.toString('utf8')}`)
      .digest('hex');

    const a = Buffer.from(expected, 'hex');
    const b = Buffer.from(provided, 'hex');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end();
    }

    // Ack first, process async on a queue.
    res.status(204).end();
    enqueue(JSON.parse(req.body.toString('utf8')));
  },
);
```

## Python (Flask)

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

app = Flask(__name__)
SECRET = os.environ["SHARE_SIGNING_SECRET"].encode()
MAX_SKEW_SECONDS = 5 * 60

@app.post("/webhooks/share")
def receive():
    timestamp = request.headers.get("X-Share-Timestamp", "")
    signature_header = request.headers.get("X-Share-Signature", "")
    provided = signature_header.removeprefix("sha256=")
    if not timestamp or not provided:
        abort(401)

    try:
        age = abs(int(time.time()) - int(timestamp))
    except ValueError:
        abort(401)
    if age > MAX_SKEW_SECONDS:
        abort(401)

    raw = request.get_data()  # bytes, before json parsing
    signed = f"{timestamp}.".encode() + raw
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, provided):
        abort(401)

    # Ack first, enqueue for processing.
    enqueue(request.get_json())
    return "", 204
```

## Ruby (Rack)

```ruby
require "openssl"

SECRET             = ENV.fetch("SHARE_SIGNING_SECRET")
MAX_SKEW_SECONDS   = 5 * 60

post "/webhooks/share" do
  timestamp = request.env["HTTP_X_SHARE_TIMESTAMP"].to_s
  provided  = request.env["HTTP_X_SHARE_SIGNATURE"].to_s.sub(/\Asha256=/, "")
  halt 401 if timestamp.empty? || provided.empty?

  age = (Time.now.to_i - timestamp.to_i).abs
  halt 401 if age > MAX_SKEW_SECONDS

  raw      = request.body.tap(&:rewind).read
  expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{timestamp}.#{raw}")

  halt 401 unless Rack::Utils.secure_compare(expected, provided)

  enqueue(JSON.parse(raw))
  [204, {}, []]
end
```

## Go

```go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var (
    secret         = []byte(os.Getenv("SHARE_SIGNING_SECRET"))
    maxSkewSeconds = int64(5 * 60)
)

func receive(w http.ResponseWriter, r *http.Request) {
    timestamp := r.Header.Get("X-Share-Timestamp")
    provided := strings.TrimPrefix(r.Header.Get("X-Share-Signature"), "sha256=")
    if timestamp == "" || provided == "" {
        http.Error(w, "", http.StatusUnauthorized)
        return
    }

    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        http.Error(w, "", http.StatusUnauthorized)
        return
    }
    if abs(time.Now().Unix()-ts) > maxSkewSeconds {
        http.Error(w, "", http.StatusUnauthorized)
        return
    }

    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "", http.StatusBadRequest)
        return
    }

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

    if !hmac.Equal([]byte(expected), []byte(provided)) {
        http.Error(w, "", http.StatusUnauthorized)
        return
    }

    w.WriteHeader(http.StatusNoContent)
    enqueue(body)
}

func abs(x int64) int64 {
    if x < 0 {
        return -x
    }
    return x
}
```

## Constant-time compare — why it matters

A naive `expected == provided` returns at the first byte that differs. The longer the strings agree, the longer the comparison takes. An attacker that can submit signatures and measure response latency can recover your signing secret one byte at a time.

Every standard library ships a constant-time compare for this exact reason — use it:

| Language | Function                     |
| -------- | ---------------------------- |
| Node.js  | `crypto.timingSafeEqual`     |
| Python   | `hmac.compare_digest`        |
| Ruby     | `Rack::Utils.secure_compare` |
| Go       | `hmac.Equal`                 |
| Java     | `MessageDigest.isEqual`      |
| PHP      | `hash_equals`                |

If your language is not listed, look for "constant time comparison" or "timing-safe equal" in its crypto / security utilities. Do not roll your own.

## Common pitfalls

* **Parsing the body before verifying.** JSON parsers reorder keys, re-serialize numbers, and strip whitespace. The recomputed signature will not match. Always run the HMAC over the raw request bytes first, then parse.
* **Forgetting to strip `sha256=`.** The header is `sha256=<hex>`. The signature you compare against is the hex digits only.
* **Ignoring `X-Share-Timestamp`.** Without the timestamp check, an attacker that ever captures a real delivery (logged, mirrored in a staging proxy, etc.) can replay it forever.
* **Logging the signing secret.** Treat it like `X-Access-Secret`. Redact it from logs and exception trackers.
* **Skipping verification on retries.** Every attempt — including retries — carries a fresh `X-Share-Timestamp` and `X-Share-Signature`. Verify every request, not just the first one.

## What to do if a signature does not match

Return `401` immediately and do not process the body. If you start seeing mismatches you cannot explain:

1. Confirm you are using the **current** `signingSecret`. If a teammate ran `PUT /v1/webhooks/:id` with a new secret, the old one stops working immediately — there is no overlap window.
2. Confirm your framework is not silently mutating the body (some add-ons rewrite JSON, decompress, or strip BOMs).
3. Confirm you are signing `<timestamp>.<body>`, not the body alone.

When in doubt, mirror the suspect request to a logging-only handler, capture the exact bytes, and recompute the HMAC offline against the secret you have stored. If your offline computation matches Share's header, your framework is the problem; if it does not, your secret is the problem.

## Next steps

* [Setup](/webhooks/setup.md) — register a subscription and capture the secret
* [Envelope](/webhooks/envelope.md) — the body shape every delivery carries
* [Event catalog](/webhooks/event-catalog.md) — every event you can subscribe to
