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.

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
idUUID v4. Stable across retries of the same delivery — dedupe on this.
typeThe event type. See event catalogue.
created_atWhen the event was generated.
guild_idThe guild the event happened in.
livemodetrue in production, false in staging environments. Useful for filtering.
dataThe 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 bodynot 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.