Early access. The waiting room runs today in the live demo. Site keys are issued by hand while we test it with early partners. New here? Follow the step-by-step setup.
1. Install
Put this in the <head> of every page that should be protected. Load it without async if you use data-hold, so it runs before your own scripts.
<script src="https://fourway.io/w.js"
data-site="site_yourshop"
data-hold="/api/,/checkout"
data-pass="1"></script>| Attribute | What it does |
|---|---|
data-site | Your site id. Required. |
data-hold | Comma-separated paths. Same-origin fetch() calls to these paths wait until the visitor is admitted, so your API only sees admitted visitors. |
data-pass | 1 to get a signed pass when admitted, stored in the fw_pass cookie for your server to check. |
data-reload | 1 to reload the page once admitted, so server-rendered pages see the pass. |
data-park | When you pause the site, visitors go to the public parked page (fourway.io/web/parked), which returns them here on its own once you reopen. 0 keeps them on your page instead. |
data-name, data-color | Name and accent on the wait screen. Defaults: your og:site_name and theme-color. |
Your code can wait for admission too: await window.FourWay.ready.
No site id yet? Ids and their secret keys are issued by hand during early access: ask for one. Keep the secret in an environment variable, never in a page.
Done. Check your installation, or watch a rush in the demo. On a calm day nobody waits, so you will not see the line.
2. Pause bots during a rush
Most bots never run JavaScript, so they never see the waiting room. While your site is busy, a small gate on your server answers them with 503 and Retry-After. When the rush is over, they are welcome again. If FourWay cannot be reached, nothing is blocked.
- Paused while busy: AI crawlers (GPTBot, ClaudeBot and others), SEO tools (AhrefsBot, SemrushBot) and scripts (curl, headless browsers, empty User-Agent).
- Always let through: search engines and people in browsers.
Add two lines at the very top of your PHP entry file:
require __DIR__ . '/fourway_gate.php';
fourway_bot_gate('site_yourshop');fourway_gate.php (PHP, one file)
<?php
/**
* FourWay bot gate for PHP. While your site is busy, automated traffic (AI
* crawlers, SEO tools, scripts) gets "503 Service Unavailable" with Retry-After,
* so the capacity goes to people. Search engines are allowed unless you choose
* otherwise in FourWay. People in browsers are never touched by this gate.
*
* require __DIR__ . '/fourway_gate.php';
* fourway_bot_gate('site_yourshop'); // first line of your front controller
*
* Cost: at most one small HTTP request to FourWay every 5 seconds per server
* (the status is cached in a temp file); every other request is a string match.
* Fails open: if FourWay cannot be reached, nothing is blocked.
*/
function fourway_bot_gate(string $siteId, array $opt = []): void {
$api = rtrim($opt['api'] ?? 'https://fourway.io/api', '/');
$ttl = (int)($opt['cache_seconds'] ?? 5);
$st = fourway_gate_status($api, $siteId, $ttl);
if (!$st || empty($st['busy'])) return; // quiet, or FourWay unreachable: allow
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
$bots = $st['bots'] ?? [];
$pause = false;
if (trim($ua) === '') {
$pause = ($bots['empty_ua'] ?? 'allow') === 'pause';
} else {
foreach ((array)($bots['pause'] ?? []) as $tok) {
if (is_string($tok) && $tok !== '' && stripos($ua, $tok) !== false) { $pause = true; break; }
}
}
if (!$pause) return;
$retry = max(60, min(600, (int)($st['retry_after'] ?? 120)));
http_response_code(503);
header('Retry-After: ' . $retry);
header('Cache-Control: no-store');
header('Content-Type: text/plain; charset=utf-8');
header('X-FourWay-Gate: bot-paused');
echo "This site is busy right now. Automated traffic is paused; please retry after {$retry} seconds.\n";
exit;
}
/** Status from FourWay, cached in a temp file for a few seconds. Returns null on any failure. */
function fourway_gate_status(string $api, string $siteId, int $ttl): ?array {
if (!preg_match('/^[a-z0-9_]{3,40}$/', $siteId)) return null;
$file = rtrim(sys_get_temp_dir(), '/\\') . '/fourway_status_' . $siteId . '.json';
$fresh = is_file($file) && (time() - (int)@filemtime($file)) < $ttl;
if ($fresh) {
$j = json_decode((string)@file_get_contents($file), true);
return is_array($j) && !empty($j['ok']) ? ($j['data'] ?? null) : null;
}
$ctx = stream_context_create(['http' => ['timeout' => 1.5, 'ignore_errors' => true, 'header' => "Accept: application/json\r\n"]]);
$raw = @file_get_contents($api . '/wr/status?site=' . rawurlencode($siteId) . '&b=' . intdiv(time(), 5), false, $ctx);
$j = is_string($raw) ? json_decode($raw, true) : null;
// Cache failures too (as {"ok":false}), so a FourWay outage costs one try per few seconds, not one per request.
@file_put_contents($file . '.tmp', is_array($j) ? $raw : '{"ok":false}');
@rename($file . '.tmp', $file);
return is_array($j) && !empty($j['ok']) ? ($j['data'] ?? null) : null;
}
Cloudflare Worker version
/**
* FourWay bot gate for Cloudflare Workers (or any fetch-style edge runtime).
* While your site is busy, automated traffic gets 503 + Retry-After; people and
* (by default) search engines pass straight through to your origin.
* The status is cached at the edge for 5 seconds. Fails open.
*/
const SITE = 'site_yourshop';
const STATUS = `https://fourway.io/api/wr/status?site=${SITE}`;
async function fourwayStatus() {
try {
const r = await fetch(STATUS + '&b=' + Math.floor(Date.now() / 5000), { cf: { cacheTtl: 5, cacheEverything: true } });
const j = await r.json();
return j && j.ok ? j.data : null;
} catch (e) {
return null;
}
}
function paused(ua, bots) {
if (!bots) return false;
if (!ua || !ua.trim()) return bots.empty_ua === 'pause';
const u = ua.toLowerCase();
return (bots.pause || []).some((t) => typeof t === 'string' && t && u.includes(t.toLowerCase()));
}
export default {
async fetch(request, env, ctx) {
const st = await fourwayStatus();
if (st && st.busy && paused(request.headers.get('user-agent'), st.bots)) {
const retry = Math.max(60, Math.min(600, st.retry_after || 120));
return new Response(`This site is busy right now. Automated traffic is paused; please retry after ${retry} seconds.\n`, {
status: 503,
headers: { 'Retry-After': String(retry), 'Cache-Control': 'no-store', 'Content-Type': 'text/plain; charset=utf-8', 'X-FourWay-Gate': 'bot-paused' },
});
}
return fetch(request); // everyone else goes to your origin as usual
},
};
3. Enforce the line on your server (optional)
The script is a polite gate: people in a browser follow it, but tools that skip JavaScript do not. To enforce the line, reject requests to busy endpoints that lack a valid pass. Checking a pass is one HMAC, with no call to FourWay.
function fourway_pass_ok(?string $pass, string $siteId, string $secretHex): bool {
if (!$pass || substr_count($pass, '.') !== 1) return false;
[$p, $sig] = explode('.', $pass);
$want = rtrim(strtr(base64_encode(hash_hmac('sha256', 'fwp|' . $p, hex2bin($secretHex), true)), '+/', '-_'), '=');
if (!hash_equals($want, $sig)) return false;
$d = json_decode(base64_decode(strtr($p, '-_', '+/')), true);
return is_array($d) && ($d['s'] ?? '') === $siteId && ($d['x'] ?? 0) >= time();
}
if (!fourway_pass_ok($_COOKIE['fw_pass'] ?? null, 'site_yourshop', getenv('FOURWAY_SECRET'))) {
http_response_code(503);
header('Retry-After: 30');
exit('Busy. Please try again in a moment.');
}The same check in Node.js
const crypto = require('crypto');
function fourwayPassOk(pass, siteId, secretHex) {
if (!pass || pass.split('.').length !== 2) return false;
const [p, sig] = pass.split('.');
const want = crypto.createHmac('sha256', Buffer.from(secretHex, 'hex'))
.update('fwp|' + p).digest('base64url');
if (want.length !== sig.length || !crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sig))) return false;
const d = JSON.parse(Buffer.from(p, 'base64url').toString());
return d.s === siteId && d.x >= Math.floor(Date.now() / 1000);
}4. What it cannot do
- Help a page that never loadsIf your server cannot send the page at all, no script can run. Serve pages from a CDN cache and pair this with a CDN error fallback.
- Stop clients that skip JavaScriptBots and scripts go around a browser gate. Use the pass check above on the endpoints that matter.
- Block visitors if FourWay is downIt fails open: if FourWay is unreachable, visitors go straight through. A site can ask for fail-closed, accepting that risk.
5. Under the hood
How the line stays fair
- Each visitor gets one signed ticket: their place in line, to the second.
- Waiting visitors check a small status file every 10 to 20 seconds. It can be cached, so the number of waiting people barely matters.
- Every 5 seconds, FourWay moves one number, the watermark, forward by your admission rate. Everyone before it may go in.
- Admitted visitors are released spread over a few seconds, not all at once, and stay admitted across pages for 15 minutes.
| Mode | Behaviour |
|---|---|
| Auto | No line until arrivals exceed your rate; then a line forms by itself and clears by itself. |
| Queue | Everyone takes a place, even when quiet. Useful for a timed launch. |
| Maintenance | Everyone waits on the calm screen until you switch back. |
| Off | The script does nothing and makes no requests. |
With adaptive rate on, a small sample of admitted visitors reports how your API is responding. Errors or slow responses lower the rate by 30% at once; a healthy site with a line raises it gently.
6. Parked page links
Anyone can send traffic to the parked page, paused or not. It checks your address from the visitor's browser, forwards them when it answers well, and otherwise keeps them waiting and checks again.
https://fourway.io/web/parked?fw_url=https://shop.example.com/search&q=shoes&page=2
| Variable | What it does |
|---|---|
fw_url | Where visitors go. Required. A full address, or ./path for a page on fourway.io. |
fw_site | Your site id. When fw_url is one of your registered addresses, a pause also holds visitors, and a POSTed form is resent without a click. |
fw_min, fw_max | Seconds between checks, picked at random in this range. Default 60 to 240. |
fw_ms | Slowest answer that still counts as healthy, in milliseconds. Default 1500. |
fw_wait, fw_play | Seconds before the Continue button unlocks (default 20), and 0 to hide the game. |
| Anything else | Passed on to your address when the visitor is forwarded, after any query already in fw_url. In the example, visitors land on /search?q=shoes&page=2. |
? or # | Every variable works after ? or after #, for example /web/parked#fw_url=shop.example.com&q=shoes. A # part never reaches any server or cache. |
- Forms: with the script installed, a form sent while you are paused is held in the visitor's browser and resent to your site, field for field, when they continue. It travels in the link's
#fragment, which browsers never send to any server, so it never reaches FourWay. Files cannot be resent. - Checks stay clean: each check requests the bare address only, with no query variables and no form fields, so nothing is submitted twice.
- Older links:
?url=and the other names withoutfw_still work. In those links the bare names are FourWay's, so they are not passed on.