# Validating Deliveries

To ensure webhook deliveries are authentic and untampered, webhook requests can be signed using **HMAC**.

Signature verification is **optional** and only applies when a **secret** is configured on the webhook.

## When Is a Signature Included?

- ✅ If a webhook **has a secret configured**, requests **will include** the `X-Agc-Signature` header
- ❌ If **no secret is configured**, the `X-Agc-Signature` header **will NOT be included**

## Signature Headers

When enabled, the following headers are used for verification:

```http
X-Agc-Signature: <hex-encoded hmac>
X-Agc-Timestamp: <ISO-8601 timestamp>
```

- `X-Agc-Signature` is the computed HMAC
- `X-Agc-Timestamp` is included in the signed message to prevent replay attacks

## Signature Construction

The signature is generated using:

- **Algorithm**: `HMAC-SHA256`
- **Secret**: The webhook secret you configured in the portal
- **Signed value**: A concatenation of the timestamp and the raw request body

### Signed Payload Format

```
<timestamp>.<raw_request_body>
```

Example:

```
2026-01-22T06:40:00.000Z.{"version":"1.0","tenant_id":"tenant_abc",...}
```

> ⚠️ The **raw request body must be used exactly as received**
> Do not parse, reformat, pretty-print, or reorder JSON keys before verification.

---

## Verification Steps

### 1. Read Required Values

From the incoming request:

- `X-Agc-Signature`
- `X-Agc-Timestamp`
- Raw request body (string)
- Your webhook secret

### 2. Recreate the Signed Payload

```text
signed_payload = X-Agc-Timestamp + "." + raw_body
```

### 3. Compute the Expected Signature

Using HMAC-SHA256:

```
expected_signature = HMAC_SHA256(secret, signed_payload)
```

The resulting signature is **hex-encoded lowercase**.

### 4. Compare Signatures Securely

Compare the received signature with the computed signature using a **constant-time comparison**.

If they match, the webhook is valid.

---

## Timestamp Validation (Recommended)

To protect against replay attacks, validate the timestamp:

- Parse `X-Agc-Timestamp`
- Reject requests older than a defined window (e.g. **5 minutes**)

Example rule:

```
abs(now - timestamp) <= 300 seconds
```

---

## End-to-End Verification Examples

This section demonstrates a complete webhook handling flow using a shared webhook secret.

> ⚠️ Always use the **raw request body** exactly as received.
**Javascript**
```js

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

// --------------------------------------------------
// Raw body capture (MUST run before signature check)
// --------------------------------------------------
app.use(
  express.json({
    verify: (req, _res, buf) => {
      req.rawBody = buf.toString("utf8");
    },
  })
);

// --------------------------------------------------
// Helpers
// --------------------------------------------------
function getHeader(req, name) {
  return req.get(name) || undefined;
}

function computeHmacHex(secret, timestamp, rawBody) {
  const signedPayload = `${timestamp}.${rawBody}`;
  return crypto.createHmac("sha256", secret).update(signedPayload, "utf8").digest("hex");
}

function safeEqualHex(aHex, bHex) {
  // timingSafeEqual requires buffers of equal length
  if (aHex.length !== bHex.length) return false;

  try {
    return crypto.timingSafeEqual(Buffer.from(aHex, "hex"), Buffer.from(bHex, "hex"));
  } catch {
    // Invalid hex input
    return false;
  }
}

function isTimestampFresh(isoTimestamp, maxSkewSeconds) {
  const ms = Date.parse(isoTimestamp);
  if (Number.isNaN(ms)) return false;

  const ageSeconds = Math.abs(Date.now() - ms) / 1000;
  return ageSeconds <= maxSkewSeconds;
}

function verifyAgcSignature({ secret, signature, timestamp, rawBody }) {
  const expected = computeHmacHex(secret, timestamp, rawBody);
  return safeEqualHex(signature, expected);
}

// --------------------------------------------------
// Webhook endpoint
// --------------------------------------------------
app.post("/webhooks/agc", (req, res) => {
  const signature = getHeader(req, "X-Agc-Signature");
  const deliveryTimestamp = getHeader(req, "X-Agc-Timestamp");
  const eventType = getHeader(req, "X-Agc-Event-Type");
  const eventId = getHeader(req, "X-Agc-Event-Id");

  // Signature & timestamp required if a secret is configured
  if (WEBHOOK_SECRET) {
    if (!signature) return res.status(401).send("Missing signature");
    if (!deliveryTimestamp) return res.status(401).send("Missing timestamp");
    if (!req.rawBody) return res.status(400).send("Missing raw body");

    if (!isTimestampFresh(deliveryTimestamp, MAX_SKEW_SECONDS)) {
      return res.status(401).send("Expired timestamp");
    }

    const valid = verifyAgcSignature({
      secret: WEBHOOK_SECRET,
      signature,
      timestamp: deliveryTimestamp,
      rawBody: req.rawBody,
    });

    if (!valid) return res.status(401).send("Invalid signature");
  }

  // --------------------------------------------------
  // Process event (MUST be idempotent)
  // --------------------------------------------------
  const payload = req.body;

  console.log("Webhook received:", {
    eventId,
    eventType,
    payload,
  });

  return res.sendStatus(200);
});

// --------------------------------------------------
// Server
// --------------------------------------------------
app.listen(3000, () => {
  console.log("Webhook listener running on port 3000");
});
```

**Typescript**
```ts

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

// ---- Raw body capture (must be BEFORE signature verification) ----
type RawBodyRequest = Request & { rawBody?: string };

app.use(
  express.json({
    verify: (req: RawBodyRequest, _res, buf) => {
      req.rawBody = buf.toString("utf8");
    },
  })
);

// ---- Helpers ----
const getHeader = (req: Request, name: string): string | undefined => req.get(name) ?? undefined;

function computeHmacHex(secret: string, timestamp: string, rawBody: string): string {
  const signedPayload = `${timestamp}.${rawBody}`;
  return crypto.createHmac("sha256", secret).update(signedPayload, "utf8").digest("hex");
}

function safeEqualHex(aHex: string, bHex: string): boolean {
  // timingSafeEqual requires equal-length buffers; short-circuit safely
  if (aHex.length !== bHex.length) return false;

  try {
    return crypto.timingSafeEqual(Buffer.from(aHex, "hex"), Buffer.from(bHex, "hex"));
  } catch {
    // invalid hex input
    return false;
  }
}

function isTimestampFresh(isoTimestamp: string, maxSkewSeconds: number): boolean {
  const ms = Date.parse(isoTimestamp);
  if (Number.isNaN(ms)) return false;

  const ageSeconds = Math.abs(Date.now() - ms) / 1000;
  return ageSeconds <= maxSkewSeconds;
}

function verifyAgcSignature(opts: { secret: string; signature: string; timestamp: string; rawBody: string }): boolean {
  const expected = computeHmacHex(opts.secret, opts.timestamp, opts.rawBody);
  return safeEqualHex(opts.signature, expected);
}

// ---- Route ----
app.post("/webhooks/agc", (req: RawBodyRequest, res: Response) => {
  const signature = getHeader(req, "X-Agc-Signature");
  const deliveryTimestamp = getHeader(req, "X-Agc-Timestamp");
  const eventType = getHeader(req, "X-Agc-Event-Type");
  const eventId = getHeader(req, "X-Agc-Event-Id");

  // If you configured a secret, signature + timestamp are required.
  if (WEBHOOK_SECRET) {
    if (!signature) return res.status(401).send("Missing signature");
    if (!deliveryTimestamp) return res.status(401).send("Missing timestamp");
    if (!req.rawBody) return res.status(400).send("Missing raw body");

    if (!isTimestampFresh(deliveryTimestamp, MAX_SKEW_SECONDS)) {
      return res.status(401).send("Expired timestamp");
    }

    const ok = verifyAgcSignature({
      secret: WEBHOOK_SECRET,
      signature,
      timestamp: deliveryTimestamp,
      rawBody: req.rawBody,
    });

    if (!ok) return res.status(401).send("Invalid signature");
  }

  // Process event (make idempotent using event_id from body or X-Agc-Event-Id)
  const payload = req.body;

  console.log("Webhook received:", {
    eventId,
    eventType,
    payload,
  });

  return res.sendStatus(200);
});

// ---- Server ----
app.listen(3000, () => {
  console.log("Webhook listener running on port 3000");
});
```

**Python**
```py

from fastapi import FastAPI, Request, HTTPException
from datetime import datetime, timezone

app = FastAPI()

WEBHOOK_SECRET = os.getenv("AGC_WEBHOOK_SECRET")

def verify_signature(secret, signature, timestamp, raw_body):
    payload = f"{timestamp}.{raw_body}".encode("utf-8")

    expected = hmac.new(
        secret.encode("utf-8"),
        payload,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected)

@app.post("/webhooks/agc")
async def handle_webhook(request: Request):
    raw_body = (await request.body()).decode("utf-8")

    signature = request.headers.get("X-Agc-Signature")
    timestamp = request.headers.get("X-Agc-Timestamp")
    event_type = request.headers.get("X-Agc-Event-Type")
    event_id = request.headers.get("X-Agc-Event-Id")

    # 1. Signature required if secret exists
    if WEBHOOK_SECRET and not signature:
        raise HTTPException(status_code=401, detail="Missing signature")

    # 2. Timestamp validation (5 minutes)
    if timestamp:
        age = abs(
            (request.state.now if hasattr(request.state, "now") else 0)
        )

    # Simpler timestamp check
    event_time = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    now = datetime.now(timezone.utc)

    if abs((now - event_time).total_seconds()) > 300:
        raise HTTPException(status_code=401, detail="Expired timestamp")

    # 3. Verify signature
    if WEBHOOK_SECRET and signature and timestamp:
        if not verify_signature(
            WEBHOOK_SECRET, signature, timestamp, raw_body
        ):
            raise HTTPException(status_code=401, detail="Invalid signature")

    # 4. Process event
    payload = await request.json()

    print("Webhook received:", {
        "event_id": event_id,
        "event_type": event_type,
        "payload": payload,
    })

    return {"status": "ok"}
```

---

## Handling Missing Signatures

If the `X-Agc-Signature` header is missing:

- ✅ Check if the webhook **is configured without a secret**
- ❌ If a secret **is configured**, treat the request as **invalid**

---

## Best Practices

- Always use **HTTPS**
- Store webhook secrets securely
- Use **raw request bodies** for verification
- Enforce a timestamp tolerance window
- Log failed verification attempts
- Rotate secrets periodically

Source: https://apidocs.agilitycredit.com/webhooks/validating-deliveries
