HomeDocsPricingSign in
← All docs

Outgoing webhooks

Push every new signup to your tools in real time — Zapier, Make, n8n, Slack, your Laravel / Rails / Node backend, or any HTTPS endpoint.

Quick start
  1. Open /dashboard, pick a Pro waitlist, click the Webhooks tab.
  2. Enter an HTTPS URL on your server, click Create webhook.
  3. Copy the whsec_... secret once — it will not be shown again.
  4. Click Send test to verify your endpoint receives the canned event.
Payload shape

Every delivery is a POST with Content-Type: application/json. The body matches this schema (v1 emits signup.active only):

{
  "event": "signup.active",
  "occurred_at": "2026-04-23T12:34:56.000Z",
  "waitlist": {
    "id": "wl_abc123",
    "slug": "acme-ai",
    "title": "Acme AI"
  },
  "signup": {
    "id": "su_xyz789",
    "email": "alice@example.com",
    "name": "Alice",
    "position": 42,
    "referred_by": "RT65REGB",
    "referral_count": 0,
    "metadata": {
      "company": "Acme",
      "twitter": "@acme"
    }
  }
}

Delivery uses User-Agent: WaitForge-Webhooks/1.0. Allow this UA through any WAF.

Signature verification

Every request includes an X-WaitForge-Signature header in the Stripe-style format:

X-WaitForge-Signature: t=1714049696, v1=8f4d3c...hex

The signed payload is `${timestamp}.${raw_request_body}` and the signature is HMAC-SHA256(secret, signed_payload), hex-encoded. Always compare signatures with a constant-time function, and reject timestamps more than 5 minutes old to stop replay attacks.

Node.js (Express)

import crypto from 'node:crypto';

app.post('/webhooks/waitforge', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.header('X-WaitForge-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map(s => s.trim().split('=')));
  const t = Number(parts.t), v1 = parts.v1;
  if (!t || !v1) return res.status(400).end();

  const signed = `${t}.${req.body.toString()}`;
  const expected = crypto.createHmac('sha256', process.env.WAITFORGE_SECRET)
    .update(signed).digest('hex');

  const a = Buffer.from(expected), b = Buffer.from(v1);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).end();
  if (Math.abs(Date.now() / 1000 - t) > 300) return res.status(401).end(); // replay guard

  const payload = JSON.parse(req.body.toString());
  // ... handle payload.event === 'signup.active' ...
  res.status(200).end();
});

PHP (Laravel)

public function __invoke(Request $request)
{
    $header = $request->header('X-WaitForge-Signature', '');
    preg_match('/t=(\d+),\s*v1=([0-9a-f]+)/', $header, $m);
    abort_if(count($m) !== 3, 400);
    [$all, $t, $v1] = $m;

    $body = $request->getContent();
    $expected = hash_hmac('sha256', "{$t}.{$body}", env('WAITFORGE_SECRET'));
    abort_unless(hash_equals($expected, $v1), 401);
    abort_if(abs(time() - (int) $t) > 300, 401);

    $payload = json_decode($body, true);
    // ... handle $payload['event'] === 'signup.active' ...
    return response()->noContent();
}

Python (Flask)

import hmac, hashlib, time, os
from flask import request, abort

@app.post('/webhooks/waitforge')
def waitforge_webhook():
    header = request.headers.get('X-WaitForge-Signature', '')
    parts = dict(p.strip().split('=', 1) for p in header.split(',') if '=' in p)
    t, v1 = parts.get('t'), parts.get('v1')
    if not t or not v1:
        abort(400)

    body = request.get_data(as_text=True)
    expected = hmac.new(
        os.environ['WAITFORGE_SECRET'].encode(),
        f"{t}.{body}".encode(),
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, v1):
        abort(401)
    if abs(time.time() - int(t)) > 300:
        abort(401)

    payload = request.get_json()
    # ... handle payload['event'] == 'signup.active' ...
    return '', 204

Go (net/http)

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    header := r.Header.Get("X-WaitForge-Signature")
    var t int64; var v1 string
    for _, p := range strings.Split(header, ",") {
        kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
        if len(kv) != 2 { continue }
        switch kv[0] {
        case "t": t, _ = strconv.ParseInt(kv[1], 10, 64)
        case "v1": v1 = kv[1]
        }
    }
    body, _ := io.ReadAll(r.Body)
    mac := hmac.New(sha256.New, []byte(os.Getenv("WAITFORGE_SECRET")))
    mac.Write([]byte(fmt.Sprintf("%d.%s", t, body)))
    expected := hex.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(expected), []byte(v1)) { http.Error(w, "bad sig", 401); return }
    if abs64(time.Now().Unix() - t) > 300 { http.Error(w, "stale", 401); return }
    // handle JSON payload...
    w.WriteHeader(204)
}
Test vector

Paste these values into your implementation and confirm you get the same hex. If you do, your verification code matches ours.

secret    = "whsec_test_do_not_use_in_production"
body      = "{\"event\":\"ping\"}"
timestamp = 1700000000

signed_payload = "1700000000.{\"event\":\"ping\"}"
hmac_sha256_hex = "30a2c582cb93415987099beffa136de1d77e6c7b8dc00559727c59c378758c8b"

Quick shell reproduction: printf '1700000000.{"event":"ping"}' | openssl dgst -sha256 -hmac 'whsec_test_do_not_use_in_production' | awk '{print $2}'

Retry policy
  • Each attempt times out at 3 seconds.
  • Three attempts total per signup event: the first fires immediately when the signup lands. If it fails, a retry is scheduled ~10 seconds later. If that fails, a final retry ~60 seconds later. Total retry window: ~70 seconds.
  • Durable across worker isolate restarts — retries are queued by Cloudflare Queues, not a sleep loop inside the worker. An edge-server eviction mid-backoff does not drop your delivery.
  • Each retry is re-signed with a fresh timestamp so your receiver's replay-window check accepts it. The signature format is identical to the first attempt.
  • After 100 consecutive failures, the hook auto-deactivates. Recreate it once your endpoint is fixed.
  • Hooks that exhaust all three attempts land in a dead-letter queue; the owner UI surfaces the failure reason + counter on the Webhooks tab.
  • Redirects are not followed — point the webhook at the canonical URL.
  • Event ordering is not guaranteed. Use occurred_at if you need to sequence.
Secret rotation

Click Rotate on the Manage → Webhooks tab to issue a new secret without a delivery gap. Rotation runs in two phases:

  1. Start rotation. A new secret is issued and revealed once. Your existing secret stays live. For the next 48 hours every delivery signs with BOTH secrets — the signature header becomes t=<epoch>, v1=<hmac_with_old>, v1b=<hmac_with_new>.
  2. Swap your receiver, then click Commit on the hook row. The old secret is invalidated immediately. If you forget, the next delivery after the 48-hour window auto-commits for you.

During the window, your verification code has one job: accept a request if either signature matches. Pattern in Node.js:

import crypto from 'node:crypto';

function verifyWaitforge(req, secretOld, secretNew) {
  const header = req.header('X-WaitForge-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map(s => s.trim().split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const signed = `${t}.${req.body.toString()}`;
  const expect = (secret) => crypto.createHmac('sha256', secret).update(signed).digest('hex');
  const match = (given, want) => {
    if (!given || !want) return false;
    const a = Buffer.from(given), b = Buffer.from(want);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  };

  // Accept v1 (old secret) OR v1b (new secret — present only during rotation).
  return match(parts.v1, expect(secretOld)) ||
         (parts.v1b && match(parts.v1b, expect(secretNew)));
}

Cancelling a rotation. If you started the rotation by mistake, click Cancel on the hook row. The pending new secret is discarded; your original secret stays in place.

Parser note: verifiers that strictly require v1= as the last key in the header will reject dual-signed deliveries. Parse comma-separated key=value pairs in any order.

SSRF hardening

Webhook URLs are strictly validated at registration and again at delivery time:

  • Must be https:// — plain HTTP is refused.
  • Private / loopback / link-local ranges are refused (10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, fc00::/7, fe80::/10).
  • URLs with embedded credentials (https://user:pass@host) are refused.
  • Single-label hostnames (https://server/) are refused.
Zapier / Make / n8n

All three platforms accept webhook-triggered zaps. Copy the inbound webhook URL from your Zap / Scenario / Workflow and paste it as the endpoint URL in waitforge. No extra configuration — their inbound handlers ignore the signature header so the integration is plug-and-play.

Limits
  • Max 10 webhooks per waitlist.
  • Max request payload: ~2 KB (the JSON shape above).
  • Secrets are shown once at creation time. To rotate, delete the hook and recreate it.