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 · 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 |
Reject requests that fail verification. Do not process payment fulfillment until the signature and timestamp checks pass.
Prerequisites
- Webhook secret — Returned once in the response to
POST /v1/account/{accountID}/webhook. Store it securely; it cannot be retrieved again. - HTTPS endpoint — Your server must accept
POSTwithContent-Type: application/json. - 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:
Code
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:
Code
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:
- Marshal the value to JSON (struct tags and
omitemptyapply — nil/zero fields are omitted). - Unmarshal into
map[string]any/ arrays so nested objects become maps. - Marshal again — Go
json.Marshalon 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
- Read
X-Request-SignatureandX-Request-Timestampfrom headers. - Parse the request body into a generic object (
map/dict). - Reject if
now - timestamp > 300. - Serialize the parsed object to canonical JSON (sorted keys, compact).
- Build
signed_string = timestamp + "." + canonical_json. - 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):
Code
Canonical JSON (single line):
Code
Signed string:
Code
Expected signature (hex):
Code
If your implementation produces this signature for the inputs above, it matches Fluxis production signing.
Code examples
Node.js / TypeScript
Code
::::warning Node.js timingSafeEqual
crypto.timingSafeEqual throws if buffer lengths differ. Compare lengths first (as above), or use hmac.compare_digest in Python.
::::
Python
Code
Go
Matches production toCanonicalJSON and VerifyRequestSignature:
Code
Test your handler
- Register a webhook:
POST /v1/account/{accountID}/webhookwith your public URL andevent_type: "payment_request". - Save the
secretfrom the create response. - Call
POST /v1/account/{accountID}/webhook/{webhookID}/test— Fluxis POSTs a signed sample payload to your URL using the same code path as production (GenerateRequestSignatureon{ "data": … }). - Verify the signature in your handler and return HTTP
200. - Optionally compare against manual computation using the 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,timingSafeEqualwith 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 [email protected] with a redacted copy of the received body, headers (omit secret), and your computed canonical JSON if verification fails.