Mosce ERP · Help Center
Webhooks

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-Signature header. 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-Signature header.
  • 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:

HeaderDescription
X-Helix-SignatureHMAC-SHA256 signature of the request body. Format: sha256=<hex>
X-Helix-Event-IdUnique identifier of the delivery. Use it to deduplicate deliveries on retries.
X-Helix-Event-TypeEvent type (e.g. invoice.created).
X-Helix-TimestampUnix 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}, 200

Verification 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"
fi

Idempotency 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:

  1. Go to Settings → Integrations → Webhooks and open the webhook.
  2. Click Rotate secret.
  3. Mosce ERP generates a new secret and shows it only once. Copy it immediately.
  4. Update the secret on your receiving server with the new value.
  5. 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

SymptomLikely causeSolution
Verification always fails even though the secret is correctThe framework parsed the JSON before you could read the raw bodyRead the raw body (raw bytes) before any middleware that parses JSON
Crypto.timingSafeEqual throws a length errorThe header arrives empty or in an unexpected formatVerify that X-Helix-Signature is present before comparing
The same event arrives twice and is processed twiceThere is no deduplication by X-Helix-Event-IdImplement 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).


Last updated: 2026-05-09