API Reference
The weNow backend runs on Cloudflare Workers and exposes the endpoints listed below.
All endpoints return JSON. The base URL for production is
https://wenow-api.<account>.workers.dev.
Endpoint Overview
| Method | Path | Description | Rate Limit (per IP/day) |
|---|---|---|---|
POST |
/api/weather |
Fetch weather from all active sources | 5 (currently elevated for development) |
POST |
/api/weather/widget |
Fetch weather for Android widgets | 100 |
GET |
/api/weather/status |
Health check and usage info | None |
GET |
/api/geocode |
City search autocomplete | 200 |
GET |
/api/geocode/reverse |
Reverse geocode (coordinates to city) | 200 (shared with geocode) |
GET |
/api/accuracy |
Forecast accuracy report for a location | 20 |
GET |
/api/ads |
Fetch one self-served ad | 200 |
POST |
/api/ads/click |
Report an ad click (aggregate counter) | None |
POST |
/api/feedback |
Submit anonymous feedback | 5 |
POST |
/api/auth/register |
Create a new account | 10 |
POST |
/api/auth/login |
Authenticate and receive a JWT | 30 |
GET |
/api/auth/me |
Get current user (requires JWT) | None |
POST |
/api/users |
Register a RevenueCat user | 10 |
POST |
/api/webhooks/revenuecat |
RevenueCat subscription webhook | None (authenticated by Bearer token) |
POST /api/weather
Main weather endpoint. Fans out to all active weather API sources in parallel, normalizes responses, computes a weighted ensemble median, and returns everything to the client.
Request Body
{
"lat": 48.8566, // Required (unless "location" is provided)
"lon": 2.3522, // Required (unless "location" is provided)
"name": "Paris", // Optional display name (max 200 chars)
"location": "Paris", // Alternative: city name string (backend geocodes it)
"lang": "en", // Optional, default "en" (max 5 chars)
"includeHourly": false // Optional, include hourly forecast data
}
Provide either lat/lon or location. If
location is provided, the backend geocodes it to coordinates using
the first search result.
Response (200)
{
"data": [
{
"source": "Open-Meteo",
"temperature": 18.5,
"feelsLike": 17.2,
"humidity": 62,
"pressure": 1013,
"windSpeed": 3.4,
"windDirection": 220,
"description": "Partly cloudy",
"icon": "partly_cloudy",
"timestamp": "2026-04-06T12:00:00Z",
"location": {
"city": "Paris",
"country": "FR",
"lat": 48.8566,
"lon": 2.3522
},
"forecast": [
{
"date": "2026-04-07",
"highTemp": 20.1,
"lowTemp": 11.3,
"description": "Sunny",
"precipChance": 5
}
],
"hourlyForecast": [
{
"datetime": "2026-04-06T14:00:00Z",
"temperature": 19.1,
"feelsLike": 18.0,
"humidity": 58,
"windSpeed": 3.6,
"windDirection": 215,
"description": "Partly cloudy",
"precipChance": 10
}
]
}
// ... additional sources
],
"ensemble": {
"temperature": 18.3,
"feelsLike": 17.0,
"humidity": 60,
"pressure": 1013,
"windSpeed": 3.5,
"description": "Partly cloudy",
"confidence": {
"level": "high",
"range": 1.2,
"sourceCount": 4,
"totalSources": 4,
"outliers": []
},
"forecast": [],
"hourlyForecast": []
},
"errors": [
{
"source": "WeatherBit",
"message": "Failed to fetch data"
}
],
"skipped": ["AccuWeather"],
"rateLimit": {
"remaining": 4,
"limit": 5,
"used": 1
}
}
Error Responses
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "Invalid JSON"} | Malformed request body |
| 400 | {"error": "Invalid coordinates"} | lat/lon out of range |
| 400 | {"error": "lat/lon or location required"} | Missing both coordinate and location fields |
| 404 | {"error": "Location not found"} | Geocode returned no results for location string |
| 413 | {"error": "Request too large"} | Body exceeds 4096 bytes |
| 429 | {"error": "Daily limit reached", ...} | Rate limit exceeded (5/day) |
POST /api/weather/widget
Dedicated endpoint for Android home screen widgets. Identical request/response
format to /api/weather, but with a separate, higher rate limit of
100 requests per day per IP. This separation ensures widget
background refreshes (every 30 minutes) do not consume the app's daily quota.
Request Body
Same format as POST /api/weather.
Response
Same format as POST /api/weather. The rateLimit object
reflects the widget quota (limit: 100) rather than the app quota.
GET /api/weather/status
Returns the caller's current rate limit usage for the weather endpoint. Does not consume a rate limit token. Useful for displaying remaining requests in the UI.
Response (200)
{
"remaining": 4,
"limit": 5,
"used": 1
}
GET /api/geocode
City search autocomplete. Returns an array of matching cities with coordinates.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
q |
string | Yes | Search query (min 2 chars, max 200 chars) |
Response (200)
[
{
"name": "Paris",
"state": "Ile-de-France",
"country": "FR",
"lat": 48.8566,
"lon": 2.3522
},
{
"name": "Paris",
"state": "Texas",
"country": "US",
"lat": 33.6609,
"lon": -95.5555
}
]
Error Responses
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "q param required (min 2 chars)"} | Missing or too-short query |
| 400 | {"error": "q too long"} | Query exceeds 200 characters |
| 429 | {"error": "Rate limit exceeded"} | Geocode limit exceeded (200/day) |
GET /api/geocode/reverse
Reverse geocoding: converts latitude/longitude coordinates to a city name. Used when the user taps "Use My Location" to resolve GPS coordinates to a display name.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
lat | number | Yes | Latitude (-90 to 90) |
lon | number | Yes | Longitude (-180 to 180) |
Response (200)
{
"name": "Paris",
"state": "Ile-de-France",
"country": "FR",
"lat": 48.8566,
"lon": 2.3522
}
Error Responses
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "lat and lon required"} | Missing or non-numeric coordinates |
| 404 | null | No city found for the given coordinates |
| 429 | {"error": "Rate limit exceeded"} | Shared geocode limit exceeded (200/day) |
GET /api/accuracy
Returns a forecast accuracy leaderboard for a tracked location. The backend automatically snapshots forecasts and scores them against observed data daily at 08:00 UTC.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
lat | number | Yes | Latitude |
lon | number | Yes | Longitude |
days | number | No | Lookback period, 1-30 (default: 7) |
Response (200)
{
"location": {"lat": 48.86, "lon": 2.35, "name": "Paris"},
"period": {"days": 7, "from": "2026-03-30", "to": "2026-04-06"},
"leaderboard": [
{
"rank": 1,
"source": "Open-Meteo",
"avgHighError": 0.8,
"avgLowError": 0.6,
"compositeScore": 0.7,
"daysTracked": 7,
"trend": "stable"
}
],
"dataAvailable": true,
"trackedSince": "2026-03-01T00:00:00Z"
}
Error Responses
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "lat and lon required"} | Missing or non-numeric coordinates |
| 404 | {"error": "Location not tracked yet"} | No accuracy data for this location |
| 429 | {"error": "Rate limit exceeded"} | Accuracy limit exceeded (20/day) |
GET /api/ads
Returns one weighted-random active ad from the ads table, or {ad: null} if the inventory is empty. Ads are first-party, self-served, and contain no third-party tracking code. An impression counter is incremented in D1 — aggregate only, not linked to any user or device.
Response (200)
{
"ad": {
"id": 1,
"title": "weNow Premium — Coming Soon",
"body": "Support the project. More sources, higher accuracy, no ads.",
"imageUrl": null,
"clickUrl": "https://wenow.name/#pricing",
"kind": "cross-promo"
}
}
When inventory is empty or all rows have active = 0:
{"ad": null}
| Field | Type | Description |
|---|---|---|
id | number | Stable ID used for click tracking |
title | string | Short headline (main text) |
body | string | null | Optional subheadline |
imageUrl | string | null | Optional 72x72 image URL |
clickUrl | string | Destination URL (opened in system browser) |
kind | "affiliate" | "sponsor" | "cross-promo" | Editorial classification; not shown to user |
POST /api/ads/click
Increments the click counter for an ad. Best-effort, fire-and-forget — the app does not block navigation on this request. No rate limit; the counter update is a single bounded write and no personal data is accepted.
Request Body
{"id": 1}
Response (200)
{"ok": true}
Error Responses
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "Invalid JSON"} | Malformed body |
| 400 | {"error": "id required"} | Missing or non-numeric id |
POST /api/feedback
Submits anonymous user feedback. Messages are stored in the D1 database and emailed in a digest every 2 hours via a scheduled cron task.
Request Body
{
"message": "The app is great but...", // Required, max 2000 chars
"lang": "en", // Optional, default "en"
"app_version": "1.2.0" // Optional
}
Response (200)
{"ok": true}
Error Responses
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "Invalid JSON"} | Malformed body |
| 400 | {"error": "Message required (max 2000 chars)"} | Empty or oversized message |
| 429 | {"error": "Rate limit exceeded"} | Feedback limit exceeded (5/day) |
POST /api/auth/register
Creates a new user account with email and password. Returns a JWT token valid for 30 days.
Request Body
{
"email": "user@example.com", // Required, valid email format
"password": "securepass123" // Required, 8-128 characters
}
Response (201)
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": 42,
"email": "user@example.com"
}
}
Error Responses
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "Email and password required"} | Missing fields |
| 400 | {"error": "Invalid email"} | Malformed email address |
| 400 | {"error": "Password must be at least 8 characters"} | Password too short |
| 400 | {"error": "Password too long"} | Password exceeds 128 characters |
| 409 | {"error": "Email already registered"} | Duplicate email |
| 429 | {"error": "Rate limit exceeded"} | Registration limit exceeded (10/day) |
POST /api/auth/login
Authenticates with email and password. Returns a JWT token valid for 30 days.
Request Body
{
"email": "user@example.com",
"password": "securepass123"
}
Response (200)
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": 42,
"email": "user@example.com"
}
}
Error Responses
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "Email and password required"} | Missing fields |
| 401 | {"error": "Invalid email or password"} | Wrong credentials |
| 429 | {"error": "Rate limit exceeded"} | Login limit exceeded (30/day) |
GET /api/auth/me
Returns the currently authenticated user. Requires a valid JWT in the
Authorization header.
Request Headers
Authorization: Bearer <jwt-token>
Response (200)
{
"user": {
"id": 42,
"email": "user@example.com"
}
}
Error Responses
| Status | Body | Cause |
|---|---|---|
| 401 | {"error": "Unauthorized"} | Missing Authorization header |
| 401 | {"error": "Invalid or expired token"} | JWT verification failed |
POST /api/users
Registers a RevenueCat user ID. Insert-only — this endpoint cannot set or override subscription status (defaults to "free"). Only the RevenueCat webhook can change subscription status.
Request Body
{
"revenuecatId": "rc_abc123", // Required, max 256 chars
"platform": "android" // Required, "ios" or "android"
}
Response (200)
{"ok": true}
Error Responses
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "Invalid JSON"} | Malformed body |
| 400 | {"error": "revenuecatId required"} | Missing or invalid ID |
| 400 | {"error": "Invalid platform"} | Platform not "ios" or "android" |
| 429 | {"error": "Rate limit exceeded"} | User limit exceeded (10/day) |
POST /api/webhooks/revenuecat
Receives subscription lifecycle events from RevenueCat. Authenticated via a Bearer token that is compared using constant-time comparison to prevent timing attacks.
Authentication
Authorization: Bearer <REVENUECAT_WEBHOOK_SECRET>
Handled Event Types
| Event Type | Resulting Status |
|---|---|
INITIAL_PURCHASE | active |
RENEWAL | active |
UNCANCELLATION | active |
CANCELLATION | expired |
EXPIRATION | expired |
BILLING_ISSUE | billing_issue |
Unrecognized event types are acknowledged with {"ok": true, "message": "ignored"}.
Error Responses
| Status | Body | Cause |
|---|---|---|
| 400 | {"error": "Invalid JSON"} | Malformed body |
| 400 | {"error": "Invalid webhook payload"} | Missing event fields |
| 401 | {"error": "Unauthorized"} | Invalid or missing Bearer token |
Rate Limiting
All public endpoints are rate-limited per IP address. IP addresses are hashed with SHA-256 before storage to comply with GDPR — no raw IP addresses are persisted. Rate limit counters reset daily. Old records are purged by a scheduled cron job at 01:00 UTC.
| Category | Endpoints | Daily Limit |
|---|---|---|
| App weather | /api/weather | 5 (currently elevated for development) |
| Widget weather | /api/weather/widget | 100 |
| Geocoding | /api/geocode, /api/geocode/reverse | 200 (shared) |
| Ads fetch | /api/ads | 200 |
| Ads click | /api/ads/click | None (aggregate counter) |
| Accuracy | /api/accuracy | 20 |
| Feedback | /api/feedback | 5 |
| Auth register | /api/auth/register | 10 |
| Auth login | /api/auth/login | 30 |
| User registration | /api/users | 10 |
When a rate limit is exceeded, the endpoint returns HTTP 429 with
{"error": "Rate limit exceeded"} (or "Daily limit reached"
for the weather endpoint, which also includes limit, used,
and remaining fields).
CORS Policy
Cross-origin requests are restricted to the following origins:
https://wenow.appandhttps://wenow.namehttps://*.wenow.pages.devandhttps://*.wenow-name.pages.devhttp://localhostandhttp://localhost:*(development only)
Allowed methods: GET, POST, OPTIONS.
Allowed headers: Content-Type.
Preflight responses are cached for 86400 seconds (24 hours).
Authentication
User endpoints (/api/auth/register, /api/auth/login)
return JWT tokens valid for 30 days. Include the token in subsequent requests:
Authorization: Bearer <jwt-token>
The /api/auth/me endpoint validates the token and returns the current
user. If the token is expired or invalid, a 401 response is returned.
The RevenueCat webhook uses a separate authentication mechanism: a shared secret passed as a Bearer token, validated with constant-time comparison to prevent timing attacks.
Global Error Handling
All request bodies are limited to 4096 bytes. Requests exceeding this limit
receive a 413 Request Too Large response. Unmatched routes return
404 Not Found. Unhandled exceptions return
500 Internal Server Error.