Forstify External API
Version: 1.0 ·
Base URL: https://app.forstify.de/external/api/v1 ·
Swagger UI: https://app.forstify.de/api/v2/forstify-api
Authentication
Every request must include your API key in the X-API-KEY header. Keys are managed in the Forstify app under My Account → Connected Apps (a Business-plan feature).
X-API-KEY: {prefix}.{token}
Requests with a missing or invalid key return 401 Unauthorized. Endpoints require a plan with a monthly API allowance greater than zero (see Rate & monthly limits); accounts without one get 402 Payment Required.
FORSTIFY_API_KEY). Anyone with your token can consume your monthly quota and access your data.
Rate & monthly limits
Monthly request quota
Each token's plan grants a monthly request allowance, shared across all API tokens of that account. The quota resets on the 1st of each month. Once exhausted, requests return 429 Too Many Requests with code quota.exceeded and a Retry-After header (seconds until the next monthly reset).
GET /version is free (0). GET /images/:id and GET /images/:id/:width/:height cost 3. Only responses that complete successfully (2xx) consume quota; 304 and error responses never do.
Rate limits per endpoint
| Endpoint | Rate limit | Quota cost |
|---|---|---|
GET /measurements | 20 req / 60 s | 1 |
GET /woodlists | 20 req / 60 s | 1 |
GET /measurements/overview | 30 req / 60 s | 1 |
GET /woodlists/overview | 30 req / 60 s | 1 |
GET /measurements/search | 60 req / 60 s (default) | 1 |
GET /woodlists/search | 60 req / 60 s (default) | 1 |
GET /images/:id | 60 req / 60 s | 3 |
GET /images/:id/:width/:height | 60 req / 60 s | 3 |
GET /version | no rate limit | 0 |
Rate limits are tracked per API token (by its prefix), not per IP. Exceeding one returns 429 Too Many Requests (standard NestJS throttler error, not the quota ApiException format below).
Caching (ETag / If-None-Match)
All list and search endpoints include an ETag response header. Send it as If-None-Match on subsequent requests; the server returns 304 Not Modified with an empty body if nothing changed.
304 Not Modified response does not count against your monthly quota — conditional polling with If-None-Match is free when nothing changed.
# First request
GET /external/api/v1/measurements
→ 200 OK
→ ETag: "a1b2c3"
# Subsequent request
GET /external/api/v1/measurements
If-None-Match: "a1b2c3"
→ 304 Not Modified (empty body)
Pagination
All list endpoints return a paginated envelope:
{
"data": [...],
"pagination": {
"count": 20,
"limit": 20,
"offset": 0,
"total": 142
}
}
| Field | Type | Description |
|---|---|---|
data | array | Items on this page |
pagination.count | integer | Number of items in data (≤ limit) |
pagination.limit | integer | Echoes the requested limit |
pagination.offset | integer | Echoes the requested offset |
pagination.total | integer | Total items matching the query, across all pages |
Query parameters
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
limit | integer | 20 | 100 | Items per page |
offset | integer | 0 | 100000 | Number of items to skip |
ID format
All resource IDs are GUIDs generated by MSSQL's NEWSEQUENTIALID(). This function produces GUIDs with an irregular version nibble, for example BAEB1143-2BFD-F011-8332-6045BD9D30A4, where position 13 can be any hex digit instead of the range from 1 through 8 required by RFC 4122.
Most runtimes are unaffected. Only Node.js UUID validators enforce the version nibble by default:
| Library | Default result | Fix |
|---|---|---|
validator.js isUUID(id) | rejects | Use isUUID(id, 'loose'); this disables the version/variant check |
uuid npm validate(id) | rejects | No built in option; use isUUID(id, 'loose') from validator.js or the regex below |
Java UUID.fromString() | accepts | n/a |
Python uuid.UUID(str) | accepts | n/a |
Go uuid.Parse() | accepts | n/a |
As a last resort, use this lenient regex, which accepts any GUID made of five hex digit groups (lengths 8, 4, 4, 4 and 12) regardless of version nibble:
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
Error responses
Every error on this API, regardless of cause, uses one envelope:
{
"statusCode": 429,
"code": "quota.exceeded",
"message": "..." // present only for 400/404; omitted for auth/plan/IP/quota errors
}
| code | Status | Meaning |
|---|---|---|
auth.invalid | 401 | API key missing, malformed, or unknown |
plan.required | 402 | Account's plan has no monthly API allowance |
ip.blocked | 403 | Token has an IP allowlist and the request IP isn't in it |
quota.exceeded | 429 | Monthly quota exhausted; check the Retry-After header |
validation.failed | 400 | Query or path parameter failed validation; see message for detail |
not_found | 404 | The requested record doesn't exist or isn't visible to this account |
Example validation error, with detail in message:
{
"statusCode": 400,
"code": "validation.failed",
"message": "at least one search parameter required"
}
Endpoints
Returns current backend and API version. No rate limit and free of quota cost (0); safe to poll.
Response 200
{
"backendVersion": "4.8.0",
"apiVersion": "1.0"
}
Example
// Node.js 22+ (built-in fetch) · npm install express @types/express
import express from 'express';
const API_BASE = 'https://app.forstify.de/external/api/v1';
const API_KEY = process.env.FORSTIFY_API_KEY!;
const app = express();
app.get('/forstify-version', async (_req, res) => {
const response = await fetch(`${API_BASE}/version`, {
headers: { 'X-API-KEY': API_KEY },
});
if (!response.ok) {
return res.status(response.status).json(await response.json());
}
res.json(await response.json());
});
// Spring Boot 3.2+ · spring-boot-starter-web
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.Map;
@Service
public class ForstifyClient {
private final RestClient restClient;
public ForstifyClient(@Value("${forstify.api-key}") String apiKey) {
this.restClient = RestClient.builder()
.baseUrl("https://app.forstify.de/external/api/v1")
.defaultHeader("X-API-KEY", apiKey)
.build();
}
public Map<String, String> getVersion() {
return restClient.get()
.uri("/version")
.retrieve()
.body(new org.springframework.core.ParameterizedTypeReference<>() {});
}
}
# pip install requests
import os
import requests
class ForstifyClient:
BASE_URL = "https://app.forstify.de/external/api/v1"
def __init__(self):
self._session = requests.Session()
self._session.headers["X-API-KEY"] = os.environ["FORSTIFY_API_KEY"]
def get_version(self) -> dict:
resp = self._session.get(f"{self.BASE_URL}/version")
resp.raise_for_status()
return resp.json()
# Usage
client = ForstifyClient()
version = client.get_version()
print(version["backendVersion"])
// stdlib only
package forstify
import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type Client struct {
baseURL string
apiKey string
http *http.Client
}
func NewClient() *Client {
return &Client{
baseURL: "https://app.forstify.de/external/api/v1",
apiKey: os.Getenv("FORSTIFY_API_KEY"),
http: &http.Client{Timeout: 30 * time.Second},
}
}
type VersionResponse struct {
BackendVersion string `json:"backendVersion"`
APIVersion string `json:"apiVersion"`
}
func (c *Client) GetVersion() (*VersionResponse, error) {
req, _ := http.NewRequest(http.MethodGet, c.baseURL+"/version", nil)
req.Header.Set("X-API-KEY", c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("forstify: %s", resp.Status)
}
var result VersionResponse
json.NewDecoder(resp.Body).Decode(&result)
return &result, nil
}
Returns all logpile measurements belonging to the authenticated user, paginated. Includes full detail: volumes, single logs, metadata.
Query parameters
| Parameter | Type | Default | Options | Description |
|---|---|---|---|---|
limit | integer | 20 | 1 through 100 | Items per page |
offset | integer | 0 | 0 through 100000 | Pagination offset |
sortBy | string | createdAt | createdAt updatedAt number species | Sort field |
sortOrder | string | ASC | ASC DESC | Sort direction |
updatedAfter | ISO 8601 datetime | n/a | n/a | Only records updated after this timestamp, for delta sync |
Response 200
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-06-01T08:15:00.000Z",
"logpileNumber": "2024-001",
"species": "fi",
"assortment": "Stammholz",
"variety": "Lang",
"logCount": 48,
"logpileWidth": 4.5,
"latitude": 47.8123,
"longitude": 13.0456,
"notes": null,
"images": ["https://..."],
"volumes": [
{ "number": 1, "quality": "b", "logLength": 4.0, "volume": 12.5, "logCount": 48 }
],
"singleLogs": null,
"metadata": { "forestOwner": "Muster GmbH", "lotNumber": "42" }
}
],
"pagination": { "count": 1, "limit": 20, "offset": 0, "total": 84 }
}
Example
app.get('/measurements', async (req, res) => {
const params = new URLSearchParams({
limit: String(req.query.limit ?? 20),
offset: String(req.query.offset ?? 0),
sortBy: String(req.query.sortBy ?? 'createdAt'),
sortOrder: String(req.query.sortOrder ?? 'DESC'),
});
const response = await fetch(`${API_BASE}/measurements?${params}`, {
headers: { 'X-API-KEY': API_KEY },
});
if (response.status === 304) return res.status(304).end();
if (!response.ok) return res.status(response.status).json(await response.json());
res.json(await response.json());
});
// Inside ForstifyClient - see GET /version for full class setup
import org.springframework.core.ParameterizedTypeReference;
import java.util.List;
import java.util.Map;
public Map<String, Object> getMeasurements(
int limit, int offset, String sortBy, String sortOrder) {
return restClient.get()
.uri(u -> u.path("/measurements")
.queryParam("limit", limit)
.queryParam("offset", offset)
.queryParam("sortBy", sortBy)
.queryParam("sortOrder", sortOrder)
.build())
.retrieve()
.body(new ParameterizedTypeReference<>() {});
}
# Inside ForstifyClient - see GET /version for full class setup
def get_measurements(
self,
limit: int = 20,
offset: int = 0,
sort_by: str = "createdAt",
sort_order: str = "DESC",
) -> dict:
resp = self._session.get(
f"{self.BASE_URL}/measurements",
params={"limit": limit, "offset": offset, "sortBy": sort_by, "sortOrder": sort_order},
)
resp.raise_for_status()
return resp.json()
// Inside Client - see GET /version for full struct setup
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
)
type Pagination struct {
Count int `json:"count"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int `json:"total"`
}
type PaginatedResult struct {
Data []map[string]any `json:"data"`
Pagination Pagination `json:"pagination"`
}
func (c *Client) GetMeasurements(limit, offset int, sortBy, sortOrder string) (*PaginatedResult, error) {
q := url.Values{}
q.Set("limit", strconv.Itoa(limit))
q.Set("offset", strconv.Itoa(offset))
q.Set("sortBy", sortBy)
q.Set("sortOrder", sortOrder)
req, _ := http.NewRequest(http.MethodGet, c.baseURL+"/measurements?"+q.Encode(), nil)
req.Header.Set("X-API-KEY", c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("forstify: %s", resp.Status)
}
var result PaginatedResult
json.NewDecoder(resp.Body).Decode(&result)
return &result, nil
}
Lightweight alternative to /measurements. Returns compact logpile summaries without individual log sections; ideal for list views and map rendering.
Query parameters
Same as GET /measurements.
Example with ETag caching
// Simple in-memory ETag store - use Redis / DB in production
const etagCache = new Map<string, string>();
app.get('/measurements/overview', async (req, res) => {
const params = new URLSearchParams({
limit: String(req.query.limit ?? 20),
offset: String(req.query.offset ?? 0),
sortBy: String(req.query.sortBy ?? 'createdAt'),
sortOrder: String(req.query.sortOrder ?? 'DESC'),
});
const cacheKey = params.toString();
const headers: Record<string, string> = { 'X-API-KEY': API_KEY };
const storedEtag = etagCache.get(cacheKey);
if (storedEtag) headers['If-None-Match'] = storedEtag;
const response = await fetch(`${API_BASE}/measurements/overview?${params}`, { headers });
if (response.status === 304) return res.status(304).end();
if (!response.ok) return res.status(response.status).json(await response.json());
const etag = response.headers.get('etag');
if (etag) etagCache.set(cacheKey, etag);
res.json(await response.json());
});
// Inside ForstifyClient - see GET /version for full class setup
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Optional;
public Optional<ResponseEntity<?>> getMeasurementsOverview(
int limit, int offset, String sortBy, String sortOrder, String etag) {
return restClient.get()
.uri(u -> u.path("/measurements/overview")
.queryParam("limit", limit)
.queryParam("offset", offset)
.queryParam("sortBy", sortBy)
.queryParam("sortOrder", sortOrder)
.build())
.headers(h -> { if (etag != null) h.setIfNoneMatch(etag); })
.exchange((req, resp) -> {
if (resp.getStatusCode() == HttpStatus.NOT_MODIFIED) {
return Optional.empty();
}
return Optional.of(resp.bodyTo(new ParameterizedTypeReference<>() {}));
});
}
# Inside ForstifyClient - see GET /version for full class setup
# Returns (data, new_etag). data is None on 304 Not Modified.
from typing import Optional
def get_measurements_overview(
self,
limit: int = 20,
offset: int = 0,
sort_by: str = "createdAt",
sort_order: str = "DESC",
etag: Optional[str] = None,
) -> tuple[Optional[dict], Optional[str]]:
headers = {}
if etag:
headers["If-None-Match"] = etag
resp = self._session.get(
f"{self.BASE_URL}/measurements/overview",
params={"limit": limit, "offset": offset, "sortBy": sort_by, "sortOrder": sort_order},
headers=headers,
)
if resp.status_code == 304:
return None, etag
resp.raise_for_status()
return resp.json(), resp.headers.get("ETag")
// Inside Client - see GET /version for full struct setup
// Returns (result, newEtag, error). result is nil on 304 Not Modified.
func (c *Client) GetMeasurementsOverview(
limit, offset int, sortBy, sortOrder, etag string,
) (*PaginatedResult, string, error) {
q := url.Values{}
q.Set("limit", strconv.Itoa(limit))
q.Set("offset", strconv.Itoa(offset))
q.Set("sortBy", sortBy)
q.Set("sortOrder", sortOrder)
req, _ := http.NewRequest(http.MethodGet, c.baseURL+"/measurements/overview?"+q.Encode(), nil)
req.Header.Set("X-API-KEY", c.apiKey)
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified {
return nil, etag, nil
}
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("forstify: %s", resp.Status)
}
newEtag := resp.Header.Get("ETag")
var result PaginatedResult
json.NewDecoder(resp.Body).Decode(&result)
return &result, newEtag, nil
}
Look up a single measurement by internal UUID or the human visible logpile number. At least one parameter is required.
Query parameters
| Parameter | Type | Description |
|---|---|---|
id | GUID | Internal measurement ID |
number | string (max 65) | Human visible logpile number |
Response 200
Single Measurement object.
Example
app.get('/measurements/search', async (req, res) => {
const { id, number } = req.query as Record<string, string>;
if (!id && !number) {
return res.status(400).json({ message: 'id or number required' });
}
const params = new URLSearchParams();
if (id) params.set('id', id);
if (number) params.set('number', number);
const response = await fetch(`${API_BASE}/measurements/search?${params}`, {
headers: { 'X-API-KEY': API_KEY },
});
if (response.status === 304) return res.status(304).end();
if (response.status === 404) return res.status(404).json({ message: 'not found' });
if (!response.ok) return res.status(response.status).json(await response.json());
res.json(await response.json());
});
// Inside ForstifyClient - see GET /version for full class setup
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
public Map<String, Object> searchMeasurement(String id, String number) {
if (id == null && number == null) {
throw new IllegalArgumentException("id or number is required");
}
UriComponentsBuilder uri = UriComponentsBuilder.fromPath("/measurements/search");
if (id != null) uri.queryParam("id", id);
if (number != null) uri.queryParam("number", number);
return restClient.get()
.uri(uri.build().toUri())
.retrieve()
.body(new ParameterizedTypeReference<>() {});
}
# Inside ForstifyClient - see GET /version for full class setup
from typing import Optional
def search_measurement(
self, id: Optional[str] = None, number: Optional[str] = None
) -> dict:
if not id and not number:
raise ValueError("id or number is required")
params = {}
if id: params["id"] = id
if number: params["number"] = number
resp = self._session.get(f"{self.BASE_URL}/measurements/search", params=params)
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
// Inside Client - see GET /version for full struct setup
func (c *Client) SearchMeasurement(id, number string) (map[string]any, error) {
if id == "" && number == "" {
return nil, fmt.Errorf("id or number is required")
}
q := url.Values{}
if id != "" { q.Set("id", id) }
if number != "" { q.Set("number", number) }
req, _ := http.NewRequest(http.MethodGet, c.baseURL+"/measurements/search?"+q.Encode(), nil)
req.Header.Set("X-API-KEY", c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("forstify: %s", resp.Status)
}
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
return result, nil
}
Returns all wood lists belonging to the authenticated user, including all nested logpile measurements, paginated.
Query parameters
| Parameter | Type | Default | Options | Description |
|---|---|---|---|---|
limit | integer | 20 | 1 through 100 | Items per page |
offset | integer | 0 | 0 through 100000 | Pagination offset |
sortBy | string | createdAt | createdAt updatedAt title | Sort field |
sortOrder | string | ASC | ASC DESC | Sort direction |
updatedAfter | ISO 8601 datetime | n/a | n/a | Only records updated after this timestamp, for delta sync |
Response 200
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-06-01T08:15:00.000Z",
"title": "Schlag Nordost 2024",
"species": "fi",
"assortment": "Stammholz",
"grade": "st",
"volume": 148.2,
"logCount": 612,
"minLogLength": 3.0,
"maxLogLength": 5.0,
"latitude": 47.8123,
"longitude": 13.0456,
"images": ["https://..."],
"logpiles": [...]
}
],
"pagination": { "count": 1, "limit": 20, "offset": 0, "total": 12 }
}
Example
app.get('/woodlists', async (req, res) => {
const params = new URLSearchParams({
limit: String(req.query.limit ?? 20),
offset: String(req.query.offset ?? 0),
sortBy: String(req.query.sortBy ?? 'createdAt'),
sortOrder: String(req.query.sortOrder ?? 'DESC'),
});
const response = await fetch(`${API_BASE}/woodlists?${params}`, {
headers: { 'X-API-KEY': API_KEY },
});
if (response.status === 304) return res.status(304).end();
if (!response.ok) return res.status(response.status).json(await response.json());
res.json(await response.json());
});
// Inside ForstifyClient - see GET /version for full class setup
public Map<String, Object> getWoodLists(
int limit, int offset, String sortBy, String sortOrder) {
return restClient.get()
.uri(u -> u.path("/woodlists")
.queryParam("limit", limit)
.queryParam("offset", offset)
.queryParam("sortBy", sortBy)
.queryParam("sortOrder", sortOrder)
.build())
.retrieve()
.body(new org.springframework.core.ParameterizedTypeReference<>() {});
}
# Inside ForstifyClient - see GET /version for full class setup
def get_woodlists(
self,
limit: int = 20,
offset: int = 0,
sort_by: str = "createdAt",
sort_order: str = "DESC",
) -> dict:
resp = self._session.get(
f"{self.BASE_URL}/woodlists",
params={"limit": limit, "offset": offset, "sortBy": sort_by, "sortOrder": sort_order},
)
resp.raise_for_status()
return resp.json()
// Inside Client - see GET /version for full struct setup
func (c *Client) GetWoodLists(limit, offset int, sortBy, sortOrder string) (*PaginatedResult, error) {
q := url.Values{}
q.Set("limit", strconv.Itoa(limit))
q.Set("offset", strconv.Itoa(offset))
q.Set("sortBy", sortBy)
q.Set("sortOrder", sortOrder)
req, _ := http.NewRequest(http.MethodGet, c.baseURL+"/woodlists?"+q.Encode(), nil)
req.Header.Set("X-API-KEY", c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("forstify: %s", resp.Status)
}
var result PaginatedResult
json.NewDecoder(resp.Body).Decode(&result)
return &result, nil
}
Lightweight alternative to /woodlists. Returns compact wood list summaries without nested logpile detail.
Query parameters
Same as GET /woodlists.
Example
app.get('/woodlists/overview', async (req, res) => {
const params = new URLSearchParams({
limit: String(req.query.limit ?? 20),
offset: String(req.query.offset ?? 0),
sortBy: String(req.query.sortBy ?? 'createdAt'),
sortOrder: String(req.query.sortOrder ?? 'DESC'),
});
const response = await fetch(`${API_BASE}/woodlists/overview?${params}`, {
headers: { 'X-API-KEY': API_KEY },
});
if (response.status === 304) return res.status(304).end();
if (!response.ok) return res.status(response.status).json(await response.json());
res.json(await response.json());
});
// Inside ForstifyClient - see GET /version for full class setup
public Map<String, Object> getWoodListsOverview(
int limit, int offset, String sortBy, String sortOrder, String etag) {
return restClient.get()
.uri(u -> u.path("/woodlists/overview")
.queryParam("limit", limit)
.queryParam("offset", offset)
.queryParam("sortBy", sortBy)
.queryParam("sortOrder", sortOrder)
.build())
.headers(h -> { if (etag != null) h.setIfNoneMatch(etag); })
.retrieve()
.body(new org.springframework.core.ParameterizedTypeReference<>() {});
}
# Inside ForstifyClient - see GET /version for full class setup
from typing import Optional
def get_woodlists_overview(
self,
limit: int = 20,
offset: int = 0,
sort_by: str = "createdAt",
sort_order: str = "DESC",
etag: Optional[str] = None,
) -> tuple[Optional[dict], Optional[str]]:
headers = {}
if etag:
headers["If-None-Match"] = etag
resp = self._session.get(
f"{self.BASE_URL}/woodlists/overview",
params={"limit": limit, "offset": offset, "sortBy": sort_by, "sortOrder": sort_order},
headers=headers,
)
if resp.status_code == 304:
return None, etag
resp.raise_for_status()
return resp.json(), resp.headers.get("ETag")
// Inside Client - see GET /version for full struct setup
func (c *Client) GetWoodListsOverview(
limit, offset int, sortBy, sortOrder, etag string,
) (*PaginatedResult, string, error) {
q := url.Values{}
q.Set("limit", strconv.Itoa(limit))
q.Set("offset", strconv.Itoa(offset))
q.Set("sortBy", sortBy)
q.Set("sortOrder", sortOrder)
req, _ := http.NewRequest(http.MethodGet, c.baseURL+"/woodlists/overview?"+q.Encode(), nil)
req.Header.Set("X-API-KEY", c.apiKey)
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified {
return nil, etag, nil
}
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("forstify: %s", resp.Status)
}
newEtag := resp.Header.Get("ETag")
var result PaginatedResult
json.NewDecoder(resp.Body).Decode(&result)
return &result, newEtag, nil
}
Look up a single wood list by internal UUID, public share ID, or exact title. At least one parameter is required.
Query parameters
| Parameter | Type | Description |
|---|---|---|
id | GUID | Internal wood list ID |
shareId | string | Public share ID |
title | string (max 65) | Exact title match |
Response 200
Single WoodList object.
Example
app.get('/woodlists/search', async (req, res) => {
const { id, shareId, title } = req.query as Record<string, string>;
if (!id && !shareId && !title) {
return res.status(400).json({ message: 'id, shareId or title required' });
}
const params = new URLSearchParams();
if (id) params.set('id', id);
if (shareId) params.set('shareId', shareId);
if (title) params.set('title', title);
const response = await fetch(`${API_BASE}/woodlists/search?${params}`, {
headers: { 'X-API-KEY': API_KEY },
});
if (response.status === 304) return res.status(304).end();
if (response.status === 404) return res.status(404).json({ message: 'not found' });
if (!response.ok) return res.status(response.status).json(await response.json());
res.json(await response.json());
});
// Inside ForstifyClient - see GET /version for full class setup
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
public Map<String, Object> searchWoodList(String id, String shareId, String title) {
if (id == null && shareId == null && title == null) {
throw new IllegalArgumentException("id, shareId or title is required");
}
UriComponentsBuilder uri = UriComponentsBuilder.fromPath("/woodlists/search");
if (id != null) uri.queryParam("id", id);
if (shareId != null) uri.queryParam("shareId", shareId);
if (title != null) uri.queryParam("title", title);
return restClient.get()
.uri(uri.build().toUri())
.retrieve()
.body(new ParameterizedTypeReference<>() {});
}
# Inside ForstifyClient - see GET /version for full class setup
from typing import Optional
def search_woodlist(
self,
id: Optional[str] = None,
share_id: Optional[str] = None,
title: Optional[str] = None,
) -> Optional[dict]:
if not id and not share_id and not title:
raise ValueError("id, share_id or title is required")
params = {}
if id: params["id"] = id
if share_id: params["shareId"] = share_id
if title: params["title"] = title
resp = self._session.get(f"{self.BASE_URL}/woodlists/search", params=params)
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
// Inside Client - see GET /version for full struct setup
func (c *Client) SearchWoodList(id, shareID, title string) (map[string]any, error) {
if id == "" && shareID == "" && title == "" {
return nil, fmt.Errorf("id, shareID or title is required")
}
q := url.Values{}
if id != "" { q.Set("id", id) }
if shareID != "" { q.Set("shareId", shareID) }
if title != "" { q.Set("title", title) }
req, _ := http.NewRequest(http.MethodGet, c.baseURL+"/woodlists/search?"+q.Encode(), nil)
req.Header.Set("X-API-KEY", c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("forstify: %s", resp.Status)
}
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
return result, nil
}
Downloads a measurement/logpile image as JPEG binary, by the blob id referenced in Measurement.images or a wood list's overview image.id. Ownership is checked; the image must belong to a measurement the token's user can access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | GUID | Image blob id |
Response 200
image/jpeg binary. Cache-Control: public,max-age=86400,immutable. Costs 3 quota units, 60 req / 60 s.
Example
app.get('/images/:id', async (req, res) => {
const response = await fetch(`${API_BASE}/images/${req.params.id}`, {
headers: { 'X-API-KEY': API_KEY },
});
if (!response.ok) return res.status(response.status).json(await response.json());
res.setHeader('Content-Type', 'image/jpeg');
res.send(Buffer.from(await response.arrayBuffer()));
});
// Inside ForstifyClient - see GET /version for full class setup
public byte[] getImage(String id) {
return restClient.get()
.uri("/images/{id}", id)
.retrieve()
.body(byte[].class);
}
# Inside ForstifyClient - see GET /version for full class setup
def get_image(self, id: str) -> bytes:
resp = self._session.get(f"{self.BASE_URL}/images/{id}")
resp.raise_for_status()
return resp.content
// Inside Client - see GET /version for full struct setup
func (c *Client) GetImage(id string) ([]byte, error) {
req, _ := http.NewRequest(http.MethodGet, c.baseURL+"/images/"+id, nil)
req.Header.Set("X-API-KEY", c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("forstify: %s", resp.Status)
}
return io.ReadAll(resp.Body)
}
Same as GET /images/:id, resized on the server to the given dimensions; use for thumbnails instead of downloading images at full resolution.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | GUID | Image blob id |
width | integer | Target width in px |
height | integer | Target height in px |
Response 200
image/jpeg binary, resized. Cache-Control: public,max-age=86400,immutable. Costs 3 quota units, 60 req / 60 s.
Example
app.get('/images/:id/:width/:height', async (req, res) => {
const { id, width, height } = req.params;
const response = await fetch(`${API_BASE}/images/${id}/${width}/${height}`, {
headers: { 'X-API-KEY': API_KEY },
});
if (!response.ok) return res.status(response.status).json(await response.json());
res.setHeader('Content-Type', 'image/jpeg');
res.send(Buffer.from(await response.arrayBuffer()));
});
// Inside ForstifyClient - see GET /version for full class setup
public byte[] getScaledImage(String id, int width, int height) {
return restClient.get()
.uri("/images/{id}/{width}/{height}", id, width, height)
.retrieve()
.body(byte[].class);
}
# Inside ForstifyClient - see GET /version for full class setup
def get_scaled_image(self, id: str, width: int, height: int) -> bytes:
resp = self._session.get(f"{self.BASE_URL}/images/{id}/{width}/{height}")
resp.raise_for_status()
return resp.content
// Inside Client - see GET /version for full struct setup
func (c *Client) GetScaledImage(id string, width, height int) ([]byte, error) {
path := fmt.Sprintf("/images/%s/%d/%d", id, width, height)
req, _ := http.NewRequest(http.MethodGet, c.baseURL+path, nil)
req.Header.Set("X-API-KEY", c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("forstify: %s", resp.Status)
}
return io.ReadAll(resp.Body)
}
Data types
Measurement
Full logpile recording, returned by /measurements and /measurements/search.
| Field | Type | Description |
|---|---|---|
id | string | UUID |
createdAt | string | ISO 8601 UTC |
updatedAt | string | ISO 8601 UTC; use as the cursor for delta sync |
logpileNumber | string | null | Human visible logpile number |
species | WoodSpecies | null | |
assortment | WoodAssortment | null | |
variety | WoodVariety | null | |
logCount | number | null | Total number of logs |
logpileWidth | number | null | Logpile width in metres |
latitude | number | null | GPS latitude |
longitude | number | null | GPS longitude |
notes | string | null | Max 500 chars |
images | string[] | null | Absolute image URLs |
volumes | Volume[] | null | Volume breakdown by quality class |
singleLogs | SingleLog[] | null | Individual log data, present only on measurements with individual logs |
metadata | MeasurementMetadata | null | Forestry metadata fields |
WoodList
Full wood list including nested logpile measurements, returned by /woodlists and /woodlists/search.
| Field | Type | Description |
|---|---|---|
id | string | UUID |
createdAt | string | ISO 8601 UTC |
updatedAt | string | ISO 8601 UTC; use as the cursor for delta sync |
title | string | null | Max 65 chars |
notes | string | null | Max 500 chars |
species | WoodSpecies | null | |
assortment | WoodAssortment | null | |
variety | WoodVariety | null | |
grade | WoodGrade | null | Wood assortment grade |
volume | number | null | Total m³ across all logpiles |
logCount | number | null | Total log count |
minDiameter / maxDiameter | number | null | cm |
minLogLength / maxLogLength | number | null | metres |
minQuality / maxQuality | WoodQuality | null | |
latitude / longitude | number | null | Centroid of all logpile coordinates |
images | string[] | null | Absolute image URLs |
logpiles | Measurement[] | null | All nested logpile measurements |
LogpileShort (overview endpoint)
| Field | Type | Description |
|---|---|---|
id | string | UUID |
number | string | Human visible logpile number |
assortment | WoodAssortment | null | |
grade | WoodAssortmentFrontend | null | Despite the name, this is the assortment enum, not WoodGrade |
volume | number | null | m³ |
cubicMeter | number | null | Rm; only set for measurements of type ARM |
logCount | number | null | |
logLength | number | null | Average in metres |
logLengthRange | { min, max } | null | metres |
logDiameter | { min, max } | null | cm |
location | WoodLocationDto | null | Street, house number, postcode, city, and coordinate: { latitude, longitude }; see WoodLocationDto |
notes | string | null |
WoodListShort (overview endpoint)
| Field | Type | Description |
|---|---|---|
id | string | UUID |
title | string | null | |
species | WoodSpecies | null | |
assortment | WoodAssortment | null | |
variety | WoodVariety | null | |
grade | WoodGrade | null | |
logLength | number | null | Average in metres |
volume | number | null | Total m³ |
logLengthRange | { min, max } | null | metres |
logDiameter | { min, max } | null | cm |
quality | { min, max } | null | WoodQuality values |
location | WoodLocationDto | null | City and coordinate / approximateCoordinate: { latitude, longitude }; see WoodLocationDto |
image | { id, width?, height? } | null | Title image reference; fetch the binary via GET /images/:id using id |
bundleOfferId, but it is not exposed on this DTO; it never appears in the API response.
Volume
One quality / length class within a logpile measurement.
| Field | Type | Description |
|---|---|---|
number | number | Sort index |
quality | WoodQuality | |
logLength | number | metres |
volume | number | m³ |
logCount | number | null |
SingleLog / SingleLogSection
Individual log data, present only on measurements with individual logs.
SingleLog { number: string; sections: SingleLogSection[] } // free text label, not always numeric
SingleLogSection {
number: number // sort index
logLength: number // metres
quality: WoodQuality
diameter: number // cm
volume: number // m³
}
WoodLocationDto
Used for location on the overview endpoints. Populated fields vary; logpile overviews set street level detail, wood list overviews only city and coordinates.
| Field | Type | Description |
|---|---|---|
street | string | |
houseNumber | string | |
postcode | string | |
city | string | |
coordinate | { latitude, longitude } | Exact GPS coordinate |
approximateCoordinate | { latitude, longitude } | Obfuscated GPS coordinate (privacy) |
MeasurementMetadata
All fields are string | null, max 100 chars each.
| Field | Field |
|---|---|
forestOwner | locationStreet |
forester | locationHouseNumber |
serviceProvider | locationPostcode |
buyer | locationCity |
hiebsNumber | locationCountry |
areaNumber | inventoryUnit |
lotNumber | division |
subdivision |
Enum reference
| Value | Meaning |
|---|---|
Stammholz | Round timber |
Energieholz | Energy wood |
Industrieholz | Industrial wood |
Sondersortiment | Special assortment |
Palettenholz | Pallet wood |
OSB | OSB |
| Value | Corresponds to |
|---|---|
ch_s | WoodAssortment.Stammholz |
ch_e | WoodAssortment.Energieholz |
ch_i | WoodAssortment.Industrieholz |
ch_r | WoodAssortment.Sondersortiment |
all | All assortments selected |
| Value | Meaning |
|---|---|
Lang | Long |
Kurz | Short |
Abschnitte | Sections |
Waldhackschnitzel | Forest chips |
Andere | Other |
| Value | Standard |
|---|---|
a b c d | DE quality classes |
ch_ab ch_abc | CH quality classes |
bc bcd cd | Combined |
in | Industrial, normal |
ik | Industrial, diseased |
if | Industrial, defective |
nf nfk fk | Industrial combined |
| Value | Meaning |
|---|---|
st | Stammholz lang |
fl | Stammholz Abschnitte |
il | Industrieholz lang |
is | Industrieholz kurz |
bl | Energieholz lang |
bs | Energieholz kurz |
s_hs e_hs i_hs | Hackschnitzel |
ol os o_fl o_hs o | Sondersortiment |
pa_l pa_s pa_hs | Palettenholz |
WoodSpecies, full list
All 128 values accepted for species. Species with sub varieties (e.g. individual spruce/pine/oak types) roll up to a parent code (fi, kie, ta, ...) when the exact variety isn't tracked.
| Value | DE | EN |
|---|---|---|
xy | Laub- & Nadelholz | Hardwood and Softwood |
ndh | Nadelholz | Softwood |
fi | Fichte | Spruce |
gfi | Gemeine Fichte | Common Spruce |
ofi | Omorikafichte | Serbian Spruce |
sfi | Sitkafichte | Sitka Spruce |
swfi | Schwarzfichte | Black Spruce |
efi | Engelmannsfichte | Engelmann Spruce |
bfi | Blaufichte/Stechfichte | Blue Spruce / Norway Spruce |
wfi | Weißfichte | White Spruce |
sofi | Sonstige Fichten | Other Spruce |
kie | Kiefer | Pine |
ki | Gemeine Kiefer | Common Pine |
bki | Bergkiefer | Mountain Pine |
ski | Schwarzkiefer | Black Pine |
rki | Rumelische Kiefer | Balkan Pine |
zki | Zirbelkiefer | Stone Pine |
wki | Weymouthskiefer | Weymouth Pine |
mki | Murraykiefer | Murray Pine |
gki | Gelbkiefer | Ponderosa Pine |
soki | Sonstige Kiefer | Other Pine |
ta | Tanne | Fir |
wta | Weißtanne | Silver Fir |
ata | Amerikanische Edeltanne | American Silver Fir |
cta | Coloradotanne | Colorado Fir |
kta | Küstentanne | Coastal Fir |
nita | Nikkotanne | Nikko Fir |
nota | Nordmannstanne | Nordmann Fir |
vta | Veitchtanne | St. Vitus Fir |
sota | Sonstige Tannen | Other Firs |
dgl | Douglasie | Douglas Fir |
la | Lärche | Larch |
ela | Europäische Lärche | European Larch |
jla | Japanische Lärche Hybrid | Japanese Larch Hybrid |
sla | Sonstige Lärchen | Other Larches |
sonb | Sonstige Nadelbäume | Other Conifers |
lb | Lebensbaum | Arborvitae / Thuja |
ht | Hemlockstanne | Hemlock |
mam | Mammutbaum | Sequoia / Redwood |
eib | Eibe | Yew |
sz | Lawsonszypresse | Lawson Cypress |
bu | Buche | Beech |
sei | Stieleiche | Common Oak |
tei | Traubeneiche | Sessile Oak |
rei | Roteiche | Red Oak |
zei | Zerreiche | Turkey Oak |
suei | Sumpfeiche | Swamp Oak |
ei | Eiche | Oak |
que | sonstige Eichen | Other Oaks |
es | Esche | Ash |
ges | Gemeine Esche | Common Ash |
wes | Weißesche | White Ash |
fra | Sonstige Eschen | Other Ash |
hbu | Hainbuche / Weißbuche | Hornbeam |
ah | Ahorn | Maple |
bah | Bergahorn | Sycamore Maple |
sah | Spitzahorn | Norway Maple |
fah | Feldahorn | Field Maple |
eah | Eschenblättriger Ahorn | Ash-leaved Maple |
siah | Silberahorn | Silver Maple |
ace | Sonstige Ahorne | Other Maples |
li | Linde | Lime |
wli | Winterlinde | Small-Leaved Lime |
sli | Sommerlinde | Summer Lime |
til | Sonstige Linden | Other Lime Trees |
rob | Robinie | Black Locust |
akz | Akazie | Acacia |
ul | Ulme | Elm |
bul | Bergulme | Wych Elm |
ful | Feldulme | Field Elm |
flu | Flatterulme | White Elm |
ulm | Sonstige Ulmen | Other Elms |
rka | Rosskastanie | Horse Chestnut |
eka | Edelkastanie | Sweet Chestnut |
ka | Kastanie | Chestnut |
mau | Weißer Maulbeerbaum | White Mulberry |
nus | Nussbaum | Walnut |
wnu | Walnuss | Walnut |
snu | Schwarznuss Hybrid | Black Walnut Hybrid |
jug | Sonstige Nussbäume | Other Nut Trees |
ste | Stechpalme | Holly |
pla | Platane | Sycamore |
apl | Ahornblättrige Platane | London Plane |
solh | Sonstige Laubbäume mit hoher Lebensdauer | Other deciduous trees with a long lifespan |
gbi | Gemeine Birke | Common Birch |
mbi | Moorbirke / Karpatenbirke | Bog Birch / Carpathian Birch |
bi | Birke | Birch |
erl | Erle | Alder |
ser | Schwarzerle | Black Alder |
wer | Weißerle / Grauerle | White Alder / Gray Alder |
ger | Grünerle | Green Alder |
aln | Sonstige Erlen | Other Alders |
pap | Pappel | Poplar |
zpa | Aspe / Zitterpappel | Aspen / Trembling Poplar |
spa | Europäische Schwarzpappel | European Black Poplar |
spah | Schwarzpappel Hybrid | Black Poplar Hybrid |
gpa | Graupappel Hybrid | Gray Poplar Hybrid |
wpa | Silberpappel / Weißpappel | Silver Poplar / White Poplar |
bpa | Balsampappel | Balsam Poplar |
bpah | Balsampappel Hybrid | Balsam Poplar Hybrid |
pop | Sonstige Pappeln | Other Poplars |
sor | Sorbusarten | Sorbus Species |
sso | Sonstige Sorbusarten | Other Sorbus Species |
vb | Vogelbeere | Rowan Berry |
els | Elsbeere | Serviceberry |
spe | Speierling | Service Tree |
meb | Echte Mehlbeere | Real Serviceberry |
wei | Weide | Willow |
swei | Salweide | Sal Willow |
kir | Kirsche | Cherry |
gtk | Gew. Traubenkirsche | Common Bird Cherry |
vk | Vogelkirsche | Wild Cherry / Bird Cherry |
stk | Spätbl. Traubenkirsche | Black Cherry |
pru | Sonstige Kirschen | Other Cherries |
zwe | Zwetschge | Damson |
hic | Hickory | Hickory |
soln | Sonstige Laubbäume mit niedriger Lebensdauer | Other deciduous trees with a short lifespan |
fau | Gemeiner Faulbaum / Pulverholz | Common Buckthorn / Powder Wood |
wob | Wildobst (unbestimmt) | Wild fruit (undefined) |
wap | Holzapfel / Wildapfel | Crab Apple / Wild Apple |
wbi | Holzbirne / Wildbirne | Wood Pear / Wild Pear |
has | Baumhasel | Turkish Hazel |
got | Gem. Götterbaum | Common Tree Of Heaven |
slbh | Sonstiges Hartlaubholz | Other Hardwood |
lbh | Laubholz | Hardwood |
slbw | Sonstiges Weichlaubholz | Other Softwood |
str | Strauch (unbestimmt) | Shrub (undefined) |
fita | Mischsortiment Fichte/Tanne | Mixed Assortment Spruce/Fir |