Skip to content

Website Lead Intake

Doug edited this page Aug 8, 2026 · 2 revisions

Website Lead Intake

Capture form submissions from your public marketing site as landing leads in GDX: each successful POST creates a row in the leads triage list, rings the in-app notification bell for every user, and writes an audit entry. This page covers wiring a website to the intake endpoint end to end.

The flow:

your website form
  └── your site's server-side handler          (holds the API key)
        └── POST /api/v1/landing-leads          (X-API-Key header)
              ├── landing_leads row             → Leads triage list
              ├── broadcast notification        → topbar bell, all users
              └── audit entry                   → landing_lead_created

1. Mint an API key

The endpoint authenticates with an API key carrying the landing_leads:write scope. Mint one from the CLI, inside the app container:

python -m gdx_dispatch.tools.create_api_key \
    --tenant <slug-or-uuid> \
    --name "example.com lead form" \
    --scopes landing_leads:write

Optional: --expires-days N (default: never expires), --dry-run.

The full key (gdx_live_…) is shown once — store it in your site's secret store immediately. Only a SHA-256 hash and the 16-char prefix are kept; there is no way to recover a lost key, only to revoke and re-mint.

Keys can also be minted, listed, and revoked over the API (POST/GET /api/developer/keys, DELETE /api/developer/keys/<id>). Revocation is immediate and permanent. (The old /developer portal UI page was removed with the rest of the multi-tenant control plane in v1.38.0 — the key API and CLI are the supported paths.)

2. Wire your website

The API key must never reach the browser. Have your site's form POST to a server-side handler on your site, and let that handler forward to GDX with the key. A static site with client-side JS would expose the key to every visitor — if you have no server side at all, put a tiny relay (worker, function, etc.) in front.

The forward call:

curl -X POST https://your-gdx-host/api/v1/landing-leads \
  -H "X-API-Key: $GDX_LEAD_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jane Doe",
    "phone": "555-555-0100",
    "email": "jane@example.com",
    "message": "Broken spring, door stuck halfway.",
    "source": "website",
    "utm_source": "google",
    "utm_campaign": "spring-repair"
  }'

Success → 201 with {"data": {"id": "<uuid>", "status": "new"}}.

Make your relay loud on failure. If GDX returns a non-2xx (revoked key, network trouble), a relay that still shows the visitor a "Thanks!" page will silently discard real customers. Log the failure where you'll see it, and ideally fall back to something durable (an email to yourself, a queue) so no lead is ever lost while the primary path is down.

Request reference

All fields are optional strings at the API level — enforce your own required-ness in your site form. Server-side column limits:

Field Limit Notes
name 200
email 254
phone 30 over-limit values are rejected, not truncated
message unlimited text
source 100 defaults to website; use it to distinguish forms (repair-form, …)
referrer 500
utm_campaign / utm_source / utm_medium 200 each shown in the triage list
cf_turnstile_token Turnstile response token, if enabled (below)
website honeypot — never render this as a visible field

Responses:

Status Meaning
201 Lead created (data.id, data.status)
400 Turnstile challenge failed (error: challenge_failed + codes)
401 Missing, unknown, expired, or revoked API key
403 Key lacks the landing_leads:write scope
429 Rate limit: 60 requests/minute per key

Spam protection

Three independent layers:

  • Honeypot — the website field. Include it in your form as a hidden input; humans leave it empty, bots fill it. A tripped honeypot returns a fake 201 (with a synthetic id) and stores nothing, so bots learn nothing.
  • Cloudflare Turnstile (recommended) — create a Turnstile site in the Cloudflare dashboard, render the widget on your form, and pass the response token as cf_turnstile_token. On the GDX side set TURNSTILE_SECRET (and optionally TURNSTILE_HOSTNAME to pin the expected hostname). Unset secret = fail-open: tokens are not checked, which is the intended dev/preview behavior — production should set the secret, otherwise expect cold-outreach and crypto spam to arrive as real-looking leads.
  • Rate limit — 60 requests/minute per key, enforced via Redis.

Verify the wiring

Submit a test through your real form, then check:

  1. Leads triage list — the submission appears with source and UTM tags.
  2. Notification bell — every user gets a "New lead" notification.
  3. On failure, the audit log and your relay's logs are the places to look.

Troubleshooting

  • 401 on every request — the key was revoked, expired, or the api_keys table no longer holds it (e.g. after a database restore). Re-mint and update your site's secret. This failure mode is silent from the visitor's point of view — see "make your relay loud" above.
  • 403 Scope … required — the key exists but wasn't minted with landing_leads:write.
  • 400 challenge_failed — the Turnstile token was missing/invalid while TURNSTILE_SECRET is set; check the widget renders and the token field name matches.
  • 500 on long valuesphone (30) and other limited columns reject over-length input; validate lengths in your form.

See also

Clone this wiki locally