Skip to content

Commit ee3e7eb

Browse files
RipperMercsclaude
andcommitted
feat(worker): premium webhook watches (price + status, signed POST delivery)
Agents can now register watches that fire HMAC-signed webhooks when a model price crosses a threshold or a provider's status changes. Phase 1 of the recurring/event-driven billing path. Endpoints (under /api/premium/watches): POST register a watch (1 credit). Body: { spec, callback_url, secret?, fire_cap? } GET list watches owned by the bearer token (free) GET /{id} read one watch + fire metadata (free, owner-only) DELETE /{id} remove a watch (free, owner-only) Watch types in v1: price { model, field: inputPrice|outputPrice|blended, op: lt|gt|changes, threshold? } status { provider, op: becomes|changes, value? } Predicates fire only on edge transitions (debounced) so a watch on "Opus 4.7 inputPrice < $10" fires once when the price crosses, not on every poll while the price stays below. Watches live 90 days, fire up to fire_cap times (default 100), capped at 25 active watches per token. Delivery: signed POST to callback_url with X-TensorFeed-Signature (HMAC-SHA256 over the body using the watch's secret) and X-TensorFeed-Watch-Id headers. Fire-and-forget with 8-second timeout. Basic SSRF guard rejects http://, localhost, RFC1918, link-local, and loopback addresses. Cron integration: - Status dispatch hooks into pollStatusPages (every 5 min) using the same transition detection the incident detector already runs on. - Price dispatch runs daily after updateCatalog via runPriceWatchCycle, diffing the freshly-merged pricing payload against a watch-owned previous snapshot at watch:prev:pricing. Storage layout (TENSORFEED_CACHE): watch:{id} full Watch record (90-day TTL) watch:index:price string[] of watch IDs (single-key lookup per cron) watch:index:status string[] watch:byToken:{token} string[] for owner listing watch:prev:pricing last seen pricing payload for diff baseline Tests: 30 new vitest cases covering URL validation (SSRF), spec validation, predicate edge transitions, transition computation, HMAC determinism, CRUD lifecycle, ownership enforcement, and end-to-end dispatch with stubbed fetch. Total worker tests: 63. Updated /api/meta, public/llms.txt, /developers/agent-payments, and CLAUDE.md to advertise the new endpoints. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0818bf0 commit ee3e7eb

7 files changed

Lines changed: 1260 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ tensorfeed/
3838
routing.ts Tier 2 routing recommendation engine + free preview rate limiter
3939
routing.test.ts Vitest unit tests for the routing engine (pure-logic, no chain)
4040
payments.ts Payment middleware: credits + x402 fallback, USDC on Base verification, daily rollup analytics
41+
history-series.ts Premium history series: pricing/benchmark series, status uptime, snapshot diff
42+
watches.ts Premium webhook watches: register, predicate eval, HMAC-signed POST delivery, cron-driven dispatch
43+
watches.test.ts Vitest coverage for predicate edge transitions, SSRF guard, dispatch end-to-end
4144
podcasts.ts Podcast feed polling
4245
trending.ts Trending GitHub repos
4346
twitter.ts X/Twitter auto-posting
@@ -195,6 +198,9 @@ All mounted under `https://tensorfeed.ai/api/*` via the Worker.
195198
- `/api/premium/history/benchmarks/series?model=&benchmark=&from=&to=`: Tier 1, 1 credit. Score evolution for a single benchmark on one model. Returns delta in percentage points.
196199
- `/api/premium/history/status/uptime?provider=&from=&to=`: Tier 1, 1 credit. Daily uptime % for one provider (degraded counts as half) with incident-day list. Missing-data days excluded from denominator.
197200
- `/api/premium/history/compare?from=&to=&type=pricing|benchmarks`: Tier 1, 1 credit. Diff two daily snapshots: added, removed, changed entries with deltas.
201+
- `/api/premium/watches` (POST): Tier 1, 1 credit per registration. Body `{ spec, callback_url, secret?, fire_cap? }`. Spec is `{ type: "price"|"status", ... }`. Watch lives 90 days, default fire cap 100. Fires deliver HMAC-signed POST to callback URL.
202+
- `/api/premium/watches` (GET): List watches owned by the bearer token. Free.
203+
- `/api/premium/watches/{id}` (GET|DELETE): Read or remove an owned watch. Free.
198204

199205
**Admin (auth-gated via `?key=ENVIRONMENT`):**
200206
- `/api/admin/usage?date=YYYY-MM-DD`: Daily revenue + usage rollup
@@ -308,6 +314,8 @@ Key modules:
308314
- `worker/src/payments.ts`: middleware (`requirePayment`), USDC verification, quote/confirm/balance, daily rollup analytics
309315
- `worker/src/routing.ts`: routing engine + free preview rate limiter
310316
- `worker/src/history.ts`: daily snapshot capture (the data moat)
317+
- `worker/src/history-series.ts`: premium aggregated views (series, uptime, compare) over the daily snapshots
318+
- `worker/src/watches.ts`: premium webhook watches; status dispatch hooks into `pollStatusPages` (every 5 min), price dispatch runs daily after `updateCatalog`
311319

312320
SDKs:
313321
- Python: `sdk/python/` (1.2.0). `pip install tensorfeed[web3]` enables `tf.purchase_credits()` for one-call sign-and-send. See `sdk/python/PUBLISHING.md` for the PyPI release flow.

public/llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ TensorFeed accepts USDC on Base as the sole payment method for premium endpoints
5353
- [Premium Benchmark Series](https://tensorfeed.ai/api/premium/history/benchmarks/series): Tier 1, 1 credit per call. Score evolution for a single benchmark (e.g. swe_bench, mmlu_pro, gpqa_diamond, math, human_eval) on one model. Params: `?model=&benchmark=&from=&to=`. Range capped at 90 days.
5454
- [Premium Status Uptime](https://tensorfeed.ai/api/premium/history/status/uptime): Tier 1, 1 credit per call. Daily status rollup for one provider with operational/degraded/down day counts, uptime % (degraded counts as half), and incident-day list. Params: `?provider=&from=&to=`.
5555
- [Premium History Compare](https://tensorfeed.ai/api/premium/history/compare): Tier 1, 1 credit per call. Diff between two daily snapshots: added, removed, and changed entries with deltas. Params: `?from=YYYY-MM-DD&to=YYYY-MM-DD&type=pricing|benchmarks`.
56+
- [Premium Watches](https://tensorfeed.ai/api/premium/watches): Tier 1, 1 credit per registration. Webhook alerts for price changes (`{ type: "price", model, field: "inputPrice"|"outputPrice"|"blended", op: "lt"|"gt"|"changes", threshold? }`) or service status transitions (`{ type: "status", provider, op: "becomes"|"changes", value? }`). POST `{ spec, callback_url, secret? }` to register, GET to list, GET/DELETE on `/api/premium/watches/{id}` for individual control. Each watch lives 90 days, fires up to 100 times by default, delivers a signed POST to the callback URL with `X-TensorFeed-Signature: sha256=<hex>` and an `X-TensorFeed-Watch-Id` header. Listing and per-watch read/delete require the bearer token but cost no credits.
5657

5758
**Recommended flow:** POST `/api/payment/buy-credits`, send USDC, POST `/api/payment/confirm`, then use the returned token for all premium calls. **Fallback (x402):** call any `/api/premium/*` endpoint without auth to receive a 402 response with payment instructions; send USDC and retry with `X-Payment-Tx: <txHash>` header.
5859

src/app/developers/agent-payments/page.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,32 @@ const ENDPOINTS: PremiumEndpoint[] = [
226226
{ "model": "GPT-5.5", "field": "inputPrice", "from": 12, "to": 10, "delta_pct": -16.67 }
227227
],
228228
"unchanged_count": 8
229+
}`,
230+
},
231+
{
232+
method: 'POST',
233+
path: '/api/premium/watches',
234+
description:
235+
'Register a webhook watch on a price change or service status transition. Each watch lives 90 days and fires up to 100 times by default. Deliveries POST to your callback URL with an HMAC-SHA256 signature header (X-TensorFeed-Signature: sha256=...). Per-token cap of 25 active watches. Listing and per-watch read/delete are free for the owning bearer token.',
236+
cost: '1 credit per registration',
237+
example: `// Body: { spec, callback_url, secret?, fire_cap? }
238+
// Spec types:
239+
// { type: "price", model, field: "inputPrice"|"outputPrice"|"blended",
240+
// op: "lt"|"gt"|"changes", threshold? }
241+
// { type: "status", provider, op: "becomes"|"changes",
242+
// value?: "operational"|"degraded"|"down" }
243+
{
244+
"ok": true,
245+
"watch": {
246+
"id": "wat_a1b2c3d4e5f60718a9b0c1d2",
247+
"spec": { "type": "price", "model": "Claude Opus 4.7",
248+
"field": "blended", "op": "lt", "threshold": 30 },
249+
"callback_url": "https://agent.example.com/hook",
250+
"created": "2026-04-27T18:00:00Z",
251+
"expires_at": "2026-07-26T18:00:00Z",
252+
"fire_count": 0, "fire_cap": 100, "status": "active"
253+
},
254+
"billing": { "credits_charged": 1, "credits_remaining": 49 }
229255
}`,
230256
},
231257
];

worker/src/index.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ import {
1717
MAX_RANGE_DAYS,
1818
DEFAULT_RANGE_DAYS,
1919
} from './history-series';
20+
import {
21+
createWatch,
22+
getWatch,
23+
listWatchesForToken,
24+
deleteWatch,
25+
runPriceWatchCycle,
26+
} from './watches';
2027
import { computeRouting, checkRoutingPreviewRateLimit, hoursUntilUTCRollover, RoutingTask } from './routing';
2128
import {
2229
requirePayment,
@@ -471,6 +478,9 @@ export default {
471478
premiumBenchmarkSeries: '/api/premium/history/benchmarks/series?model=&benchmark=&from=&to=',
472479
premiumStatusUptime: '/api/premium/history/status/uptime?provider=&from=&to=',
473480
premiumHistoryCompare: '/api/premium/history/compare?from=&to=&type=pricing|benchmarks',
481+
premiumWatchesCreate: 'POST /api/premium/watches (1 credit per registration)',
482+
premiumWatchesList: 'GET /api/premium/watches',
483+
premiumWatchesItem: 'GET|DELETE /api/premium/watches/{id}',
474484
paymentInfo: '/api/payment/info',
475485
paymentBuyCredits: '/api/payment/buy-credits',
476486
paymentConfirm: '/api/payment/confirm',
@@ -856,6 +866,80 @@ export default {
856866
return premiumResponse(result, payment, 1);
857867
}
858868

869+
// === PAID PREMIUM: WATCHES (webhook alerts) ===
870+
// Registration costs 1 credit. Read/list/delete are free for the
871+
// bearer token that owns the watch (no charge, but token required).
872+
873+
if (path === '/api/premium/watches' && request.method === 'POST') {
874+
const payment = await requirePayment(request, env, 1);
875+
if (!payment.paid) return payment.response!;
876+
if (!payment.token) {
877+
return jsonResponse(
878+
{ ok: false, error: 'token_required', message: 'Watch creation requires a bearer token (use /api/payment/buy-credits).' },
879+
401,
880+
);
881+
}
882+
let body: { spec?: unknown; callback_url?: string; secret?: string; fire_cap?: number };
883+
try {
884+
body = await request.json();
885+
} catch {
886+
return jsonResponse({ ok: false, error: 'invalid_json' }, 400);
887+
}
888+
if (typeof body.callback_url !== 'string') {
889+
return jsonResponse({ ok: false, error: 'callback_url_required' }, 400);
890+
}
891+
const result = await createWatch(env, payment.token, {
892+
spec: body.spec as never,
893+
callback_url: body.callback_url,
894+
...(typeof body.secret === 'string' ? { secret: body.secret } : {}),
895+
...(typeof body.fire_cap === 'number' ? { fire_cap: body.fire_cap } : {}),
896+
});
897+
if (!result.ok) {
898+
return jsonResponse(result, 400);
899+
}
900+
ctx.waitUntil(
901+
logPremiumUsage(env, '/api/premium/watches', request.headers.get('User-Agent') || 'unknown', 1),
902+
);
903+
return premiumResponse(result, payment, 1);
904+
}
905+
906+
if (path === '/api/premium/watches' && request.method === 'GET') {
907+
// Listing requires a bearer token but no credits.
908+
const authHeader = request.headers.get('Authorization');
909+
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
910+
if (!token || !token.startsWith('tf_live_')) {
911+
return jsonResponse({ ok: false, error: 'token_required' }, 401);
912+
}
913+
const watches = await listWatchesForToken(env, token);
914+
return jsonResponse({ ok: true, count: watches.length, watches }, 200, 0);
915+
}
916+
917+
const watchMatch = path.match(/^\/api\/premium\/watches\/(wat_[a-f0-9]{24})$/);
918+
if (watchMatch) {
919+
const id = watchMatch[1];
920+
const authHeader = request.headers.get('Authorization');
921+
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
922+
if (!token || !token.startsWith('tf_live_')) {
923+
return jsonResponse({ ok: false, error: 'token_required' }, 401);
924+
}
925+
if (request.method === 'GET') {
926+
const watch = await getWatch(env, id);
927+
if (!watch) return jsonResponse({ ok: false, error: 'watch_not_found' }, 404);
928+
if (watch.token !== token) return jsonResponse({ ok: false, error: 'forbidden' }, 403);
929+
return jsonResponse({ ok: true, watch }, 200, 0);
930+
}
931+
if (request.method === 'DELETE') {
932+
const result = await deleteWatch(env, id, token);
933+
if (!result.ok) {
934+
return jsonResponse(
935+
result,
936+
result.error === 'watch_not_found' ? 404 : result.error === 'forbidden' ? 403 : 400,
937+
);
938+
}
939+
return jsonResponse({ ok: true }, 200, 0);
940+
}
941+
}
942+
859943
// === ADMIN: usage and revenue rollup (auth-gated) ===
860944
// Same key pattern as /api/refresh: ?key=<ENVIRONMENT>. At MVP scale
861945
// this is sufficient; if/when revenue is real, swap to a dedicated
@@ -1029,6 +1113,9 @@ export default {
10291113
// pricing, models, benchmarks, status, agent activity. Runs after
10301114
// updateDailyData so the snapshot reflects freshly-updated data.
10311115
await run('captureHistory', () => captureHistory(env));
1116+
// Premium webhook watches: fire price-change webhooks based on the
1117+
// diff between the last seen pricing and the freshly-updated payload.
1118+
await run('runPriceWatchCycle', () => runPriceWatchCycle(env));
10321119
// X/Twitter: 1 post/day, re-enabled 2026-04-12 after spam flag on 2026-04-04.
10331120
// Hold at 1/day until 2026-05-04; do not increase cadence without 30 clean days.
10341121
} else if (cron === '30 14 * * *') {

worker/src/status.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Env, ServiceStatus, StatusPageResponse } from './types';
22
import { STATUS_PAGES } from './sources';
3+
import { dispatchStatusWatches, StatusTransition } from './watches';
34

45
function normalizeStatus(indicator: string): 'operational' | 'degraded' | 'down' | 'unknown' {
56
switch (indicator?.toLowerCase()) {
@@ -142,6 +143,7 @@ export async function pollStatusPages(env: Env): Promise<void> {
142143
const previousRaw = await env.TENSORFEED_STATUS.get('previous-status', 'json') as
143144
{ name: string; status: string; provider: string }[] | null;
144145

146+
const watchTransitions: StatusTransition[] = [];
145147
if (previousRaw) {
146148
const prevMap = new Map(previousRaw.map(s => [s.name, s]));
147149
const incidentsRaw = await env.TENSORFEED_STATUS.get('incidents', 'json') as Incident[] | null;
@@ -158,6 +160,15 @@ export async function pollStatusPages(env: Env): Promise<void> {
158160
if (prevStatus === curStatus) continue;
159161
if (curStatus === 'unknown' || prevStatus === 'unknown') continue;
160162

163+
// Collect for premium watch dispatch (fires only on real transitions,
164+
// matching the same edge filter the incident detector uses).
165+
watchTransitions.push({
166+
provider: current.provider,
167+
name: current.name,
168+
from: prevStatus as StatusTransition['from'],
169+
to: curStatus as StatusTransition['to'],
170+
});
171+
161172
// Service went from operational to degraded/down
162173
if (prevStatus === 'operational' && (curStatus === 'degraded' || curStatus === 'down')) {
163174
const incident: Incident = {
@@ -203,6 +214,20 @@ export async function pollStatusPages(env: Env): Promise<void> {
203214
const down = statuses.filter(s => s.status === 'down').length;
204215

205216
console.log(`Status poll complete - ${operational} operational, ${degraded} degraded, ${down} down`);
217+
218+
// Premium watch dispatch (no-op when no watches are subscribed)
219+
if (watchTransitions.length > 0) {
220+
try {
221+
const summary = await dispatchStatusWatches(env, watchTransitions);
222+
if (summary.watches_fired > 0) {
223+
console.log(
224+
`status watches fired: ${summary.watches_fired} of ${summary.watches_evaluated} (failures: ${summary.delivery_failures})`,
225+
);
226+
}
227+
} catch (e) {
228+
console.error('dispatchStatusWatches failed:', e instanceof Error ? e.message : e);
229+
}
230+
}
206231
}
207232

208233
export interface Incident {

0 commit comments

Comments
 (0)