All endpoints are Netlify Functions accessible at /.netlify/functions/<name>.
| Endpoint | Method | Purpose | Reads Sheet? |
|---|---|---|---|
/access-verify |
POST | Verify code, bind device, create session | Yes |
/access-me |
GET | Check current session validity | No |
/access-log |
POST | Log usage events | No (append only) |
Verifies an access code, performs device binding, and creates a session.
POST /.netlify/functions/access-verify
Content-Type: application/json
{
"code": "ABCD-EFGH-IJKL-MNOP"
}Body Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
code |
string | Yes | The access code to verify |
HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: gate_device=...; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=31536000
Set-Cookie: gate_access=...; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=604800
{
"ok": true,
"label": "Acme Corp Demo",
"expires_at": "2025-12-31T00:00:00.000Z"
}Note: Cookie prefix is configurable via COOKIE_PREFIX env var (default: gate).
Response Fields:
| Field | Type | Description |
|---|---|---|
ok |
boolean | Always true on success |
label |
string | Human-readable label from Access sheet |
expires_at |
string | ISO timestamp when access expires |
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"ok": false,
"error": "INVALID_CODE"
}HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"ok": false,
"error": "REVOKED"
}HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"ok": false,
"error": "EXPIRED"
}HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"ok": false,
"error": "DEVICE_LIMIT_REACHED"
}This occurs when the code has already been used on the maximum number of devices (default: 3, configurable per code via max_devices column).
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"ok": false,
"error": "MISSING_EXPIRES_AT"
}Before hashing, codes are normalized:
- Trimmed of whitespace
- Converted to uppercase
- Spaces removed
- Non-alphanumeric characters (except
-) removed
These are equivalent:
abcd-efgh-ijkl-mnopABCD-EFGH-IJKL-MNOPabcd efgh ijkl mnopAbCdEfGhIjKlMnOp
The system supports multi-device binding. Each code can be used on up to max_devices different devices (default: 3).
| Scenario | Action |
|---|---|
| No device cookie | Generate new device ID, set cookie |
Device already in device_hash list |
Allow access |
| Device not in list, room available | Add device to list, allow access |
| Device not in list, list full | Reject with DEVICE_LIMIT_REACHED |
shared=TRUE in sheet |
Skip all device checks, allow any device |
Device hash format: Pipe-separated hashes: hash1|hash2|hash3
Cookie name: {COOKIE_PREFIX}_device (default prefix: gate)
Checks if the current session is valid. Does not read from the sheet.
GET /.netlify/functions/access-me
Cookie: gate_access=eyJhbGciOiJIUzI1NiIs...No request body. Cookies are sent automatically by the browser.
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"label": "Acme Corp Demo",
"expires_at": "2025-12-31T00:00:00.000Z"
}HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"ok": false
}Call this endpoint on app boot to determine whether to show the gate or the app:
async function checkAccess() {
const res = await fetch('/.netlify/functions/access-me', {
credentials: 'include' // Important: send cookies
});
const data = await res.json();
return data.ok;
}
// On app load
if (await checkAccess()) {
showApp();
} else {
showGate();
}Logs a usage event to the Usage sheet. Requires a valid session.
POST /.netlify/functions/access-log
Content-Type: application/json
Cookie: gate_access=eyJhbGciOiJIUzI1NiIs...
{
"event": "level_complete",
"path": "/game/level-3",
"meta": {
"score": 9500,
"time_seconds": 120
}
}Body Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
event |
string | Yes | Event name (e.g., open_app, level_complete) |
path |
string | No | Current page/route |
meta |
object | No | Arbitrary metadata (stored as JSON string) |
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true
}HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"ok": false
}Each call appends a row to the Usage sheet:
| Column | Value |
|---|---|
ts |
Current ISO timestamp |
event |
From request body |
label |
From JWT claims |
code_hash |
From JWT claims |
device_hash |
Hash of device cookie value |
path |
From request body |
meta_json |
JSON.stringify(meta) |
| Event | When to Fire |
|---|---|
open_app |
App loads after gate |
page_view |
User navigates to a page |
feature_used |
User interacts with a feature |
level_start |
Game level begins |
level_complete |
Game level ends |
error |
An error occurs |
logout |
User clears session (if you implement this) |
| Code | HTTP Status | Meaning |
|---|---|---|
MISSING_CODE |
400 | No code provided in request body |
INVALID_JSON |
400 | Request body is not valid JSON |
INVALID_CODE |
401 | Code hash not found in Access sheet |
INVALID_ORIGIN |
403 | Request origin failed CSRF validation |
REVOKED |
403 | Code has been revoked (revoked=TRUE) |
EXPIRED |
403 | Current time is past expires_at |
DEVICE_LIMIT_REACHED |
403 | Code already used on maximum devices |
RATE_LIMITED |
429 | Too many requests, try again later |
MISSING_EXPIRES_AT |
500 | Access row has no expires_at value |
SERVER_CONFIG_ERROR |
500 | Missing or invalid server configuration |
INTERNAL_ERROR |
500 | Unexpected server error |
The /access-verify endpoint is rate-limited to prevent brute force attacks.
- Default: 10 requests per minute per IP
- Response:
429 Too Many RequestswithRetry-Afterheader - Configuration: Set
RATE_LIMIT_WINDOW_MSandRATE_LIMIT_MAX_REQUESTSenv vars
Cookie names use a configurable prefix (default: gate, set via COOKIE_PREFIX env var).
| Cookie | Purpose | Attributes | Max-Age |
|---|---|---|---|
{prefix}_device |
Device identifier | HttpOnly, Secure, SameSite=None, Path=/ | 31536000 (1 year) |
{prefix}_access |
Session JWT | HttpOnly, Secure, SameSite=None, Path=/ | 604800 (7 days) |
Notes:
- Default prefix is
gate(e.g.,gate_device,gate_access) - Production:
SameSite=None+Secure=true(supports cross-origin requests) - Local dev:
SameSite=Lax+Secure=false(browsers reject None without Secure)
All endpoints support CORS for same-origin requests. When calling from JavaScript:
// Always include credentials to send cookies
fetch('/.netlify/functions/access-verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: userCode })
});Built-in rate limiting protects /access-verify against brute force:
- Default: 2 requests per minute per IP
- Configurable: Set
RATE_LIMIT_WINDOW_MSandRATE_LIMIT_MAX_REQUESTSenv vars
Important: Rate limiting is in-memory and resets on cold starts. This provides basic abuse protection but is not durable. For stronger guarantees, add Netlify Edge Functions or an external rate limiting service.