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
| Field | Type | Notes |
|---|---|---|
id | string | Stable, globally unique signal id. |
signalTypeKey | string | E.g. web.exa.news. The same key as on the subscription. |
sourceKind | string | High-level source classification: web_monitor, hg_data, customer_data, or internal. |
entityType | string | What the signal attaches to. Currently only account is supported. |
entityId | string | Identifier of the entity (domain or HG id for accounts). |
dedupKey | string | Source-supplied or computed dedup key. Stable across retries — safe to use as an idempotency key in your receiver. |
payload | object | Signal-type-specific. The exact shape varies by signalTypeKey; see the signal type catalog for the schema. |
observedAt | RFC 3339 timestamp | When the underlying event occurred. |
ingestedAt | RFC 3339 timestamp | When Phoenix ingested it. Always >= observedAt. |
subscription fields
Phoenix only sends the subscription identity (not the full row):
| Field | Type | Notes |
|---|---|---|
id | string | Subscription id. |
signalTypeKey | string | Same as signal.signalTypeKey. |
scope | object | The 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):
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Phoenix-Signals/1.0 (+phoenix.hginsights.com) (host varies by environment) |
X-Phoenix-Delivery-Id | Opaque 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-Signature | sha256=<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 assent, 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 terminalPOST), 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:
| Attempt | Wait before next attempt |
|---|---|
| 1 | 1s |
| 2 | 4s |
| 3 | 16s |
| 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.
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.
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.raw — express.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.
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
POSTand 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.