WildMap API
Programmatic access to real-time environmental sensor data from deployed weather stations.
Built by Bonny Doon Labs
The WildMap API lets you query historical readings, stream live sensor data, manage alert thresholds, receive webhook notifications, and access shared data from partner organizations.
/api-docs on your WildMap instance for a live Swagger UI. Append ?format=json for the raw OpenAPI 3.0.3 spec.
Quick Start
- Get an account — Register in the WildMap app and join or create an organization.
- Create an API key — Go to Settings → API Keys → Create. Select the scopes you need.
- Store the key securely — The plaintext key is shown once and cannot be retrieved again.
- Make your first request:
curl "https://<project>.supabase.co/functions/v1/api-readings?limit=1" \ -H "Authorization: Bearer wf_your_key_here"import requests resp = requests.get( "https://<project>.supabase.co/functions/v1/api-readings", headers={"Authorization": "Bearer wf_your_key_here"}, params={"limit": 1} ) print(resp.json())const resp = await fetch( "https://<project>.supabase.co/functions/v1/api-readings?limit=1", { headers: { Authorization: "Bearer wf_your_key_here" } } ); const data = await resp.json(); console.log(data);req, _ := http.NewRequest("GET", "https://<project>.supabase.co/functions/v1/api-readings?limit=1", nil) req.Header.Set("Authorization", "Bearer wf_your_key_here") resp, _ := http.DefaultClient.Do(req) body, _ := io.ReadAll(resp.Body) fmt.Println(string(body))require "net/http" require "json" uri = URI("https://<project>.supabase.co/functions/v1/api-readings?limit=1") req = Net::HTTP::Get.new(uri) req["Authorization"] = "Bearer wf_your_key_here" resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) } puts JSON.parse(resp.body) - Set up streaming or webhooks for real-time data, or poll the readings endpoint on a schedule.
Authentication
All data endpoints use API key authentication. Keys are scoped to an organization and carry fine-grained permissions.
Using Your Key
Include your key in every request using either header format:
Authorization: Bearer wf_a1b2c3d4e5f6...
# or
X-API-Key: wf_a1b2c3d4e5f6...
Creating a Key via API
curl -X POST https://<project>.supabase.co/functions/v1/api-keys \
-H "Authorization: Bearer <supabase_jwt>" \
-H "Content-Type: application/json" \
-d '{
"orgId": "your-org-uuid",
"name": "Production Integration",
"scopes": ["readings:read", "devices:read", "stream"],
"expiresAt": "2027-01-01T00:00:00Z"
}'import requests
resp = requests.post(
"https://<project>.supabase.co/functions/v1/api-keys",
headers={
"Authorization": "Bearer <supabase_jwt>",
"Content-Type": "application/json",
},
json={
"orgId": "your-org-uuid",
"name": "Production Integration",
"scopes": ["readings:read", "devices:read", "stream"],
"expiresAt": "2027-01-01T00:00:00Z",
}
)
api_key = resp.json()["key"] # Store this securely — shown once only
print(f"API Key: {api_key}")const resp = await fetch(
"https://<project>.supabase.co/functions/v1/api-keys",
{
method: "POST",
headers: {
Authorization: "Bearer <supabase_jwt>",
"Content-Type": "application/json",
},
body: JSON.stringify({
orgId: "your-org-uuid",
name: "Production Integration",
scopes: ["readings:read", "devices:read", "stream"],
expiresAt: "2027-01-01T00:00:00Z",
}),
}
);
const { key } = await resp.json();
console.log(`API Key: ${key}`); // Store securely — shown once onlybody := strings.NewReader(`{"orgId":"your-org-uuid","name":"Production Integration","scopes":["readings:read","devices:read","stream"],"expiresAt":"2027-01-01T00:00:00Z"}`)
req, _ := http.NewRequest("POST", "https://<project>.supabase.co/functions/v1/api-keys", body)
req.Header.Set("Authorization", "Bearer <supabase_jwt>")
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
// Parse resp.Body for the "key" field — store securelyuri = URI("https://<project>.supabase.co/functions/v1/api-keys")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req["Authorization"] = "Bearer <supabase_jwt>"
req.body = { orgId: "your-org-uuid", name: "Production Integration",
scopes: ["readings:read", "devices:read", "stream"],
expiresAt: "2027-01-01T00:00:00Z" }.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
api_key = JSON.parse(resp.body)["key"] # Store securelyKey Security
- Keys use the format
wf_+ 40 hex characters - Only the SHA-256 hash is stored — WildMap never stores plaintext keys
- Keys support optional expiration dates
- Revoked keys return
401 Unauthorizedimmediately last_used_atis updated on each request for audit visibility
Scopes
| Scope | Access |
|---|---|
readings:read | Query historical sensor readings |
devices:read | List devices and their metadata |
stream | Subscribe to real-time SSE data stream |
alerts:read | Query alert history and current thresholds |
alerts:write | Update alert threshold configuration |
org:read | Query organization info, members, and device stats |
webhooks:manage | Register, list, and delete webhooks |
Endpoints
Sensor Readings
GET /api-readings readings:read
Query historical sensor readings with filtering, pagination, and optional cross-org data.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
start | ISO 8601 | — | Filter readings after this time |
end | ISO 8601 | — | Filter readings before this time |
device_id | UUID | — | Filter by device ID |
device_serial | string | — | Filter by serial (e.g., WX-000001) |
include_shared | boolean | false | Include data from partner organizations |
order | asc / desc | desc | Sort by timestamp |
limit | 1–1000 | 100 | Results per page |
offset | integer | 0 | Pagination offset |
Example
curl "https://<project>.supabase.co/functions/v1/api-readings?\
start=2026-03-01T00:00:00Z&limit=10&order=desc" \
-H "Authorization: Bearer wf_your_key_here"resp = requests.get(
f"{BASE}/api-readings",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"start": "2026-03-01T00:00:00Z", "limit": 10, "order": "desc"}
)
for r in resp.json()["data"]:
s = r["sensors"]
print(f"{r['device']['serial']}: {s['temp_c']}°C, {s['wind_mps']} m/s")const url = new URL(`${BASE}/api-readings`);
url.searchParams.set("start", "2026-03-01T00:00:00Z");
url.searchParams.set("limit", "10");
url.searchParams.set("order", "desc");
const resp = await fetch(url, {
headers: { Authorization: `Bearer ${API_KEY}` }
});
const { data } = await resp.json();
data.forEach(r =>
console.log(`${r.device.serial}: ${r.sensors.temp_c}°C, ${r.sensors.wind_mps} m/s`)
);req, _ := http.NewRequest("GET",
BASE+"/api-readings?start=2026-03-01T00:00:00Z&limit=10&order=desc", nil)
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var result struct { Data []map[string]any }
json.NewDecoder(resp.Body).Decode(&result)
for _, r := range result.Data {
fmt.Printf("%v: %v°C\n", r["device"], r["sensors"])
}uri = URI("#{BASE}/api-readings?start=2026-03-01T00:00:00Z&limit=10&order=desc")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{API_KEY}"
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(resp.body)["data"].each do |r|
s = r["sensors"]
puts "#{r['device']['serial']}: #{s['temp_c']}°C, #{s['wind_mps']} m/s"
endResponse
{
"data": [{
"id": "reading-uuid", "time": "2026-03-20T14:30:00Z",
"device": { "id": "device-uuid", "serial": "WX-000001", "label": "North Ridge Station" },
"location": { "lat": 34.0522, "lon": -118.2437, "alt_m": 285.0 },
"sensors": { "wind_mps": 4.7, "wind_dir_deg": 225, "temp_c": 28.3, "rh_pct": 35.0, "dewpoint_c": 11.8, "pm2_5": 18.5 },
"org_id": "org-uuid"
}],
"meta": { "count": 1, "limit": 100, "offset": 0, "has_more": true }
}
Sensor Fields
| Field | Unit | Description |
|---|---|---|
wind_mps | m/s | Wind speed |
wind_dir_deg | degrees (0–360) | Wind direction (0 = North) |
temp_c | °C | Temperature |
rh_pct | % | Relative humidity |
dewpoint_c | °C | Dewpoint (derived from temp + humidity) |
pm1_0 / pm2_5 / pm10 | μg/m³ | Particulate matter |
Devices
GET /api-devices devices:read
List weather stations registered to your organization.
// Response
{ "data": [{ "id": "uuid", "serial": "WX-000001", "label": "North Ridge",
"status": "active", "firmware_version": "1.2.0", "last_seen_at": "2026-03-20T14:30:00Z" }] }
Real-Time Stream (SSE)
GET /api-stream stream
Subscribe to a Server-Sent Events stream for live readings as they arrive.
| Parameter | Description |
|---|---|
device_id | Comma-separated UUIDs to filter specific devices |
include_shared | Include data from partner organizations |
curl -N "https://<project>.supabase.co/functions/v1/api-stream" \
-H "Authorization: Bearer wf_your_key_here"
# Output:
# event: reading
# data: {"id":"...","device":{...},"sensors":{"temp_c":28.3,...}}
# id: reading-uuidimport requests, json
with requests.get(
f"{BASE}/api-stream",
headers={"Authorization": f"Bearer {API_KEY}"},
stream=True
) as r:
for line in r.iter_lines(decode_unicode=True):
if line.startswith("data: "):
reading = json.loads(line[6:])
s = reading["sensors"]
print(f"{reading['device']['serial']}: {s['temp_c']}°C")const EventSource = require("eventsource");
const es = new EventSource(
`${BASE}/api-stream`,
{ headers: { Authorization: `Bearer ${API_KEY}` } }
);
es.addEventListener("reading", (event) => {
const r = JSON.parse(event.data);
console.log(`${r.device.serial}: ${r.sensors.temp_c}°C`);
});// Use github.com/r3labs/sse/v2
client := sse.NewClient(BASE + "/api-stream")
client.Headers["Authorization"] = "Bearer " + apiKey
client.SubscribeRaw(func(msg *sse.Event) {
if string(msg.Event) == "reading" {
fmt.Println(string(msg.Data))
}
})require "net/http"
require "json"
uri = URI("#{BASE}/api-stream")
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{API_KEY}"
http.request(req) do |resp|
resp.read_body do |chunk|
chunk.each_line do |line|
if line.start_with?("data: ")
puts JSON.parse(line[6..])
end
end
end
end
end- Keepalive comments sent every 30 seconds
- On reconnect, pass
Last-Event-IDheader to replay missed readings
Alert History
GET /api-alert-history alerts:read
Query triggered alerts filtered by device, severity, type, and time range.
| Parameter | Type | Description |
|---|---|---|
device_id | UUID | Filter by device |
severity | warning / critical / emergency | Filter by severity |
alert_type | string | e.g., wind_direction_shift |
start / end | ISO 8601 | Time range |
include_shared | boolean | Include partner org alerts |
limit / offset | integer | Pagination |
Alert Thresholds
GET /api-alert-thresholds alerts:read
PUT /api-alert-thresholds alerts:write
Read or update your organization's alert threshold configuration.
# Update a threshold
curl -X PUT "https://<project>.supabase.co/functions/v1/api-alert-thresholds" \
-H "Authorization: Bearer wf_your_key_here" \
-H "Content-Type: application/json" \
-d '{"parameter_path": "wind.speed.increaseMs", "value": 0.6}'requests.put(
f"{BASE}/api-alert-thresholds",
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
json={"parameter_path": "wind.speed.increaseMs", "value": 0.6}
)await fetch(`${BASE}/api-alert-thresholds`, {
method: "PUT",
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ parameter_path: "wind.speed.increaseMs", value: 0.6 })
});body := strings.NewReader(`{"parameter_path":"wind.speed.increaseMs","value":0.6}`)
req, _ := http.NewRequest("PUT", BASE+"/api-alert-thresholds", body)
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)uri = URI("#{BASE}/api-alert-thresholds")
req = Net::HTTP::Put.new(uri, "Content-Type" => "application/json")
req["Authorization"] = "Bearer #{API_KEY}"
req.body = { parameter_path: "wind.speed.increaseMs", value: 0.6 }.to_json
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }Organizations
GET /api-orgs org:read
Query organization info including member counts, device counts, and reading totals. Pass include_shared=true to list orgs sharing data with yours.
Webhooks
Receive push notifications to your server when events occur, instead of polling.
POST /api-webhooks webhooks:manage
GET /api-webhooks webhooks:manage
DELETE /api-webhooks webhooks:manage
Register a Webhook
curl -X POST "https://<project>.supabase.co/functions/v1/api-webhooks" \
-H "Authorization: Bearer wf_your_key_here" \
-H "Content-Type: application/json" \
-d '{"url": "https://your-server.com/wildmap-webhook", "events": ["new_reading", "alert_triggered"]}'resp = requests.post(
f"{BASE}/api-webhooks",
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
json={"url": "https://your-server.com/wildmap-webhook",
"events": ["new_reading", "alert_triggered"]}
)
secret = resp.json()["data"]["secret"] # Store this — shown once onlyconst resp = await fetch(`${BASE}/api-webhooks`, {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://your-server.com/wildmap-webhook",
events: ["new_reading", "alert_triggered"]
})
});
const { data } = await resp.json();
console.log(`Secret: ${data.secret}`); // Store securelybody := strings.NewReader(`{"url":"https://your-server.com/wildmap-webhook","events":["new_reading","alert_triggered"]}`)
req, _ := http.NewRequest("POST", BASE+"/api-webhooks", body)
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
// Parse resp for data.secret — store securelyuri = URI("#{BASE}/api-webhooks")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req["Authorization"] = "Bearer #{API_KEY}"
req.body = { url: "https://your-server.com/wildmap-webhook",
events: ["new_reading", "alert_triggered"] }.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
secret = JSON.parse(resp.body)["data"]["secret"] # Store securelysecret shown only once. Use it to verify webhook deliveries.
Available Events
| Event | Triggered When |
|---|---|
new_reading | A new sensor reading is ingested |
alert_triggered | An alert threshold is crossed |
device_connected | A device establishes a connection |
Verifying Signatures
Every delivery includes an X-Webhook-Signature header with an HMAC-SHA256 signature of the request body.
# Verify with openssl
echo -n "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}'
# Compare output with X-Webhook-Signature header valueimport hmac, hashlib
def verify_signature(payload_bytes, signature, secret):
expected = hmac.new(
secret.encode(), payload_bytes, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)const crypto = require("crypto");
function verifySignature(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(signature)
);
}func verifySignature(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}def verify_signature(payload, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
Rack::Utils.secure_compare(expected, signature)
endCross-Organization Data Sharing
WildMap supports secure data sharing between organizations. When a partner shares their data with you, their readings, devices, and alerts become accessible through the same API endpoints.
How It Works
- Source org admin creates a share — grants your organization access to their sensor data, with an optional expiration date.
- You query with
include_shared=true— partner data appears alongside yours in readings, devices, stream, and alert endpoints. - Access automatically expires when the set time limit is reached, or can be revoked immediately at any time.
Share Properties
| Scope | Full read access to all readings, devices, and alerts from the source organization |
| Time Limit | Optional expiration date — access auto-revokes when reached |
| Revocation | Immediate — source org can cut access at any time |
| Audit Trail | granted_at, granted_by, revoked_at timestamps preserved |
| Uniqueness | One active share per source–target org pair |
Managing Shares
Share management requires JWT authentication (logged-in admin/owner):
# Create a share
curl -X POST ".../org-shares" -H "Authorization: Bearer <jwt>" \
-d '{"sourceOrgId": "your-org", "targetOrgId": "partner-org", "expiresAt": "2026-06-01T00:00:00Z"}'
# Revoke immediately
curl -X DELETE ".../org-shares?id=share-uuid" -H "Authorization: Bearer <jwt>"
# Extend expiration
curl -X PATCH ".../org-shares" -H "Authorization: Bearer <jwt>" \
-d '{"shareId": "share-uuid", "expiresAt": "2027-01-01T00:00:00Z"}'
Rate Limits
| Limit | 100 requests per minute per API key |
| On exceed | HTTP 429 with Retry-After header |
Every response includes rate limit headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1711000000
Error Handling
All errors return JSON:
{ "error": "Human-readable error message" }
| Status | Meaning |
|---|---|
| 400 | Bad request — missing or invalid parameters |
| 401 | Unauthorized — missing, invalid, expired, or revoked key |
| 403 | Forbidden — valid key but insufficient scope |
| 404 | Not found |
| 429 | Rate limit exceeded — retry after Retry-After seconds |
| 500 | Server error |
Code Examples
Full working examples in each language. Set BASE and API_KEY variables first.
Fetch Recent Readings
# Last 24 hours of readings
curl "https://<project>.supabase.co/functions/v1/api-readings?\
start=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)&limit=500" \
-H "X-API-Key: wf_your_key_here"
# List all devices
curl ".../api-devices" -H "X-API-Key: wf_your_key_here"
# Critical alerts from past week
curl ".../api-alert-history?severity=critical&\
start=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
-H "X-API-Key: wf_your_key_here"import requests
from datetime import datetime, timedelta
API_KEY = "wf_your_key_here"
BASE = "https://<project>.supabase.co/functions/v1"
headers = {"Authorization": f"Bearer {API_KEY}"}
# Last 24 hours
since = (datetime.utcnow() - timedelta(hours=24)).isoformat() + "Z"
resp = requests.get(f"{BASE}/api-readings", headers=headers,
params={"start": since, "limit": 500})
for r in resp.json()["data"]:
s = r["sensors"]
print(f"{r['device']['serial']}: {s['temp_c']}°C, "
f"{s['wind_mps']} m/s, PM2.5: {s['pm2_5']} µg/m³")const API_KEY = "wf_your_key_here";
const BASE = "https://<project>.supabase.co/functions/v1";
const headers = { Authorization: `Bearer ${API_KEY}` };
// Last 24 hours
const since = new Date(Date.now() - 86400000).toISOString();
const resp = await fetch(
`${BASE}/api-readings?start=${since}&limit=500`, { headers }
);
const { data } = await resp.json();
data.forEach(r => {
const s = r.sensors;
console.log(`${r.device.serial}: ${s.temp_c}°C, ${s.wind_mps} m/s, PM2.5: ${s.pm2_5}`);
});package main
import (
"encoding/json"; "fmt"; "net/http"; "time"
)
func main() {
apiKey := "wf_your_key_here"
base := "https://<project>.supabase.co/functions/v1"
since := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)
req, _ := http.NewRequest("GET",
fmt.Sprintf("%s/api-readings?start=%s&limit=500", base, since), nil)
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var result struct { Data []map[string]any }
json.NewDecoder(resp.Body).Decode(&result)
for _, r := range result.Data {
fmt.Println(r)
}
}require "net/http"
require "json"
require "time"
API_KEY = "wf_your_key_here"
BASE = "https://<project>.supabase.co/functions/v1"
since = (Time.now.utc - 86400).strftime("%Y-%m-%dT%H:%M:%SZ")
uri = URI("#{BASE}/api-readings?start=#{since}&limit=500")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{API_KEY}"
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(resp.body)["data"].each do |r|
s = r["sensors"]
puts "#{r['device']['serial']}: #{s['temp_c']}°C, #{s['wind_mps']} m/s"
end