License

Webhook & daily report setup

The server-side half of licensing: receiving domain-mismatch events on your own endpoint, and rolling up usage plus those events into one daily call to TsWorldTech. Five steps — credentials, the webhook receiver, the report route, a scheduler for your host, and testing the whole loop end to end.

Prerequisites
  • A FLUX_LICENSE_TOKEN already wired into createFlux() — see license/setup if not
  • A project on the License tab with a webhook URL slot and report schedule available to configure
  • A server-side route you control (Next.js Route Handler, Express, or equivalent)
1

Why this exists at all

One paragraph of context before the setup steps — this explains why an offline-first license system still needs a server-side page at all.
typescript
1// Flux's license verification is offline-first, not zero-network.
2// Within a 3-day cache window, verification (signature, domain,
3// expiry — all of it) never touches the network at all. Outside
4// that window, one small cached-and-retried request fetches your
5// project's public signing key from TsWorldTech — see
6// license/domain-binding, Step 1, for exactly when and why.
7//
8// What genuinely never happens, regardless of license state: no
9// per-event, per-session, or per-boot "check in" ping. The old
10// verify-on-every-boot model is gone. TsWorldTech has essentially no
11// real-time visibility into your usage, or into misuse of your key,
12// unless your own server says so — once a day. This page is that
13// telling. It's the only server-to-server contact this system makes
14// with TsWorldTech at all.
2

Get your four credentials, plus one you generate

Open your project's License tab (tsworldtechflux.dev/portal/projects/[id]/license) and reveal these values. CRON_SECRET is the one exception — you generate it yourself.
bash
1# .envsame four values from license/setup, plus one you generate
2# yourself for this page specifically.
3
4FLUX_LICENSE_TOKEN=eyJhbGciOiJFUzI1NiIs...
5FLUX_KEY_ID=flux_ent_xyz789
6FLUX_PROJECT_ID=proj_abc123
7FLUX_LICENSE_SECRET=sk_live_550e8400...
8
9# Not issued by TsWorldTechyou generate this yourself, e.g.:
10# openssl rand -hex 32
11# It exists purely to stop random internet traffic from hitting your
12# OWN cron route and triggering premature or duplicate submissions.
13CRON_SECRET=<generate this yourself>
typescript
1// FLUX_LICENSE_TOKEN is the only one of these five values safe to
2// expose to the browser — it's what NEXT_PUBLIC_FLUX_LICENSE_TOKEN
3// (or your framework's equivalent) should hold.
4//
5// FLUX_KEY_ID, FLUX_PROJECT_ID, FLUX_LICENSE_SECRET, and CRON_SECRET
6// all stay server-only, for the entire rest of this page.
3

Write the webhook receiver

This endpoint receives domain-mismatch events from any browser running your token on an unauthorized domain — entirely on your own infrastructure.
typescript
1// app/api/flux-webhook/route.ts
2//
3// This receives domain-mismatch events from any browser running
4// your token on an unauthorized domain. Runs entirely on YOUR OWN
5// server — TsWorldTech never sees these events unless you choose to
6// roll them up in Step 4's daily report.
7
8import { createFluxWebhookHandler } from '@tsworldtech/flux/server'
9import { db } from './db'
10
11const handleFluxWebhook = createFluxWebhookHandler({
12 persist: async (hit) => {
13 await db.fluxWebhookHits.create({
14 data: {
15 type: hit.type,
16 usedDomain: hit.usedDomain,
17 registeredDomain: hit.registeredDomain,
18 timestamp: new Date(hit.timestamp),
19 userAgent: hit.userAgent,
20 }
21 })
22 }
23})
24
25export async function POST(req: Request) {
26 await handleFluxWebhook(await req.json())
27 return Response.json({ ok: true })
28}
typescript
1// Set this route's URL as your Webhook URL on the project's License
2// tab (Webhook Config Card). It must be HTTPS.
3//
4// Sample rate defaults to 10% — this is how often a REAL mismatch
5// actually triggers a beacon, not how often this route gets called
6// by legitimate users. It never gets called by legitimate users at
7// all — only mismatches reach this route, ever.
This route receives anonymous POSTs

navigator.sendBeacon can't attach custom headers, so requests here carry no signature. The payload is low-sensitivity by design — see below for the full reasoning.

typescript
1// A known limitation, documented rather than "fixed": navigator
2// .sendBeacon cannot attach custom headers, so the beacon hitting
3// this route carries no HMAC or bearer-token signature. Your
4// endpoint must accept anonymous POSTs.
5//
6// The payload itself is low-sensitivity — a domain string, an event
7// type, a timestamp — so this is an accepted tradeoff, not an
8// oversight. If you're worried about noise unrelated to real Flux
9// events, apply IP-based rate limiting at your own edge/CDN layer —
10// this is outside what Flux can enforce from inside a browser.
4

Write the daily report route

The one server-to-server call your infrastructure makes to TsWorldTech, once per day — rolling up real usage numbers and an aggregated webhook-hit summary in a single request.
typescript
1// app/api/cron/flux-report/route.ts
2//
3// The one server-to-server call your infrastructure makes to
4// TsWorldTech, once per day, rolling up both your real usage numbers
5// and an aggregated summary of whatever webhook hits landed in the
6// last 24 hours.
7
8import { sendDailyReport, aggregateWebhookHits, verifyCronSecret } from '@tsworldtech/flux/server'
9
10export async function GET(req: Request) {
11 if (!verifyCronSecret(req, process.env.CRON_SECRET)) {
12 return new Response('Unauthorized', { status: 401 })
13 }
14
15 const since = Date.now() - 24 * 60 * 60 * 1000
16 const hits = await db.fluxWebhookHits.findMany({
17 where: { timestamp: { gte: new Date(since) } }
18 })
19 const webhookSummary = aggregateWebhookHits(hits, since)
20
21 const result = await sendDailyReport({
22 key_id: process.env.FLUX_KEY_ID!,
23 project_id: process.env.FLUX_PROJECT_ID!,
24 secret: process.env.FLUX_LICENSE_SECRET!,
25 date: new Date().toISOString().split('T')[0],
26 events: await getEventCount(),
27 users: await getUserCount(),
28 features: ['lruCache', 'jobTracker'],
29 domains: ['yourapp.com'],
30 webhook_hits: webhookSummary,
31 })
32
33 return Response.json({ ok: true, quotaRemaining: result.quota_remaining })
34}
typescript
1// verifyCronSecret(req, CRON_SECRET) is what stops this route from
2// being triggered by anyone who isn't your own scheduler — it's YOUR
3// own secret, not something TsWorldTech issues or checks.
4//
5// Vercel Cron is the one exception where this guard is unnecessary
6// (see the host table in Step 6) because Vercel signs its own cron
7// requests internally. Every other host needs this guard.
5

Handle the 429 case explicitly

One accepted report per project per 24 hours is enforced server-side. A double-fire isn't a real failure — catch it by type, not by status code alone.
typescript
1// TsWorldTech's report endpoint enforces one accepted report per
2// (key_id, project_id) pair per 24 hours. If your cron somehow fires
3// twice in a window — a retry, a manual trigger, a misconfigured
4// second scheduler — the second call gets:
5//
6// HTTP 429
7// Retry-After header
8// body: { error: "report_already_submitted_today", next_allowed_at }
9//
10// sendDailyReport() throws a DailyReportRateLimitedError specifically
11// for this case — distinguishable from a real failure. Catch it and
12// no-op rather than alerting on it:
13
14import { DailyReportRateLimitedError } from '@tsworldtech/flux/server'
15
16try {
17 await sendDailyReport({ /* ...same as Step 4 */ })
18} catch (err) {
19 if (err instanceof DailyReportRateLimitedError) {
20 console.log('Report already submitted today, next allowed at', new Date(err.nextAllowedAt))
21 return Response.json({ ok: true, skipped: true })
22 }
23 throw err
24}
typescript
1// If you run more than one project under the same Enterprise key:
2// each project needs its own cron route (or the same route
3// parameterized by project), since key_id alone doesn't identify
4// which project's domains/webhook config a report describes —
5// project_id does.
6//
7// The 24h rate limit is scoped to the (key_id, project_id) PAIR, so
8// three projects under one key correctly get three accepted reports
9// a day, not one shared budget of one.
6

Pick your host and wire the scheduler

The route from Step 4 doesn't change across hosts — only how something calls it on a schedule does. Set your preferred time on the License tab's Report Schedule Card (default suggestion 03:00 UTC); it regenerates the cron expression shown below automatically.
json
1// vercel.json — no CRON_SECRET check needed; Vercel signs these
2// requests itself.
3
4{ "crons": [{ "path": "/api/cron/flux-report", "schedule": "0 3 * * *" }] }
bash
1# Rendera separate "Cron Job" resource, one-off container
2# invocation on schedule "0 3 * * *":
3
4curl -X GET https://yourapp.com/api/cron/flux-report \
5 -H "Authorization: Bearer $CRON_SECRET"
json
1// Railway — cronSchedule field on the service:
2
3{ "cronSchedule": "0 3 * * *" }
toml
1# NetlifyScheduled Functions:
2
3[[scheduled.functions]]
4 path = "flux-report"
5 schedule = "0 3 * * *"
bash
1# Self-hosted / VPS / Dockersystem crontab or an in-process
2# node-cron:
3
40 3 * * * curl https://yourapp.com/api/cron/flux-report \
5 -H "Authorization: Bearer $CRON_SECRET"
6
7# Other: any external cron-as-a-service, or a scheduled GitHub
8# Actions workflow, pointed at the deployed route the same way as
9# this self-hosted example.
Vercel is the one exception

Every other host needs the verifyCronSecret() guard from Step 4 — Vercel is the only platform that signs its own cron requests internally.

typescript
1// Every host except Vercel needs the verifyCronSecret() guard from
2// Step 4 — Vercel is the only platform that signs its own cron
3// requests, so it's the only one where skipping the guard is safe
4// rather than a hole.
7

Test it

Two separate tests — one for the webhook receiver, one for the report route — neither touches real production report data.
typescript
1// Testing the webhook receiver (Step 3):
2// Use "Test webhook" on the License tab's Webhook Config Card to
3// fire a synthetic mismatch event at your route and confirm it
4// persists correctly. This doesn't touch your real report data.
5
6// Testing the daily report route (Step 4) end to end:
7// Manually hit your cron route once, with the correct Authorization
8// header if your host needs it, and confirm the response:
9
10// GET /api/cron/flux-report
11// -> { ok: true, quotaRemaining: ... }
12
13// Then check the project's Usage tab. It should flip from:
14// "Set up usage reporting to see data here"
15// to:
16// "Waiting for your first report"
17// immediately, and to the full populated dashboard within the next
18// scheduled cycle.
8

What 'done' looks like

Three things worth confirming yourself before considering this finished.
typescript
1// What "done" looks like, all three true at once:
2
3// 1. Watch the browser's Network tab on first load. You'll see at
4// most one small request to api.tsworldtechflux.dev/v1/keys/...
5// — fetching your project's public signing key, cached for 3
6// days after that (see license/domain-binding, Step 1). Reload
7// within that window and confirm it's gone: zero requests. This
8// ONE cached request is the entire client-side network footprint
9// of licensing — everything else (domain check, expiry, tier
10// unlock) is pure offline logic on top of it.
11
12// 2. Your webhook route (Step 3) silently sits idle unless a token
13// genuinely turns up on an unauthorized domain.
14
15// 3. Your cron route (Step 4) makes exactly ONE outbound call to
16// TsWorldTech per day, from your infrastructure, on your
17// schedule — never from an end-user browser, ever.

For the four base credentials referenced in Step 2, see license/setup. For what triggers the webhook beacon this page receives — and the client-side key-fetch behavior referenced in Step 8 — see license/domain-binding. For what the numbers in your daily report mean once they show up in the portal, see license/quotas.