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

Language:

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.

Security: Never hardcode or expose your API token in source code, bundles shipped to the browser, or public repositories. Always load it from an environment variable or a secrets manager (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).

Quota cost: most endpoints consume 1 unit per request. 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

EndpointRate limitQuota cost
GET /measurements20 req / 60 s1
GET /woodlists20 req / 60 s1
GET /measurements/overview30 req / 60 s1
GET /woodlists/overview30 req / 60 s1
GET /measurements/search60 req / 60 s (default)1
GET /woodlists/search60 req / 60 s (default)1
GET /images/:id60 req / 60 s3
GET /images/:id/:width/:height60 req / 60 s3
GET /versionno rate limit0

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.

Note: A 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
  }
}
FieldTypeDescription
dataarrayItems on this page
pagination.countintegerNumber of items in data (≤ limit)
pagination.limitintegerEchoes the requested limit
pagination.offsetintegerEchoes the requested offset
pagination.totalintegerTotal items matching the query, across all pages

Query parameters

ParameterTypeDefaultMaxDescription
limitinteger20100Items per page
offsetinteger0100000Number 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:

LibraryDefault resultFix
validator.js isUUID(id)rejectsUse isUUID(id, 'loose'); this disables the version/variant check
uuid npm validate(id)rejectsNo built in option; use isUUID(id, 'loose') from validator.js or the regex below
Java UUID.fromString()acceptsn/a
Python uuid.UUID(str)acceptsn/a
Go uuid.Parse()acceptsn/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
}
codeStatusMeaning
auth.invalid401API key missing, malformed, or unknown
plan.required402Account's plan has no monthly API allowance
ip.blocked403Token has an IP allowlist and the request IP isn't in it
quota.exceeded429Monthly quota exhausted; check the Retry-After header
validation.failed400Query or path parameter failed validation; see message for detail
not_found404The 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

GET /version

Returns current backend and API version. No rate limit and free of quota cost (0); safe to poll.

200 OK 401 Unauthorized 402 Payment Required

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());
});
GET /measurements

Returns all logpile measurements belonging to the authenticated user, paginated. Includes full detail: volumes, single logs, metadata.

200 OK 304 Not Modified 400 Bad Request 401 Unauthorized 402 Payment Required 403 Forbidden 429 Too Many Requests

Query parameters

ParameterTypeDefaultOptionsDescription
limitinteger201 through 100Items per page
offsetinteger00 through 100000Pagination offset
sortBystringcreatedAtcreatedAt updatedAt number speciesSort field
sortOrderstringASCASC DESCSort direction
updatedAfterISO 8601 datetimen/an/aOnly 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());
});
GET /measurements/overview

Lightweight alternative to /measurements. Returns compact logpile summaries without individual log sections; ideal for list views and map rendering.

200 OK 304 Not Modified 400 Bad Request 401 Unauthorized 402 Payment Required 403 Forbidden 429 Too Many Requests

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());
});
GET /woodlists

Returns all wood lists belonging to the authenticated user, including all nested logpile measurements, paginated.

200 OK 304 Not Modified 400 Bad Request 401 Unauthorized 402 Payment Required 403 Forbidden 429 Too Many Requests

Query parameters

ParameterTypeDefaultOptionsDescription
limitinteger201 through 100Items per page
offsetinteger00 through 100000Pagination offset
sortBystringcreatedAtcreatedAt updatedAt titleSort field
sortOrderstringASCASC DESCSort direction
updatedAfterISO 8601 datetimen/an/aOnly 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());
});
GET /woodlists/overview

Lightweight alternative to /woodlists. Returns compact wood list summaries without nested logpile detail.

200 OK 304 Not Modified 400 Bad Request 401 Unauthorized 402 Payment Required 403 Forbidden 429 Too Many Requests

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());
});
GET /images/:id

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.

200 OK 401 Unauthorized 402 Payment Required 403 Forbidden 404 Not Found 429 Too Many Requests

Path parameters

ParameterTypeDescription
idGUIDImage 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()));
});
GET /images/:id/:width/:height

Same as GET /images/:id, resized on the server to the given dimensions; use for thumbnails instead of downloading images at full resolution.

200 OK 401 Unauthorized 402 Payment Required 403 Forbidden 404 Not Found 429 Too Many Requests

Path parameters

ParameterTypeDescription
idGUIDImage blob id
widthintegerTarget width in px
heightintegerTarget 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()));
});

Data types

Measurement

Full logpile recording, returned by /measurements and /measurements/search.

FieldTypeDescription
idstringUUID
createdAtstringISO 8601 UTC
updatedAtstringISO 8601 UTC; use as the cursor for delta sync
logpileNumberstring | nullHuman visible logpile number
speciesWoodSpecies | null
assortmentWoodAssortment | null
varietyWoodVariety | null
logCountnumber | nullTotal number of logs
logpileWidthnumber | nullLogpile width in metres
latitudenumber | nullGPS latitude
longitudenumber | nullGPS longitude
notesstring | nullMax 500 chars
imagesstring[] | nullAbsolute image URLs
volumesVolume[] | nullVolume breakdown by quality class
singleLogsSingleLog[] | nullIndividual log data, present only on measurements with individual logs
metadataMeasurementMetadata | nullForestry metadata fields

WoodList

Full wood list including nested logpile measurements, returned by /woodlists and /woodlists/search.

FieldTypeDescription
idstringUUID
createdAtstringISO 8601 UTC
updatedAtstringISO 8601 UTC; use as the cursor for delta sync
titlestring | nullMax 65 chars
notesstring | nullMax 500 chars
speciesWoodSpecies | null
assortmentWoodAssortment | null
varietyWoodVariety | null
gradeWoodGrade | nullWood assortment grade
volumenumber | nullTotal m³ across all logpiles
logCountnumber | nullTotal log count
minDiameter / maxDiameternumber | nullcm
minLogLength / maxLogLengthnumber | nullmetres
minQuality / maxQualityWoodQuality | null
latitude / longitudenumber | nullCentroid of all logpile coordinates
imagesstring[] | nullAbsolute image URLs
logpilesMeasurement[] | nullAll nested logpile measurements

LogpileShort (overview endpoint)

FieldTypeDescription
idstringUUID
numberstringHuman visible logpile number
assortmentWoodAssortment | null
gradeWoodAssortmentFrontend | nullDespite the name, this is the assortment enum, not WoodGrade
volumenumber | null
cubicMeternumber | nullRm; only set for measurements of type ARM
logCountnumber | null
logLengthnumber | nullAverage in metres
logLengthRange{ min, max } | nullmetres
logDiameter{ min, max } | nullcm
locationWoodLocationDto | nullStreet, house number, postcode, city, and coordinate: { latitude, longitude }; see WoodLocationDto
notesstring | null

WoodListShort (overview endpoint)

FieldTypeDescription
idstringUUID
titlestring | null
speciesWoodSpecies | null
assortmentWoodAssortment | null
varietyWoodVariety | null
gradeWoodGrade | null
logLengthnumber | nullAverage in metres
volumenumber | nullTotal m³
logLengthRange{ min, max } | nullmetres
logDiameter{ min, max } | nullcm
quality{ min, max } | nullWoodQuality values
locationWoodLocationDto | nullCity and coordinate / approximateCoordinate: { latitude, longitude }; see WoodLocationDto
image{ id, width?, height? } | nullTitle image reference; fetch the binary via GET /images/:id using id
Note: the internal representation also carries a 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.

FieldTypeDescription
numbernumberSort index
qualityWoodQuality
logLengthnumbermetres
volumenumber
logCountnumber | 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.

FieldTypeDescription
streetstring
houseNumberstring
postcodestring
citystring
coordinate{ latitude, longitude }Exact GPS coordinate
approximateCoordinate{ latitude, longitude }Obfuscated GPS coordinate (privacy)

MeasurementMetadata

All fields are string | null, max 100 chars each.

FieldField
forestOwnerlocationStreet
foresterlocationHouseNumber
serviceProviderlocationPostcode
buyerlocationCity
hiebsNumberlocationCountry
areaNumberinventoryUnit
lotNumberdivision
subdivision

Enum reference

WoodAssortment
ValueMeaning
StammholzRound timber
EnergieholzEnergy wood
IndustrieholzIndustrial wood
SondersortimentSpecial assortment
PalettenholzPallet wood
OSBOSB
WoodAssortmentFrontend
ValueCorresponds to
ch_sWoodAssortment.Stammholz
ch_eWoodAssortment.Energieholz
ch_iWoodAssortment.Industrieholz
ch_rWoodAssortment.Sondersortiment
allAll assortments selected
WoodVariety
ValueMeaning
LangLong
KurzShort
AbschnitteSections
WaldhackschnitzelForest chips
AndereOther
WoodQuality
ValueStandard
a b c dDE quality classes
ch_ab ch_abcCH quality classes
bc bcd cdCombined
inIndustrial, normal
ikIndustrial, diseased
ifIndustrial, defective
nf nfk fkIndustrial combined
WoodGrade
ValueMeaning
stStammholz lang
flStammholz Abschnitte
ilIndustrieholz lang
isIndustrieholz kurz
blEnergieholz lang
bsEnergieholz kurz
s_hs e_hs i_hsHackschnitzel
ol os o_fl o_hs oSondersortiment
pa_l pa_s pa_hsPalettenholz

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.

ValueDEEN
xyLaub- & NadelholzHardwood and Softwood
ndhNadelholzSoftwood
fiFichteSpruce
gfiGemeine FichteCommon Spruce
ofiOmorikafichteSerbian Spruce
sfiSitkafichteSitka Spruce
swfiSchwarzfichteBlack Spruce
efiEngelmannsfichteEngelmann Spruce
bfiBlaufichte/StechfichteBlue Spruce / Norway Spruce
wfiWeißfichteWhite Spruce
sofiSonstige FichtenOther Spruce
kieKieferPine
kiGemeine KieferCommon Pine
bkiBergkieferMountain Pine
skiSchwarzkieferBlack Pine
rkiRumelische KieferBalkan Pine
zkiZirbelkieferStone Pine
wkiWeymouthskieferWeymouth Pine
mkiMurraykieferMurray Pine
gkiGelbkieferPonderosa Pine
sokiSonstige KieferOther Pine
taTanneFir
wtaWeißtanneSilver Fir
ataAmerikanische EdeltanneAmerican Silver Fir
ctaColoradotanneColorado Fir
ktaKüstentanneCoastal Fir
nitaNikkotanneNikko Fir
notaNordmannstanneNordmann Fir
vtaVeitchtanneSt. Vitus Fir
sotaSonstige TannenOther Firs
dglDouglasieDouglas Fir
laLärcheLarch
elaEuropäische LärcheEuropean Larch
jlaJapanische Lärche HybridJapanese Larch Hybrid
slaSonstige LärchenOther Larches
sonbSonstige NadelbäumeOther Conifers
lbLebensbaumArborvitae / Thuja
htHemlockstanneHemlock
mamMammutbaumSequoia / Redwood
eibEibeYew
szLawsonszypresseLawson Cypress
buBucheBeech
seiStieleicheCommon Oak
teiTraubeneicheSessile Oak
reiRoteicheRed Oak
zeiZerreicheTurkey Oak
sueiSumpfeicheSwamp Oak
eiEicheOak
quesonstige EichenOther Oaks
esEscheAsh
gesGemeine EscheCommon Ash
wesWeißescheWhite Ash
fraSonstige EschenOther Ash
hbuHainbuche / WeißbucheHornbeam
ahAhornMaple
bahBergahornSycamore Maple
sahSpitzahornNorway Maple
fahFeldahornField Maple
eahEschenblättriger AhornAsh-leaved Maple
siahSilberahornSilver Maple
aceSonstige AhorneOther Maples
liLindeLime
wliWinterlindeSmall-Leaved Lime
sliSommerlindeSummer Lime
tilSonstige LindenOther Lime Trees
robRobinieBlack Locust
akzAkazieAcacia
ulUlmeElm
bulBergulmeWych Elm
fulFeldulmeField Elm
fluFlatterulmeWhite Elm
ulmSonstige UlmenOther Elms
rkaRosskastanieHorse Chestnut
ekaEdelkastanieSweet Chestnut
kaKastanieChestnut
mauWeißer MaulbeerbaumWhite Mulberry
nusNussbaumWalnut
wnuWalnussWalnut
snuSchwarznuss HybridBlack Walnut Hybrid
jugSonstige NussbäumeOther Nut Trees
steStechpalmeHolly
plaPlataneSycamore
aplAhornblättrige PlataneLondon Plane
solhSonstige Laubbäume mit hoher LebensdauerOther deciduous trees with a long lifespan
gbiGemeine BirkeCommon Birch
mbiMoorbirke / KarpatenbirkeBog Birch / Carpathian Birch
biBirkeBirch
erlErleAlder
serSchwarzerleBlack Alder
werWeißerle / GrauerleWhite Alder / Gray Alder
gerGrünerleGreen Alder
alnSonstige ErlenOther Alders
papPappelPoplar
zpaAspe / ZitterpappelAspen / Trembling Poplar
spaEuropäische SchwarzpappelEuropean Black Poplar
spahSchwarzpappel HybridBlack Poplar Hybrid
gpaGraupappel HybridGray Poplar Hybrid
wpaSilberpappel / WeißpappelSilver Poplar / White Poplar
bpaBalsampappelBalsam Poplar
bpahBalsampappel HybridBalsam Poplar Hybrid
popSonstige PappelnOther Poplars
sorSorbusartenSorbus Species
ssoSonstige SorbusartenOther Sorbus Species
vbVogelbeereRowan Berry
elsElsbeereServiceberry
speSpeierlingService Tree
mebEchte MehlbeereReal Serviceberry
weiWeideWillow
sweiSalweideSal Willow
kirKirscheCherry
gtkGew. TraubenkirscheCommon Bird Cherry
vkVogelkirscheWild Cherry / Bird Cherry
stkSpätbl. TraubenkirscheBlack Cherry
pruSonstige KirschenOther Cherries
zweZwetschgeDamson
hicHickoryHickory
solnSonstige Laubbäume mit niedriger LebensdauerOther deciduous trees with a short lifespan
fauGemeiner Faulbaum / PulverholzCommon Buckthorn / Powder Wood
wobWildobst (unbestimmt)Wild fruit (undefined)
wapHolzapfel / WildapfelCrab Apple / Wild Apple
wbiHolzbirne / WildbirneWood Pear / Wild Pear
hasBaumhaselTurkish Hazel
gotGem. GötterbaumCommon Tree Of Heaven
slbhSonstiges HartlaubholzOther Hardwood
lbhLaubholzHardwood
slbwSonstiges WeichlaubholzOther Softwood
strStrauch (unbestimmt)Shrub (undefined)
fitaMischsortiment Fichte/TanneMixed Assortment Spruce/Fir