Security and signature verification
How to verify that Mosce ERP webhooks are authentic using the HMAC-SHA256 signature, and how to rotate the signing secret safely.
Each Mosce ERP webhook delivery includes an HMAC-SHA256 signature in the
X-Helix-Signatureheader. Verifying this signature on your server guarantees that the event comes from Mosce ERP and not from an external source trying to spoof an event. This article shows how to implement verification in the most common languages and how to rotate the secret without service interruption.
Reading time: ~8 min
When to use this
- You are implementing the webhook receiving server and want to validate the authenticity of deliveries.
- You need to rotate the signing secret without interrupting deliveries.
- An event reached your server and you want to verify whether it is legitimate before processing it.
Before you start
- You already have a webhook configured and saved the signing secret when creating it. If you lost it, rotate it following the steps at the end of this article.
- Your endpoint accepts POST requests with a JSON body and processes the
X-Helix-Signatureheader. - See Set up a webhook if you do not yet have an endpoint registered.
How the signature works
When Mosce ERP delivers a webhook, it includes two HTTP headers relevant to security:
| Header | Description |
|---|---|
X-Helix-Signature | HMAC-SHA256 signature of the request body. Format: sha256=<hex> |
X-Helix-Event-Id | Unique identifier of the delivery. Use it to deduplicate deliveries on retries. |
X-Helix-Event-Type | Event type (e.g. invoice.created). |
X-Helix-Timestamp | Unix timestamp (seconds) of the moment of sending. |
The signature is calculated as follows:
HMAC-SHA256( signing_secret, raw_request_body )The signing_secret is the one Mosce ERP showed when creating the webhook. The raw_body is the unmodified JSON body - any process that reformats or re-parses the JSON before verifying the signature will produce an incorrect signature.
Always verify the signature over the raw body (raw bytes), not over the parsed JSON.
Implementing the verification
Node.js
import crypto from 'crypto';
function verifyHelixSignature(rawBody, signatureHeader, secret) {
// signatureHeader has the format "sha256=<hex>"
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody) // Buffer or string with encoding 'utf8'
.digest('hex');
// Constant-time comparison to avoid timing attacks
return crypto.timingSafeEqual(
Buffer.from(expected, 'utf8'),
Buffer.from(signatureHeader, 'utf8')
);
}
// Example with Express - IMPORTANT: use express.raw() before the router
app.post('/webhooks/helix', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-helix-signature'];
const secret = process.env.HELIX_WEBHOOK_SECRET;
if (!verifyHelixSignature(req.body, signature, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
const eventId = req.headers['x-helix-event-id'];
// Process the event...
console.log(`Event ${event.type} (${eventId}) verified`);
res.status(200).json({ received: true });
});Python
import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
def verify_helix_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
"""Verifies the HMAC-SHA256 signature of a Mosce ERP webhook."""
expected = 'sha256=' + hmac.new(
key=secret.encode('utf-8'),
msg=raw_body,
digestmod=hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected, signature_header)
@app.route('/webhooks/helix', methods=['POST'])
def handle_webhook():
raw_body = request.get_data() # Raw body without parsing
signature = request.headers.get('X-Helix-Signature', '')
secret = os.environ['HELIX_WEBHOOK_SECRET']
if not verify_helix_signature(raw_body, signature, secret):
abort(401)
event = request.json
event_id = request.headers.get('X-Helix-Event-Id')
# Process the event...
print(f"Event {event['type']} ({event_id}) verified")
return {'received': True}, 200Verification with curl (for debugging)
# Compute the expected signature (Linux/macOS)
BODY='{"type":"invoice.created","data":{"id":"inv_abc123"}}'
SECRET="your_secret_here"
EXPECTED_SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print "sha256=" $2}')
echo "Expected signature: $EXPECTED_SIG"
# Compare with the received header
RECEIVED_SIG="sha256=abc123..." # the value of X-Helix-Signature
if [ "$EXPECTED_SIG" = "$RECEIVED_SIG" ]; then
echo "VALID signature"
else
echo "INVALID signature"
fiIdempotency with X-Helix-Event-Id
The X-Helix-Event-Id header contains a unique identifier for each delivery. On a retry, Mosce ERP delivers the same event with the same X-Helix-Event-Id. Use this identifier to avoid processing the same event twice:
// Example of an in-memory idempotency table (use Redis or a DB in production)
const processedEvents = new Set();
function processEvent(eventId, event) {
if (processedEvents.has(eventId)) {
console.log(`Duplicate event ${eventId} - skipping`);
return;
}
processedEvents.add(eventId);
// Process the event...
}Rotate the signing secret
If the secret was compromised or you need to rotate it for security policy:
- Go to Settings → Integrations → Webhooks and open the webhook.
- Click Rotate secret.
- Mosce ERP generates a new secret and shows it only once. Copy it immediately.
- Update the secret on your receiving server with the new value.
- The previous secret is invalidated immediately - any delivery signed with the old secret will fail verification.
Rotate the secret on your server before invalidating the previous one, or there will be a brief period in which deliveries fail verification. The safest strategy is to first update your server to accept both secrets temporarily, then rotate in Mosce ERP, then remove the old secret from your server.
Endpoint security requirements
- HTTPS required. Mosce ERP rejects URLs with
http://- communication is always encrypted in transit. - No private IPs. To protect against SSRF (Server-Side Request Forgery) attacks, Mosce ERP rejects URLs that resolve to private IP ranges (
10.x.x.x,192.168.x.x,172.16-31.x.x,127.x.x.x). This also applies to URLs that look public but resolve internally to a private IP. - Respond with HTTP 200 or 2xx to confirm receipt. Any 4xx or 5xx code is treated as a failure and triggers the retry.
Common errors
| Symptom | Likely cause | Solution |
|---|---|---|
| Verification always fails even though the secret is correct | The framework parsed the JSON before you could read the raw body | Read the raw body (raw bytes) before any middleware that parses JSON |
| Crypto.timingSafeEqual throws a length error | The header arrives empty or in an unexpected format | Verify that X-Helix-Signature is present before comparing |
| The same event arrives twice and is processed twice | There is no deduplication by X-Helix-Event-Id | Implement an idempotency table using the X-Helix-Event-Id |
Frequently asked questions
Can I verify the signature without reading the raw body?
No. The signature is calculated over the raw body (exact bytes), not over the parsed JSON. If you reformat the JSON (for example, reorder fields), the signature will not match.
What happens if my server cannot verify the signature?
Reject the request with HTTP 401 or 403 and do not process the event. Events from an unknown source must not run business logic.
Is there an expiration time on the signature?
The signature itself has no time expiration. For additional protection against replay attacks, you can verify that the X-Helix-Timestamp is within an acceptable window (e.g. ±5 minutes of the current time).
Related
Last updated: 2026-05-09