Outbound webhooks
Subscribe a URL you control to specific Phantom events. Whenever one fires, we'll POST a signed JSON payload to your URL.
Lifecycle
- Add an endpoint on Developer → Webhooks. Give it a name, a destination URL (HTTPS only), and tick the events you care about.
- Copy the signing secret that appears immediately after — it's only shown once. Store it in your receiver's env vars.
- We deliver each subscribed event as a separate
POSTto your URL. Successful responses (HTTP 2xx) mark the delivery asdelivered. Failures (5xx, timeouts, or 408/425/429) are retried with exponential backoff. - After 30 consecutive failures, the endpoint is
auto_disabled— no further events fire until you re-enable it from the dashboard.
Payload shape
Every payload has the same outer envelope:
{
"id": "f7d3a3a2-b8a4-4b8c-bc4f-7d6a9e1c2f4a",
"type": "case.created",
"created_at": "2026-05-24T11:32:18+00:00",
"guild_id": "123456789012345678",
"livemode": true,
"data": {
/* event-specific shape — see the Event catalogue */
}
}| Field | Always present | Notes |
|---|---|---|
id | ✅ | UUID v4. Stable across retries of the same delivery — dedupe on this. |
type | ✅ | The event type. See event catalogue. |
created_at | ✅ | When the event was generated. |
guild_id | ✅ | The guild the event happened in. |
livemode | ✅ | true in production, false in staging environments. Useful for filtering. |
data | ✅ | The event-specific payload. |
Request headers
| Header | Notes |
|---|---|
Phantom-Signature | t=<unix>,v1=<hex> — verify this. |
Phantom-Event-Id | Same as id in the body. Convenience for receivers that route on header. |
Phantom-Event-Type | Same as type in the body. |
Idempotency-Key | Always set to id. Use it on your end to dedupe replays. |
User-Agent | Phantom-Webhooks/1.0 (+https://phantombot.gg) |
Content-Type | application/json |
Verifying the signature
Always verify the signature. Without it, any attacker who learns your webhook URL can forge events.
The signed string is <timestamp>.<raw_body> — the timestamp is the value of t= in the header. Compute HMAC-SHA256 over that string with your endpoint's signing secret, then compare against the v1= hex digest using a constant-time comparison.
Node.js
const crypto = require('crypto');
function verify(rawBody, headerValue, secret) {
const parts = Object.fromEntries(
headerValue.split(',').map(kv => kv.split('='))
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
// 5-minute replay window
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}Important: use the raw request body — not a re-stringified version of the parsed JSON. JSON re-serialisation can change whitespace and key order, breaking the signature.
Python (Flask)
import hmac
import hashlib
import time
def verify(raw_body: bytes, header_value: str, secret: str) -> bool:
parts = dict(p.split('=', 1) for p in header_value.split(','))
t, v1 = parts.get('t'), parts.get('v1')
if not t or not v1:
return False
if abs(int(time.time()) - int(t)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{t}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(v1, expected)Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
"strconv"
"time"
)
func Verify(rawBody []byte, header, secret string) bool {
var t, v1 string
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) != 2 { continue }
switch kv[0] { case "t": t = kv[1]; case "v1": v1 = kv[1] }
}
if t == "" || v1 == "" { return false }
ts, _ := strconv.ParseInt(t, 10, 64)
if abs(time.Now().Unix() - ts) > 300 { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "." + string(rawBody)))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(v1), []byte(expected))
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }PHP
function verify(string $rawBody, string $header, string $secret): bool {
parse_str(strtr($header, ',', '&'), $parts);
$t = $parts['t'] ?? null;
$v1 = $parts['v1'] ?? null;
if (! $t || ! $v1) return false;
if (abs(time() - (int) $t) > 300) return false;
$expected = hash_hmac('sha256', "{$t}.{$rawBody}", $secret);
return hash_equals($expected, $v1);
}Retry policy
Failures back off through this schedule:
| Attempt | Delay before next try |
|---|---|
| 1 (initial) | — |
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 3 minutes |
| 5 | 30 minutes |
| 6 | 4 hours |
| 7 | 12 hours |
A delivery that fails all 7 attempts is marked failed and will not be retried automatically. You can manually re-queue it from the dashboard's recent-deliveries panel.
What counts as a failure?
- Connection error, DNS error, TLS error, timeout.
- HTTP
5xxstatus. - HTTP
408,425,429— treated as "try again later". - HTTP
4xx(anything else) — treated as "client bug" and not retried. The delivery is markedfailedimmediately.
The auto-disable threshold (30 consecutive failures) only resets when a delivery succeeds. Endpoints that fail intermittently but recover will stay active.
Idempotency on your side
Even though we send each event exactly once on success, retries on transient failures mean the same id can arrive more than once. Dedupe by id on your side — typically a UNIQUE constraint on a phantom_event_id column.
Testing
The dashboard's Send test event button fires a test.ping event at your endpoint. It goes through the normal delivery path (same headers, same signature, real retry semantics) so a successful test confirms your receiver is wired up correctly.
Why we don't follow redirects
The HTTP client is configured allow_redirects=false. A response of 301/302 is treated as a failure with that status code. We do this because a redirect-aware receiver could be exploited to bounce a webhook delivery to a different host than the one you allow-listed — including internal infrastructure.
What we won't deliver to
The SSRF guard (see Security model) blocks deliveries to:
127.0.0.0/8,::1/128(loopback)10.0.0.0/8,172.16.0.0/12,192.168.0.0/16(RFC 1918)169.254.0.0/16(link-local — AWS / GCP metadata)100.64.0.0/10(carrier-grade NAT)fc00::/7,fe80::/10(IPv6 ULA / link-local)- Hostnames
localhost,metadata.google.internal,metadata.goog - Anything that ends in a trailing dot or is a single dotless label
DNS rebinding is mitigated by re-checking the hostname's IPs at delivery time, not just at endpoint-create time. An endpoint whose DNS flips to a private IP between create and delivery is rejected at delivery.
