Custom webhook endpoints with HMAC verification

If none of the four first-class destinations (Slack, Teams, PagerDuty, Discord) is where you want alerts to land, use the "Generic" channel type on the integrations page. Point it at any HTTPS endpoint you control -- an n8n workflow, a Zapier catch-hook, a Make scenario, or your own self-hosted receiver -- and Digital Empire will POST a stable JSON envelope every time an alert fires.

The payload envelope

Every generic-channel dispatch is a POST with Content-Type: application/json and this body shape:

{ "event": "pixel_missing", "severity": "critical", "subject_key": "your-store.myshopify.com", "title": "Pixel break on your-store.myshopify.com", "message": "The Meta Pixel is no longer firing PageView on your product pages.", "url": "https://digital-empire-app.vercel.app/meta-monitor", "fields": [{ "label": "finding_type", "value": "pixel_missing" }], "detected_at": "2026-08-24T18:00:00Z" }

The fields are stable -- we will not remove or rename them without a versioned deprecation on the PixelProof roadmap at /meta-monitor/roadmap. Additional fields may be added over time (for example future signature, trace_id); consumers should ignore unknown fields.

Signature verification pattern

HMAC signature verification is the standard way to prove that a webhook came from the sender it claims to be from. The HMAC construction is defined in RFC 2104 at rfc-editor.org, and the JSON canonicalization approach that makes signatures reproducible across senders is defined in RFC 8785 at rfc-editor.org.

Today Digital Empire's dispatcher POSTs unsigned payloads over HTTPS from a fixed set of Vercel egress IPs. Payload integrity relies on TLS. HMAC-signed dispatches are on the roadmap, and when they ship, the pattern below is what we will implement -- so it is worth wiring your receiver for it now with a shared secret you also configure at the webhook row.

The signature header we plan to send is X-Digital-Empire-Signature: t=<unix_ts>,v1=<hex_hmac_sha256>, where the signed string is <unix_ts>.<raw_request_body>. This matches the pattern the Stripe webhook signing docs on stripe.com describe as their standard and is the reference implementation most self-hosted receivers already understand.

Reference verification code -- Node.js

const crypto = require('crypto'); function verify(req, secret) { const header = req.headers['x-digital-empire-signature']; if (!header) return false; const parts = Object.fromEntries(header.split(',').map(p => p.split('='))); const signed = parts.t + '.' + req.rawBody; const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex'); const provided = Buffer.from(parts.v1, 'hex'); const expBuf = Buffer.from(expected, 'hex'); if (provided.length !== expBuf.length) return false; if (!crypto.timingSafeEqual(provided, expBuf)) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false; return true; }

Two details matter and are easy to get wrong:

Use crypto.timingSafeEqual rather than ===. Byte-by-byte string equality on a hex comparison leaks information about how many prefix bytes matched, which is the mechanism a timing attack exploits.

Bound the timestamp window (300 seconds above is a reasonable default). Without this, an attacker who captured a real signed request in the past can replay it forever.

Reference verification code -- Python

import hmac, hashlib, time def verify(headers, raw_body, secret): header = headers.get('x-digital-empire-signature') if not header: return False parts = dict(kv.split('=') for kv in header.split(',')) signed = f"{parts['t']}.{raw_body}" expected = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, parts['v1']): return False if abs(time.time() - int(parts['t'])) > 300: return False return True

hmac.compare_digest is Python's constant-time comparison, equivalent to Node's timingSafeEqual -- always use it here.

Reference verification code -- Go

import ("crypto/hmac"; "crypto/sha256"; "encoding/hex"; "strconv"; "strings"; "time") func Verify(header, rawBody, secret string) bool { parts := map[string]string{} for _, kv := range strings.Split(header, ",") { if p := strings.SplitN(kv, "=", 2); len(p) == 2 { parts[p[0]] = p[1] } } signed := parts["t"] + "." + rawBody mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signed)) expected := hex.EncodeToString(mac.Sum(nil)) provided, err := hex.DecodeString(parts["v1"]) if err != nil { return false } expBytes, _ := hex.DecodeString(expected) if !hmac.Equal(provided, expBytes) { return false } ts, err := strconv.ParseInt(parts["t"], 10, 64) if err != nil { return false } if abs(time.Now().Unix() - ts) > 300 { return false } return true }

Interim security -- until HMAC ships

Because HMAC signing has not shipped yet, use two interim measures on your receiver:

Secret in the URL path. When you paste the webhook URL on the integrations page, use a long random suffix -- for example https://your-endpoint.example.com/webhook/dgtl-emp-alerts-8f4a2c1e-e7d6-4b0e-a95a-8b7c2d1e2f3a. Reject any request that hits your endpoint without the correct suffix. This is not a substitute for HMAC but does raise the bar meaningfully.

IP allowlist. Vercel publishes the egress IP ranges its serverless functions use on the Vercel docs pages; pin your receiver to accept requests only from that list. Combined with the URL secret, this gives you defense in depth until signing is available.

Response contract -- 200 within 5 seconds

Our dispatcher waits up to 5 seconds for your receiver to respond and treats anything outside that as a failure. If your receiver returns 200-299 within 5 seconds we mark the dispatch successful. If it returns 5xx or times out, we retry once, then mark the webhook failed. If it returns 4xx we do not retry -- 4xx is treated as a permanent rejection (bad URL, wrong shape). The retry behavior aligns with the webhook best-practices summary maintained on developers.google.com under Cloud Tasks, which is a good general reference for webhook receiver design.

If your work takes longer than 5 seconds, acknowledge 200 immediately and process asynchronously -- do not hold the connection open.

Was this helpful?

Yes / No

Related articles

Send Digital Empire alerts to Slack

Send Digital Empire alerts to Microsoft Teams

Send Digital Empire alerts to PagerDuty

Send Digital Empire alerts to Discord

PixelProof customer webhooks admin

FAQ

Is HMAC signing live today? No -- as of Aug 2026 we send unsigned payloads over HTTPS. Wire your receiver for the signature header now so it verifies immediately when signing ships.

What content type do you send? Always application/json with a UTF-8 encoded body. We do not send form-encoded or multipart payloads on this channel type.

Can I inspect what Digital Empire tried to send when a dispatch failed? Yes -- the integrations page shows the last dispatch status and any error text returned by your receiver. If you need the full request body, add a Cloudflare Worker or similar in front of your receiver that logs the raw request for debugging.

Do you follow HTTP redirects? Our dispatcher rejects redirects to private-IP ranges (10.x, 172.16.x, 192.168.x, 127.x) as an SSRF safety measure. Public-IP redirects are followed once. Prefer the direct URL to avoid the extra round trip.

Still stuck? Email hello@citationsafe.com or hello@argushq.ai.

FAQ

Is HMAC signing live today?

No -- as of Aug 2026 we send unsigned payloads over HTTPS. Wire your receiver for the signature header now so it verifies immediately when signing ships.

What content type do you send?

Always `application/json` with a UTF-8 encoded body. We do not send form-encoded or multipart payloads on this channel type.

Can I inspect what Digital Empire tried to send when a dispatch failed?

Yes -- the integrations page shows the last dispatch status and any error text returned by your receiver. If you need the full request body, add a Cloudflare Worker or similar in front of your receiver that logs the raw request for debugging.

Do you follow HTTP redirects?

Our dispatcher rejects redirects to private-IP ranges (10.x, 172.16.x, 192.168.x, 127.x) as an SSRF safety measure. Public-IP redirects are followed once. Prefer the direct URL to avoid the extra round trip.