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.
- Open
/dashboard, pick a Pro waitlist, click the Webhooks tab. - Enter an HTTPS URL on your server, click Create webhook.
- Copy the
whsec_...secret once — it will not be shown again. - Click Send test to verify your endpoint receives the canned event.
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.
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)
}
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}'
- 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_atif you need to sequence.
Click Rotate on the Manage → Webhooks tab to issue a new secret without a delivery gap. Rotation runs in two phases:
- 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>. - 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.
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.
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.
- 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.