Webhooks

Learn how to handle Cortex webhook requests, verify their authenticity using HMAC-SHA256 signatures, and protect against replay attacks.

Webhooks let your server receive and respond to events from Cortex in real time, without polling. When something happens in Cortex, such as a form submission or a content update, Cortex sends an HTTP POST request to a URL you configure. Each request is signed so you can verify it genuinely came from Cortex before you act on it. Webhooks are configured in Cortex under Settings > Webhooks (Settings can be access by clicking the organisation icon in the bottom left corner, once logged in).

Self-managed vs legacy webhooks: This guide covers self-managed webhooks configured in the Cortex dashboard. If the Cortex team issued webhook credentials directly to you, see Legacy authentication. Migrate to a self-managed endpoint to get HMAC-SHA256 signature verification, replay protection, and a standard webhook envelope.

Webhook payload

Self-managed webhooks share a common payload structure:

{
  "id": "01952d9e-4d6b-7c3a-9f1b-2e8c0d5a6b7f",
  "event": "forms.submission",
  "timestamp": "2024-03-23T12:00:00Z",
  "data": {
    ...
  }
}
FieldTypeDescription
idstringUnique identifier for this event delivery. Use this to deduplicate retried requests.
eventstringThe event type, in resource.action format (e.g. forms.submission).
timestampstringRFC 3339 timestamp of when the event was generated.
dataobjectEvent-specific payload.

Retries: Cortex may retry failed deliveries. The id field will be the same across retries for the same event, so you can safely store seen IDs and discard duplicates.


Verifying authenticity

Self-managed webhooks include an X-Cortex-Signature header that you should validate before processing the payload. Use this to guard against tampered payloads and replay attacks.

The signature header

Each request includes an X-Cortex-Signature header:

X-Cortex-Signature: t=2024-03-23T12:00:00Z,sha256=3b4c5d6e...
FieldDescription
tRFC 3339 timestamp of when the request was signed.
sha256HMAC-SHA256 signature of the request.

Note: t (the signing time) and envelope.timestamp (when the event occurred) are distinct values and may differ slightly. Use t for replay attack prevention, not envelope.timestamp.

Verification steps

For each incoming webhook:

  1. Parse the X-Cortex-Signature header into its component fields.
  2. Reject the request if t is missing or not a valid RFC 3339 timestamp.
  3. Reject the request if |now − t| > 300 seconds (5 minutes). This prevents replay attacks.
  4. Reconstruct the signed string: {t}.{raw_request_body}.
  5. Compute an HMAC-SHA256 of that string using your webhook secret.
  6. Compare your computed signature to the sha256 field using a timing-safe equality function. Reject if they differ.

Important: Always use the raw request body bytes (before any JSON parsing). Re-serialising the payload will produce a different byte sequence and fail signature verification. Most languages or frameworks provide access to the raw body via middleware (e.g. express.raw(), Go's io.ReadAll).

Security: Never use standard string equality (===, ==) for signature comparison. This leaks timing information that can be exploited. Use the timing-safe functions shown in the examples below.

Code examples

const crypto = require('crypto');

const TOLERANCE_MS = 300_000;

function verifyWebhook(rawBody, secretKey, signatureHeader) {
    const fields = Object.fromEntries(
        signatureHeader.split(',').map(p => { const i = p.trim().indexOf('='); return [p.trim().slice(0, i), p.trim().slice(i + 1)]; })
    );

    if (!fields.t || !fields.sha256) {
        throw new Error('Missing required signature fields');
    }

    const ts = new Date(fields.t);
    if (isNaN(ts.getTime())) throw new Error('Invalid timestamp field');

    const age = Math.abs(Date.now() - ts.getTime());
    if (age > TOLERANCE_MS) {
        throw new Error('Webhook timestamp outside tolerance window');
    }

    const signedString = `${fields.t}.${rawBody}`;
    const hmac = crypto.createHmac('sha256', secretKey);
    hmac.update(signedString, 'utf8');
    const expected = hmac.digest('hex');

    const a = Buffer.from(expected);
    const b = Buffer.from(fields.sha256);
    if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
        return true;
    }

    throw new Error('Webhook signature mismatch');
}
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "errors"
    "strings"
    "time"
)

const toleranceDuration = 300 * time.Second

var (
    ErrMissingFields    = errors.New("missing required signature fields")
    ErrExpiredTimestamp = errors.New("webhook timestamp outside tolerance window")
    ErrInvalidSignature = errors.New("webhook signature mismatch")
)

func verifyWebhook(payload []byte, secretKey, signatureHeader string) error {
    fields := map[string]string{}
    for _, part := range strings.Split(signatureHeader, ",") {
        k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
        if ok {
            fields[k] = v
        }
    }

    timestamp, ok := fields["t"]
    if !ok {
        return ErrMissingFields
    }

    ts, err := time.Parse(time.RFC3339, timestamp)
    if err != nil {
        return errors.New("invalid timestamp field")
    }
    if time.Since(ts).Abs() > toleranceDuration {
        return ErrExpiredTimestamp
    }

    sig, ok := fields["sha256"]
    if !ok {
        return ErrMissingFields
    }

    signed := append([]byte(timestamp+"."), payload...)
    h := hmac.New(sha256.New, []byte(secretKey))
    h.Write(signed)
    expected := hex.EncodeToString(h.Sum(nil))

    if hmac.Equal([]byte(expected), []byte(sig)) {
        return nil
    }
    return ErrInvalidSignature
}

Finding your webhook secret

Your webhook secret is generated when the endpoint is created and cannot be viewed again once saved. Copy it before leaving the creation screen and store it somewhere safe. Each endpoint has its own secret; don't share secrets across endpoints.

You can rotate your secret at any time from the Cortex dashboard under Settings → Webhooks.

Rotating the secret immediately invalidates the previous secret, so update every integration that uses it to avoid delivery failures.


Troubleshooting

Signature mismatch

  • Make sure you are using the raw, unparsed request body. Parsing and re-serialising JSON will change the byte sequence.
  • Check that you are using the correct secret for the endpoint receiving the request.

Timestamp rejected

  • Ensure your server clock is accurate (NTP-synced). The tolerance window is 5 minutes.
  • Do not cache or replay webhook requests; each delivery must be processed promptly.

Header missing

  • Self-managed webhooks include X-Cortex-Signature. If it's missing, reject the request. Legacy webhooks may use X-API-Key instead; see Legacy authentication.

Legacy authentication

Webhooks configured before X-Cortex-Signature was introduced use an X-API-Key header instead. This deprecated approach does not provide replay protection or payload signature verification. If the Cortex team issued webhook credentials directly to you, migrate to a self-managed endpoint in Cortex so you can verify X-Cortex-Signature requests and use the standard webhook envelope. If you're setting up a new integration, follow the verification steps above.

Migrating from legacy webhooks

  1. In Cortex, go to Settings > Webhooks and create a new webhook endpoint for your integration.
  2. Copy the generated secret before leaving the creation screen.
  3. Update your endpoint to validate the X-Cortex-Signature header using the steps and code examples above.
  4. Update your payload handling to use the new envelope format (id, event, timestamp, data).
  5. Test with a live event and confirm signature validation passes.
  6. Once satisfied, remove any handling for the legacy X-API-Key header.