Skip to main content

Signal webhooks

When a subscription matches a signal, Phoenix delivers it by POST-ing a JSON payload to the listener URL you configured. This guide documents the exact request shape, headers, retry behavior, and HMAC signature scheme so you can build (or test) a receiver with confidence.

What Phoenix POSTs to your listener

Phoenix sends a single POST per matched signal, with Content-Type: application/json and a body like:

{
"signal": {
"id": "tz4a98ia2nqj7n2k3m4n5p6q",
"signalTypeKey": "web.exa.news",
"sourceKind": "web_monitor",
"entityType": "account",
"entityId": "cisco.com",
"dedupKey": "exa:webset_abc:item_xyz",
"payload": { "...": "signal-type-specific; see the signal type" },
"observedAt": "2026-05-13T10:00:00.000Z",
"ingestedAt": "2026-05-13T10:00:01.000Z"
},
"subscription": {
"id": "fv8c1a4ke3rl9p2k3m4n5p6q",
"signalTypeKey": "web.exa.news",
"scope": { "kind": "all" }
}
}

Identifiers (signal.id, subscription.id, X-Phoenix-Delivery-Id) are opaque strings — don't validate them against a format like UUID or ULID, just compare them byte-for-byte.

signal fields

FieldTypeNotes
idstringStable, globally unique signal id.
signalTypeKeystringE.g. web.exa.news. The same key as on the subscription.
sourceKindstringHigh-level source classification: web_monitor, hg_data, customer_data, or internal.
entityTypestringWhat the signal attaches to. Currently only account is supported.
entityIdstringIdentifier of the entity (domain or HG id for accounts).
dedupKeystringSource-supplied or computed dedup key. Stable across retries — safe to use as an idempotency key in your receiver.
payloadobjectSignal-type-specific. The exact shape varies by signalTypeKey; see the signal type catalog for the schema.
observedAtRFC 3339 timestampWhen the underlying event occurred.
ingestedAtRFC 3339 timestampWhen Phoenix ingested it. Always >= observedAt.

subscription fields

Phoenix only sends the subscription identity (not the full row):

FieldTypeNotes
idstringSubscription id.
signalTypeKeystringSame as signal.signalTypeKey.
scopeobjectThe scope object you configured. Currently { "kind": "all" } is the only supported variant.

Headers Phoenix sends

Key headers (standard HTTP framing — Content-Length etc. is set automatically):

HeaderValue
Content-Typeapplication/json
User-AgentPhoenix-Signals/1.0 (+phoenix.hginsights.com) (host varies by environment)
X-Phoenix-Delivery-IdOpaque string, stable across retries — use this as your receiver-side dedup / idempotency key. Do not assume any particular format (don't validate it as a UUID); just compare it byte-for-byte.
X-Phoenix-Signaturesha256=<hex> — present only when an HMAC secret is configured on the listener. See HMAC signing.

Expected response

Return any 2xx status within 10 seconds. Anything else counts as a failed attempt for that try.

  • 200 OK (or any 2xx) → delivery is recorded as sent, no further attempts.
  • 4xx → permanent failure, no retry. Phoenix assumes a 4xx means "you don't want this delivery."
  • 5xx, 3xx (Phoenix does not follow redirects on listener URLs — configure your endpoint to be a terminal POST), network errors, or timeout → retried per the schedule below.

Retry behavior

Phoenix attempts each delivery up to 4 times total (initial + 3 retries) with the following backoff between attempts:

AttemptWait before next attempt
11s
24s
316s
4(no retry — final)

Each individual attempt has a 10-second timeout.

Retryable outcomes: 5xx, 3xx (not followed), ECONNREFUSED, ECONNRESET, EAI_AGAIN, per-attempt timeout, response stream error.

Permanent failures (no retry): 4xx, ENOTFOUND (DNS), SSRF block (resolved to a private/reserved IP), malformed URL, non-HTTPS scheme, IP-literal hostname.

note

Make your receiver idempotent — use X-Phoenix-Delivery-Id as the dedup key. A successful 2xx that times out on the network can be retried, and your receiver will see the same delivery twice with the same id.

HMAC signing

If you set an HMAC secret on the listener, Phoenix sends X-Phoenix-Signature: sha256=<hex> on every request. The signature is HMAC-SHA256 over the raw request body, hex-encoded.

Verify against the raw body, not a re-stringified one

JSON-parsing the body and re-serializing it will change the bytes (key order, whitespace, number formatting) and break verification. Compute the HMAC on the exact bytes you received.

Node.js

import crypto from "node:crypto";

export function verifyPhoenixSignature(rawBody, headerValue, secret) {
// Treat a missing or malformed header as "no signature".
if (typeof headerValue !== "string" || !headerValue.startsWith("sha256=")) {
return false;
}
const provided = headerValue.slice("sha256=".length);
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody) // rawBody MUST be a Buffer or the original string, not JSON.parse + JSON.stringify
.digest("hex");

// timingSafeEqual throws on unequal lengths — bail before calling it.
if (provided.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
}

Example wiring in an Express handler (note express.rawexpress.json parses and loses the original bytes):

app.post(
"/phoenix-signals",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verifyPhoenixSignature(req.body, req.get("X-Phoenix-Signature"), process.env.PHOENIX_HMAC_SECRET)) {
return res.status(401).send("bad signature");
}
const { signal, subscription } = JSON.parse(req.body.toString("utf8"));
// ... handle delivery, dedup by req.get("X-Phoenix-Delivery-Id")
res.status(200).send("ok");
},
);

Python

import hmac
import hashlib

def verify_phoenix_signature(raw_body: bytes, header_value: str | None, secret: str) -> bool:
if not header_value or not header_value.startswith("sha256="):
return False
provided = header_value[len("sha256="):]
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
# compare_digest is constant-time and tolerates unequal-length inputs.
return hmac.compare_digest(provided, expected)

Flask example:

@app.post("/phoenix-signals")
def receive():
raw = request.get_data() # raw bytes, NOT request.json
if not verify_phoenix_signature(raw, request.headers.get("X-Phoenix-Signature"), os.environ["PHOENIX_HMAC_SECRET"]):
return "bad signature", 401
body = json.loads(raw)
# ... handle, dedup by request.headers.get("X-Phoenix-Delivery-Id")
return "ok", 200

Bash / openssl (debug only)

For confirming Phoenix is sending the signature you expect — not safe for production verification, because shell == is not constant-time:

RAW_BODY="$(cat /tmp/phoenix-delivery.json)"
EXPECTED="sha256=$(printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$PHOENIX_HMAC_SECRET" -r | awk '{print $1}')"
echo "expected: $EXPECTED"
echo "received: $X_PHOENIX_SIGNATURE"

Use this to spot-check during integration, then verify in your application code using the Node or Python pattern above.

Constraints

  • HTTPS only. Non-HTTPS URLs are rejected at delivery time and treated as a permanent failure.
  • Domain hostnames only. IP-literal URLs (https://203.0.113.1/...) are rejected — Phoenix's SSRF guard requires a hostname so it can re-resolve on each attempt and reject private/reserved IPs.
  • Public DNS only. Hostnames that resolve to private (RFC 1918), reserved, or loopback addresses are rejected on every attempt. This is re-checked per attempt, not just once at create time.

Quick test paths

Pick the lightest-weight option that matches what you need.

Ad-hoc capture

  • webhook.site — generates a unique URL, captures every request, no auth. Best 30-second test.
  • Pipedream — pipes deliveries into a JS/Python workflow.
  • RequestBin — request capture with replay.
Don't point production subscriptions at public capture tools

webhook.site, RequestBin, and most public Pipedream inbound URLs are visible to anyone with the URL. Signal payloads can include account, contact, and signal-type-specific data — anyone who captures or guesses the URL can read what Phoenix delivers. Use these tools only for demos and signal-shape verification with non-sensitive test subscriptions. Production subscriptions should target a customer-controlled endpoint.

No-code routing

  • Zapier webhook trigger → CRM / Slack / Sheets.
  • Make.com webhook module.
  • n8n Webhook node (self-hosted or cloud).

Production receivers

  • AWS Lambda with a Function URL.
  • Google Cloud Run or Cloud Functions with a public HTTPS trigger.
  • Cloudflare Workers (sub-50ms cold start).
  • A handler in your own app — anything that can receive a POST and return 2xx within 10 seconds works.

Example listener URLs

https://webhook.site/abc123-def456-7890           # testing
https://hooks.zapier.com/hooks/catch/123/abcdef/ # Zapier
https://acme.app.n8n.cloud/webhook/phoenix # n8n cloud
https://signals.acme.com/phoenix/inbound # customer-controlled, custom domain
https://abcdef.lambda-url.us-east-1.on.aws/ # AWS Lambda Function URL

All must be HTTPS; all must resolve to a public IP.