Skip to content

Outbound webhooks ​

Subscribe a URL you control to specific Phantom events. Whenever one fires, we'll POST a signed JSON payload to your URL.

Phantom outbound webhook endpoints page with signing secrets and delivery historyDeveloper → Webhooks.

Lifecycle ​

  1. Add an endpoint on Developer → Webhooks. Give it a name, a destination URL (HTTPS only), and tick the events you care about.
  2. Copy the signing secret that appears immediately after — it's only shown once. Store it in your receiver's env vars.
  3. We deliver each subscribed event as a separate POST to your URL. Successful responses (HTTP 2xx) mark the delivery as delivered. Failures (5xx, timeouts, or 408/425/429) are retried with exponential backoff.
  4. 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:

json
{
  "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 */
  }
}
FieldAlways presentNotes
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 ​

HeaderNotes
Phantom-Signaturet=<unix>,v1=<hex> — verify this.
Phantom-Event-IdSame as id in the body. Convenience for receivers that route on header.
Phantom-Event-TypeSame as type in the body.
Idempotency-KeyAlways set to id. Use it on your end to dedupe replays.
User-AgentPhantom-Webhooks/1.0 (+https://phantombot.gg)
Content-Typeapplication/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 ​

javascript
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) ​

python
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 ​

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 ​

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:

AttemptDelay before next try
1 (initial)—
25 seconds
330 seconds
43 minutes
530 minutes
64 hours
712 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 5xx status.
  • HTTP 408, 425, 429 — treated as "try again later".
  • HTTP 4xx (anything else) — treated as "client bug" and not retried. The delivery is marked failed immediately.

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.

Phantom is a product of Hydra Labs. The bot is run as a managed service; you do not need to host it yourself.