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

StatusBodyCause
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

ParameterTypeRequiredDescription
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

StatusBodyCause
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

ParameterTypeRequiredDescription
latnumberYesLatitude (-90 to 90)
lonnumberYesLongitude (-180 to 180)

Response (200)

{
  "name": "Paris",
  "state": "Ile-de-France",
  "country": "FR",
  "lat": 48.8566,
  "lon": 2.3522
}

Error Responses

StatusBodyCause
400{"error": "lat and lon required"}Missing or non-numeric coordinates
404nullNo 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

ParameterTypeRequiredDescription
latnumberYesLatitude
lonnumberYesLongitude
daysnumberNoLookback 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

StatusBodyCause
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}
FieldTypeDescription
idnumberStable ID used for click tracking
titlestringShort headline (main text)
bodystring | nullOptional subheadline
imageUrlstring | nullOptional 72x72 image URL
clickUrlstringDestination 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

StatusBodyCause
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

StatusBodyCause
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

StatusBodyCause
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

StatusBodyCause
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

StatusBodyCause
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

StatusBodyCause
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 TypeResulting Status
INITIAL_PURCHASEactive
RENEWALactive
UNCANCELLATIONactive
CANCELLATIONexpired
EXPIRATIONexpired
BILLING_ISSUEbilling_issue

Unrecognized event types are acknowledged with {"ok": true, "message": "ignored"}.

Error Responses

StatusBodyCause
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.

CategoryEndpointsDaily Limit
App weather/api/weather5 (currently elevated for development)
Widget weather/api/weather/widget100
Geocoding/api/geocode, /api/geocode/reverse200 (shared)
Ads fetch/api/ads200
Ads click/api/ads/clickNone (aggregate counter)
Accuracy/api/accuracy20
Feedback/api/feedback5
Auth register/api/auth/register10
Auth login/api/auth/login30
User registration/api/users10

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:

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.