Skip to main content
A webhook receiver must verify that every incoming request was actually sent by Virtuous and was not tampered with in transit. Without verification, anyone who discovers your payloadUrl could send fake events to it, potentially driving incorrect behavior in your integration — fraudulent gift records, unauthorized contact updates, manipulated reporting data. CRM+ webhook deliveries carry a signature derived from the request body and your subscription secret. Verifying the signature on every incoming request is the security foundation for every production webhook receiver.
The exact signature mechanism used by Virtuous webhooks (header name, signing algorithm, signed string format) is not documented in the CRM+ OpenAPI spec. The patterns described on this page reflect the industry-standard approach (HMAC-SHA256 over the request body, signature delivered in a request header) and the conservative implementation a partner integration should adopt. Before going to production, confirm the exact signature scheme with Virtuous engineering and adjust your verifier accordingly.

What signature verification proves

A valid signature on a webhook delivery proves two things:
  • Authenticity. Only a party with the subscription’s secret could have generated the signature. Since the secret is known only to your integration and Virtuous, a valid signature confirms the request originated from Virtuous.
  • Integrity. The signature is computed over the request body. Any modification of the body in transit invalidates the signature.
What signature verification does not prove:
  • Freshness. A captured-and-replayed request has a valid signature. Use a delivery timestamp and a short window (e.g., reject events older than five minutes) to defend against replay attacks. Combined with idempotency on event IDs, this is sufficient for most production receivers.
  • Authorization. The signature only proves the request is genuine. Whether the event represents something your integration should act on is a separate concern — handled by your business logic, not your verifier.

The verification pattern

The industry-standard pattern (which CRM+ is presumed to follow until confirmed otherwise):
  1. Virtuous computes signature = HMAC-SHA256(secret, request_body) at delivery time.
  2. The signature is delivered in a request header (typically named something like X-Virtuous-Signature).
  3. Your receiver reads the raw request body (before JSON parsing), computes the same HMAC using your stored secret, and compares the result to the signature header in constant time.
  4. If the signatures match, the request is accepted. If not, the request is rejected with 401 Unauthorized.
The two non-obvious requirements:
  • Use the raw body. Signature verification operates on the exact bytes Virtuous sent. JSON-parsing-then-re-serializing changes whitespace, key ordering, and number formatting — all of which invalidate the signature.
  • Compare in constant time. A naive string comparison reveals timing information that lets an attacker iterate toward a valid signature. Use a constant-time comparison primitive from your language’s crypto library.

Verification code

The example below shows the canonical verifier shape. Adjust the SIGNATURE_HEADER constant and the body-to-sign format once Virtuous confirms its scheme.
import crypto from 'crypto';

// The exact header name Virtuous uses is not yet confirmed.
// Update once published.
const SIGNATURE_HEADER = 'x-virtuous-signature';

export function verifyVirtuousSignature(rawBody, headers, secret) {
  const receivedSignature = headers[SIGNATURE_HEADER];
  if (!receivedSignature) {
    return false;
  }

  // Compute the expected signature over the raw body.
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // Constant-time comparison to defend against timing attacks.
  const expectedBuffer = Buffer.from(expectedSignature, 'hex');
  const receivedBuffer = Buffer.from(receivedSignature, 'hex');

  if (expectedBuffer.length !== receivedBuffer.length) {
    return false;
  }

  return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
The two implementation details worth calling out:
  • express.raw({ type: 'application/json' }) captures the request body as a Buffer instead of parsing it as JSON. Standard express.json() middleware parses and discards the raw bytes, breaking signature verification. Use raw for the webhook route only.
  • crypto.timingSafeEqual compares two equal-length Buffers in constant time. Standard === comparison short-circuits on the first differing byte, leaking timing information.

Handling signature failures

When signature verification fails, the safe response is to reject the request with 401 Unauthorized and log enough context to investigate.
JavaScript
if (!valid) {
  console.warn('Webhook signature verification failed', {
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    timestamp: new Date().toISOString(),
    // Do NOT log the raw body or the secret — both could leak sensitive data.
  });
  return res.status(401).send('Invalid signature');
}
A small number of legitimate signature failures are expected during operation:
  • Secret rotation in flight. During a secret rotation, deliveries in transit may still be signed with the old secret. Accept both old and new secrets during the rotation window. See Webhooks Overview.
  • Body modification by an intermediate proxy. Some load balancers and WAFs modify request bodies (re-encoding, header normalization). If you see consistent signature failures from a specific source, suspect a proxy. Configure the proxy to pass the body through unmodified.
Sustained signature failures from non-rotation sources are a strong signal of an attack or misconfiguration. Set up alerting on 401 responses from your webhook endpoint.

Replay protection

Even with a valid signature, a captured webhook delivery can be replayed by anyone who recorded it. Two complementary defenses:

1. Timestamp window

If the webhook payload includes a delivery timestamp, reject events older than a short window (typically five minutes). The timestamp itself must be signed — otherwise an attacker can forward an old event with a new timestamp.
JavaScript
const MAX_AGE_SECONDS = 5 * 60; // 5 minutes

function isWithinFreshnessWindow(event) {
  if (!event.timestamp) return false;
  const eventTime = new Date(event.timestamp).getTime();
  const ageSeconds = (Date.now() - eventTime) / 1000;
  return ageSeconds < MAX_AGE_SECONDS;
}
Whether Virtuous includes a signed timestamp in its delivery is not confirmed. If the payload’s timestamp field is inside the signed body, the replay-window check is meaningful. If the timestamp is in an unsigned header, an attacker can rewrite it.

2. Event ID idempotency

Even without a timestamp, you can defend against replay by tracking the event IDs you have already processed. Reject a second delivery of an event ID you have already seen.
JavaScript
async function processWithIdempotency(event) {
  const alreadyProcessed = await idempotencyStore.has(event.eventId);
  if (alreadyProcessed) {
    return; // Already handled — drop the duplicate.
  }
  await idempotencyStore.set(event.eventId, { processedAt: new Date() });
  await processVirtuousEvent(event);
}
The idempotency store can be Redis, a database table, or any persistent key-value store. Keep records for at least the maximum replay window you want to defend against — 24 hours is a reasonable default. See Idempotency and Safe Reprocessing for the full pattern, including the retry-induced duplicates that arrive even from legitimate Virtuous deliveries.

Testing your verifier

Two patterns for testing signature verification logic without depending on live Virtuous deliveries:

Self-signed test fixtures

Generate test payloads in your own test suite by computing the signature with a known secret. This validates your verifier’s logic against payloads you control:
JavaScript
import crypto from 'crypto';

function generateTestSignature(body, secret) {
  return crypto.createHmac('sha256', secret).update(body).digest('hex');
}

// In a test
const testBody = Buffer.from(JSON.stringify({ eventType: 'contact.created' }));
const testSecret = 'test-secret';
const testHeaders = {
  'x-virtuous-signature': generateTestSignature(testBody, testSecret),
};

expect(verifyVirtuousSignature(testBody, testHeaders, testSecret)).toBe(true);
expect(verifyVirtuousSignature(testBody, testHeaders, 'wrong-secret')).toBe(false);

Live replay against the Seeded Sandbox

Subscribe a real webhook against your Seeded Sandbox, capture a few real deliveries, save them as fixtures, and run your verifier against them. This validates that your code agrees with what Virtuous actually sends — not just with your test-generated approximation. See Local Testing for the full local-development workflow.

Security checklist

Before deploying a webhook receiver to production, confirm:
  • Signature verification runs on every request before any processing.
  • The raw request body is used for signature computation (not the parsed JSON).
  • Signature comparison uses a constant-time primitive (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python).
  • The shared secret is loaded from a secrets manager — never hardcoded.
  • Failed signature attempts are logged with enough context to investigate, but the raw body, the secret, and the signature value are not logged.
  • The endpoint is HTTPS-only with a valid public certificate.
  • Replay protection is in place: timestamp window, event ID idempotency, or both.
  • Secret rotation is supported: your verifier can accept multiple valid secrets simultaneously during a rotation window.

Where to go next

Idempotency and Safe Reprocessing

How to make your handler safe against the duplicate deliveries that signature verification cannot prevent.

Retry Behavior

What Virtuous does when verification or processing fails — and how that interacts with your replay defenses.

Local Testing

Set up a local environment that receives signed webhook deliveries for verification testing.

Webhooks Overview

The subscription model, including how the shared secret is stored and rotated.
Last modified on May 27, 2026