# Webhook Signature Verification

This guide explains how Fluxis signs outbound webhooks and how to verify that (1) the request came from Fluxis and (2) the JSON body was not tampered with.

Implementation reference: `src/pkg/utils/crypto_signature.go` (`GenerateRequestSignature`, `VerifyRequestSignature`, `toCanonicalJSON`).

Related: [Webhooks overview](/docs/developers/webhooks) · [API Integration Guide](/docs/developers/tutorials/api-integration-guide)

::::note Account-level webhooks
Webhook URLs and signing secrets are tied to an **account** (`POST /v1/account/{accountID}/webhook`), not to individual Points of Sale. Payment events include `point_of_sale_id` in the payload when relevant.
::::

## What verification proves

| Property    | Meaning                                                                 |
| ----------- | ----------------------------------------------------------------------- |
| Authenticity | Only someone with your webhook `secret` could produce a valid signature |
| Integrity    | The JSON body was not modified in transit                               |

::::warning
Reject requests that fail verification. Do not process payment fulfillment until the signature and timestamp checks pass.
::::

## Prerequisites

1. **Webhook secret** — Returned once in the response to `POST /v1/account/{accountID}/webhook`. Store it securely; it cannot be retrieved again.
2. **HTTPS endpoint** — Your server must accept `POST` with `Content-Type: application/json`.
3. **Parsed body** — Parse JSON first, then re-canonicalize for signing. Never sign raw request bytes.

## Algorithm

Fluxis uses HMAC-SHA256 over a deterministic string built from the timestamp and the webhook body:

```
signed_string = "<unix_timestamp_seconds>." + canonical_json(body)
signature     = hex( HMAC-SHA256( webhook_secret, signed_string ) )
```

**Delivery headers:**

| Header                | Value                                      |
| --------------------- | ------------------------------------------ |
| `X-Request-Signature` | Hex-encoded HMAC-SHA256                    |
| `X-Request-Timestamp` | Unix seconds (string, e.g. `"1712345678"`) |
| `Content-Type`        | `application/json`                         |

**Timestamp validation:** Reject if `now - timestamp > 300` seconds (`WEBHOOK_MAX_AGE_SECONDS`). Future timestamps are **not** rejected by the current server implementation — you may add stricter checks in your handler if desired.

## Signed object

Sign the **entire webhook body** as a JSON object, typically:

```json
{
  "data": {
    "notification_type": "payment_request_status_update",
    "payment_request_id": "aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf",
    "payment_request_type": "fixed",
    "external_reference_id": "order-123",
    "status": "completed",
    "point_of_sale_id": "3cc1f7ca-c017-4e7e-b00d-aca0da6e464e",
    "account_id": "c87464d1-c54a-48c3-80c9-bee4fddf593d"
  }
}
```

Do **not** sign only `data`, and do **not** sign the raw HTTP body string before parsing.

## Canonical JSON rules

Fluxis builds canonical JSON via `toCanonicalJSON`:

1. Marshal the value to JSON (struct tags and `omitempty` apply — nil/zero fields are omitted).
2. Unmarshal into `map[string]any` / arrays so nested objects become maps.
3. Marshal again — Go `json.Marshal` on maps produces **compact JSON** (no spaces) with keys **sorted alphabetically at every object level**.

Your verifier must reproduce this behavior:

| Rule | Detail |
| ---- | ------ |
| Compact | No spaces: `{"a":1}` not `{ "a": 1 }` |
| Sorted keys | At every object level, including nested `data` |
| As received | Optional fields absent in the payload must stay absent in canonical form |
| Not raw bytes | Middleware that reformats JSON before verify will break signatures |

::::note omitempty
If `external_reference_id` or `account_id` is unset, those keys are **omitted** from the JSON Fluxis signs. Rebuilding the body from a fixed schema that always includes those keys will produce a different signature.
::::

## Verification checklist

1. Read `X-Request-Signature` and `X-Request-Timestamp` from headers.
2. Parse the request body into a generic object (`map` / `dict`).
3. Reject if `now - timestamp > 300`.
4. Serialize the parsed object to canonical JSON (sorted keys, compact).
5. Build `signed_string = timestamp + "." + canonical_json`.
6. Compute `HMAC-SHA256(secret, signed_string)`, hex-encode, and compare with constant-time equality (after a length check).

## Worked example

Use these fixed values to test your implementation:

| Input | Value |
| ----- | ----- |
| Secret | `whsec_example_for_documentation_only` |
| Timestamp | `1712345678` |
| Body | See JSON below |

**Body (parsed object):**

```json
{
  "data": {
    "account_id": "c87464d1-c54a-48c3-80c9-bee4fddf593d",
    "external_reference_id": "order-123",
    "notification_type": "payment_request_status_update",
    "payment_request_id": "aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf",
    "payment_request_type": "fixed",
    "point_of_sale_id": "3cc1f7ca-c017-4e7e-b00d-aca0da6e464e",
    "status": "completed"
  }
}
```

**Canonical JSON** (single line):

```
{"data":{"account_id":"c87464d1-c54a-48c3-80c9-bee4fddf593d","external_reference_id":"order-123","notification_type":"payment_request_status_update","payment_request_id":"aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf","payment_request_type":"fixed","point_of_sale_id":"3cc1f7ca-c017-4e7e-b00d-aca0da6e464e","status":"completed"}}
```

**Signed string:**

```
1712345678.{"data":{"account_id":"c87464d1-c54a-48c3-80c9-bee4fddf593d","external_reference_id":"order-123","notification_type":"payment_request_status_update","payment_request_id":"aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf","payment_request_type":"fixed","point_of_sale_id":"3cc1f7ca-c017-4e7e-b00d-aca0da6e464e","status":"completed"}}
```

**Expected signature (hex):**

```
174b7c02c84d9112e8013336ea632ae056b1c468387eca1815d3b1c24f5c0c49
```

If your implementation produces this signature for the inputs above, it matches Fluxis production signing.

---

## Code examples

### Node.js / TypeScript

```typescript
import crypto from "crypto";

function sortKeys(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value !== null && typeof value === "object") {
    return Object.keys(value as object)
      .sort()
      .reduce((acc, key) => {
        acc[key] = sortKeys((value as Record<string, unknown>)[key]);
        return acc;
      }, {} as Record<string, unknown>);
  }
  return value;
}

export function verifyWebhookSignature(
  payload: unknown,
  signature: string,
  timestamp: string,
  secret: string,
): boolean {
  const MAX_AGE_SECONDS = 300;
  const requestTimestamp = parseInt(timestamp, 10);
  if (Math.floor(Date.now() / 1000) - requestTimestamp > MAX_AGE_SECONDS) {
    return false;
  }

  const canonicalJson = JSON.stringify(sortKeys(payload));
  const signedString = `${timestamp}.${canonicalJson}`;
  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(signedString)
    .digest("hex");

  if (signature.length !== expectedSignature.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(signature, "utf8"),
    Buffer.from(expectedSignature, "utf8"),
  );
}
```

::::warning Node.js timingSafeEqual
`crypto.timingSafeEqual` throws if buffer lengths differ. Compare lengths first (as above), or use `hmac.compare_digest` in Python.
::::

### Python

```python
import hmac
import hashlib
import json
import time

def verify_webhook_signature(
    payload: dict,
    signature: str,
    timestamp: str,
    secret: str,
) -> bool:
    MAX_AGE_SECONDS = 300
    request_timestamp = int(timestamp)
    if int(time.time()) - request_timestamp > MAX_AGE_SECONDS:
        return False

    canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    signed_string = f"{timestamp}.{canonical_json}"
    expected_signature = hmac.new(
        secret.encode("utf-8"),
        signed_string.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(signature, expected_signature)
```

### Go

Matches production `toCanonicalJSON` and `VerifyRequestSignature`:

```go
func canonicalJSON(v any) ([]byte, error) {
    raw, err := json.Marshal(v)
    if err != nil {
        return nil, err
    }
    var generic any
    if err := json.Unmarshal(raw, &generic); err != nil {
        return nil, err
    }
    return json.Marshal(generic)
}

func verifyWebhookSignature(
    payload map[string]any,
    signature, timestamp, secret string,
) (bool, error) {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false, err
    }
    if time.Now().Unix()-ts > 300 {
        return false, fmt.Errorf("timestamp too old")
    }

    jsonBytes, err := canonicalJSON(payload)
    if err != nil {
        return false, err
    }
    signedString := fmt.Sprintf("%s.%s", timestamp, jsonBytes)

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

    return hmac.Equal([]byte(signature), []byte(expected)), nil
}
```

---

## Test your handler

1. Register a webhook: `POST /v1/account/{accountID}/webhook` with your public URL and `event_type: "payment_request"`.
2. Save the `secret` from the create response.
3. Call **`POST /v1/account/{accountID}/webhook/{webhookID}/test`** — Fluxis POSTs a signed sample payload to your URL using the same code path as production (`GenerateRequestSignature` on `{ "data": … }`).
4. Verify the signature in your handler and return HTTP `200`.
5. Optionally compare against manual computation using the [worked example](#worked-example) above.

View delivery attempts in **`GET /v1/account/{accountID}/webhook/logs?page=1&page_size=20`**.

---

## Common failures

| Symptom | Likely cause |
| ------- | ------------ |
| Signature always invalid | Wrong secret (not the one from create); signing raw body bytes instead of parsed JSON |
| Intermittent invalid | Optional fields added/removed vs what was signed; middleware mutating `req.body` |
| Valid signature, rejected anyway | Clock skew beyond 300s; using wrong timestamp (must match `X-Request-Timestamp` header) |
| Node handler crashes on compare | `timingSafeEqual` called with unequal-length hex strings — add length check first |
| Go mismatch on nested structs | Signing struct without round-trip through `any` — use `canonicalJSON` pattern above |
| Test works, production fails | Production payload missing fields your test vector always includes |

## Security notes

- Use **constant-time** comparison (`hmac.Equal`, `hmac.compare_digest`, `timingSafeEqual` with equal-length buffers).
- **Reject stale timestamps** (older than 300 seconds).
- **Process business logic only after** signature verification succeeds.
- Store the webhook secret in a secrets manager, not in source control.
- Use HTTPS for your endpoint; treat the secret like an API credential.

---

## Need help?

Contact [support@fluxis.us](mailto:support@fluxis.us) with a redacted copy of the received body, headers (omit secret), and your computed canonical JSON if verification fails.
