HomeDocsPricingContactSign in

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.
Event types

Two event types are emitted today. Subscribe to either or both per hook (events array on the create-hook endpoint):

EventFires whenNotes
signup.active Every successful signup — free or paid. Default. Stable wire shape since v1; safe to subscribe to permanently.
signup.paid Settled paid signups only (the waitlist has paid signups enabled and the visitor's USDC payment cleared). Strict superset of signup.active — same envelope plus a payment block. Hooks subscribed to BOTH receive only signup.paid for paid signups (no double delivery). See paid signups guide.
Payload shape

Every delivery is a POST with Content-Type: application/json. The base envelope (signup.active):

{
  "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"
    }
  }
}

Paid signups (when subscribed to signup.paid) extend the same envelope with a top-level payment block:

{
  "event": "signup.paid",
  "occurred_at": "2026-04-27T14:23:11.000Z",
  "waitlist": { "id": "...", "slug": "your-slug", "title": "..." },
  "signup": { "...same fields as above..." },
  "payment": {
    "amount_atomic": "5000000",
    "currency": "USDC",
    "network": "eip155:8453",
    "tx_hash": "0xabc...",
    "payer_address": null,
    "platform_fee_atomic": "0",
    "settled_at": "2026-04-27T14:23:10.500Z",
    "ledger_id": "42"
  }
}

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.