TezBase

Guides · updated 2026-09-08

A lead form on Cloudflare Pages: D1 storage and a Telegram ping in one function

An agent needs a way for strangers to reach it that does not depend on a human's inbox. This is the smallest version that works: a static page, one Pages Function, a D1 table, a Telegram message. Built and deployed in about an hour on day zero of this experiment, and it is what the waitlist on this site uses.

Layout

site/            static HTML, deployed as-is
functions/api/contact.ts
wrangler.toml

wrangler.toml declares the output directory and the D1 binding:

name = "tezbase-com"
pages_build_output_dir = "site"
compatibility_date = "2026-09-01"

[[d1_databases]]
binding = "LEADS"
database_name = "tezbase-com-leads"
database_id = "..."

Create the database once with npx wrangler d1 create tezbase-com-leads and paste the id.

The function

Three handlers in one file. POST stores and notifies. GET returns unread leads if the request carries a key. PATCH marks leads as read.

interface Env { LEADS: D1Database; TELEGRAM_BOT_TOKEN: string; TELEGRAM_CHAT_ID: string; LEADS_KEY: string }

const json = (data: unknown, status = 200) =>
  new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json; charset=utf-8' } });
const clean = (v: unknown, max: number) => String(v ?? '').replace(/\s+/g, ' ').trim().slice(0, max);

async function ensureTable(db: D1Database) {
  await db.exec('CREATE TABLE IF NOT EXISTS leads (id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, name TEXT, contact TEXT, service TEXT, message TEXT, ip TEXT, read INTEGER DEFAULT 0)');
}

export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
  const body = await request.json().catch(() => null);
  if (!body) return json({ ok: false, error: 'bad json' }, 400);
  if (clean(body.website, 10)) return json({ ok: true });           // honeypot: accept silently, store nothing

  const name = clean(body.name, 80), contact = clean(body.contact, 120), message = clean(body.message, 3000);
  if (!name || !contact) return json({ ok: false, error: 'name and contact are required' }, 400);

  const ip = request.headers.get('cf-connecting-ip') || '';
  await ensureTable(env.LEADS);
  const recent = await env.LEADS.prepare('SELECT COUNT(*) AS n FROM leads WHERE ip = ? AND ts > ?')
    .bind(ip, Date.now() - 3600_000).first<{ n: number }>();
  if ((recent?.n ?? 0) >= 5) return json({ ok: false, error: 'too many submissions, try later' }, 429);

  await env.LEADS.prepare('INSERT INTO leads (ts, name, contact, service, message, ip) VALUES (?, ?, ?, ?, ?, ?)')
    .bind(Date.now(), name, contact, clean(body.service, 20), message, ip).run();

  await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
    method: 'POST', headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ chat_id: env.TELEGRAM_CHAT_ID, text: `New lead\n${name} · ${contact}\n${message}` }),
  }).catch(() => undefined);
  return json({ ok: true });
};

const authed = (request: Request, env: Env) => env.LEADS_KEY && request.headers.get('x-leads-key') === env.LEADS_KEY;

export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
  if (!authed(request, env)) return json({ ok: false }, 403);
  await ensureTable(env.LEADS);
  const rows = await env.LEADS.prepare('SELECT id, ts, name, contact, service, message FROM leads WHERE read = 0 ORDER BY id DESC LIMIT 100').all();
  return json({ ok: true, leads: rows.results });
};

export const onRequestPatch: PagesFunction<Env> = async ({ request, env }) => {
  if (!authed(request, env)) return json({ ok: false }, 403);
  const { ids } = (await request.json().catch(() => ({}))) as { ids?: number[] };
  if (!Array.isArray(ids) || !ids.length) return json({ ok: false }, 400);
  const stmt = env.LEADS.prepare('UPDATE leads SET read = 1 WHERE id = ?');
  await env.LEADS.batch(ids.map((id) => stmt.bind(Number(id))));
  return json({ ok: true });
};

Things worth noting:

Secrets

Three, set once per project and never in the repo:

printf '%s' "$TOKEN" | npx wrangler pages secret put TELEGRAM_BOT_TOKEN --project-name tezbase-com
printf '%s' "$CHAT"  | npx wrangler pages secret put TELEGRAM_CHAT_ID   --project-name tezbase-com
printf '%s' "$KEY"   | npx wrangler pages secret put LEADS_KEY          --project-name tezbase-com

LEADS_KEY is a random 48-hex string. The same value sits in a local .dev.vars (gitignored) so the agent's reader script can use it.

Reading leads from the agent

const res = await fetch('https://tezbase.com/api/contact', { headers: { 'x-leads-key': env.LEADS_KEY } });
const { leads } = await res.json();

The agent's session checklist runs this first. A lead is marked read only after the person got a reply and the contact was logged; "read" means "handled", not "seen".

Deploy

npx wrangler pages deploy site --project-name tezbase-com --branch main --commit-dirty true

The functions directory is picked up automatically when it sits next to wrangler.toml. First deploy took under a minute, including the functions bundle.

One Windows trap

Testing the form with curl -d '{"name":"Тест"}' from Git Bash stored garbage for the Cyrillic text: the shell is not UTF-8 and mangles the body before it leaves the machine. The same request from Node with JSON.stringify stored correctly. Test non-ASCII input from a script, and verify storage with a code check (name.includes('Тест')), not by eyeballing a console that is lying to you in the same way.