Crossling
On this page

Developers · BrAPI v2

BrAPI v2: trials, plots, traits and scores

The BrAPI calls Crossling answers, for Field Book, GridScore and your own scripts. How to sign in, filter and page, send scores back, and what the errors mean.

Updated

BrAPI is the open standard that breeding tools use to talk to each other. Crossling speaks BrAPI 2.1 (and answers clients that ask for 2.0). Field Book and GridScore use it to load trials and send scores back, and you can use it from your own scripts.

The base address is:

https://crossling.app/brapi/v2/

Every path on this page is under it. A trailing slash is optional.

How Crossling maps to BrAPI#

BrAPIIn Crossling
ProgramA breeding program you're a member of
TrialA trial, across all its locations
StudyOne trial at one location: a site. Field Book calls it a field.
LocationA research station or a field in it
SeasonA season and year, like Summer 2026
GermplasmA line, variety or check
Observation variableA trait from the trait bible: the trait, how it's scored, and its scale
Observation unitA plot, or a plant or subplot in one
ObservationA score

Ids are UUIDs, except a season's, which is its name and year (Summer 2026). A plot's observationUnitName is the code on its stake or tag: LAC-112 for plot 112 at Lacombe, LAC-112-P7 for plant 7 in it. The code is unique within a study, but other trials at the same location have a LAC-112 too.

Sign in with an access key#

Make a key on the Connect page (More › Field Book in the app):

  1. Under Keys, name it after the phone, app or script it's for.
  2. Choose how long it works: 30 days, 90 days or a year.
  3. Tap Make a key and copy it. Crossling keeps only a hash, so the key is shown this once.

Send it with every call, in the Authorization header:

Shell
curl -H "Authorization: Bearer $CROSSLING_KEY" https://crossling.app/brapi/v2/studies

A key sees exactly what you see in Crossling: your programs' trials, lines and traits. Another program's trial looks the same as one that doesn't exist. Scores sent with the key are saved as scored by you.

The Connect page lists your keys, with when each was last used and what its last sync sent. Revoke a key there when a phone is lost or a script is retired: the next call with it gets a 401. You can have 20 working keys at once.

The demo. On the demo, keys work for a day and are wiped at the nightly reset (08:00 UTC). Everyone signed in to the demo shares one account, so they see the same keys.

Browser apps#

BrAPI answers calls from web pages on any site, so a browser app like GridScore can call it directly. Keys only ever travel in the Authorization header: Crossling's BrAPI doesn't use cookies.

Browser sign-in for Field Book#

Field Book can sign in through the browser instead of a pasted key: its Authorize button. Crossling answers with OAuth 2's authorization code flow with PKCE (S256), found through OpenID Connect discovery:

Shell
curl https://crossling.app/.well-known/openid-configuration
Response
{
  "issuer": "https://crossling.app",
  "authorization_endpoint": "https://crossling.app/connect/authorize",
  "token_endpoint": "https://crossling.app/brapi/oauth/token",
  "jwks_uri": "https://crossling.app/brapi/oauth/jwks",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["none"],
  "scopes_supported": ["openid", "profile", "brapi"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"],
  "service_documentation": "https://crossling.app/developers/brapi/"
}
  1. The app opens /connect/authorize in the browser with response_type=code, client_id, redirect_uri, code_challenge, code_challenge_method=S256 and state.
  2. The person signs in to Crossling if they need to, checks the name and programs shown, and taps Allow.
  3. The browser goes back to the app with ?code=…&state=…. The code works once, for five minutes.
  4. The app swaps the code, with the secret it made in step 1, for an access key:
Shell
curl -X POST https://crossling.app/brapi/oauth/token \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri=fieldbook://app/auth \
  -d code_verifier="$CODE_VERIFIER"
Response
{
  "access_token": "crl_EXAMPLE_00000000000000000000000000000000000",
  "token_type": "Bearer",
  "expires_in": 31535999,
  "scope": "openid brapi"
}

The body can be a form (as above) or JSON. A code that's wrong, used or out of date gets 400 with {"error": "invalid_grant", "error_description": "…"}, OAuth's usual shape.

Some things to know:

  • Only Field Book, for now. Crossling sends the browser back only to Field Book's address, fieldbook://app/auth. Other apps and scripts use an access key.
  • The key is an ordinary access key. It works for a year, shows on the Connect page as signed in from the app, and can be revoked there.
  • There's no ID token and no refresh token. When the key runs out, sign in again.
  • In Field Book, set OIDC Flow to OAuth2 Authorization Code. The implicit flow isn't used, and the sign-in page says so. The Connect page walks through the whole Field Book setup.

Requests and answers#

The answer's shape#

Every answer is wrapped the BrAPI way: metadata (paging and messages) around the result. A list's rows are in result.data:

JSON
{
  "@context": ["https://brapi.org/jsonld/context/metadata.jsonld"],
  "metadata": {
    "datafiles": [],
    "pagination": {"currentPage": 0, "pageSize": 1000, "totalCount": 3, "totalPages": 1},
    "status": []
  },
  "result": {
    "data": ["Canola", "Potato", "Wheat"]
  }
}

A single item (GET /studies/{studyDbId}) is the result itself, with no data.

Paging#

ParameterWhat it does
pageWhich page, counting from 0
pageSizeRows per page: 1,000 if you don't say, 5,000 at most

metadata.pagination gives totalCount and totalPages. Ask for the next page until page reaches totalPages. A pageSize over 5,000 is cut to 5,000, and the pagination says so.

Filters#

Filters go in the query string, one name per filter: ?studyDbId=…&observationVariableDbId=…. Repeat a filter to give it several values: ?studyDbId=A&studyDbId=B returns either.

  • Ids must match exactly. An id that isn't yours, or isn't an id, matches nothing: you get an empty list, not an error.
  • Names (studyName, programName, germplasmName and the like) match the whole name, ignoring case.
  • Yes or no filters, like active, take true or false.

Each POST /search/{thing} takes the same filters as a JSON body, with plural names and lists of values, and page and pageSize:

JSON
{
  "studyDbIds": ["your-studyDbId"],
  "observationLevels": [{"levelName": "plot"}],
  "includeObservations": true,
  "page": 0,
  "pageSize": 1000
}

A search answers straight away with the results (200). There's never a searchResultsDbId to fetch later, so GET /search/{thing}/{searchResultsDbId} is always a 404.

Calls#

These are every call Crossling answers. The server lists the same in 0.

Server#

CallReturns
GET /serverinfoThe server's name, and every call it answers. No key needed.
GET /commoncropnamesThe crops your programs grow, as names (Wheat)

Programs#

CallReturns
GET /programsYour programs: name, abbreviation, crop and lead
GET /programs/{programDbId}One program
POST /search/programsPrograms, filtered by a JSON body

Filters: programDbId, programName, abbreviation, commonCropName.

Trials#

CallReturns
GET /trialsYour trials, the latest start first. additionalInfo.stage is the trial's stage.
GET /trials/{trialDbId}One trial
POST /search/trialsTrials, filtered by a JSON body

Filters: trialDbId, programDbId, trialName, commonCropName, locationDbId, studyDbId, active.

Studies#

A study is one trial at one location.

CallReturns
GET /studiesYour studies, the newest season first, with their traits (observationVariableDbIds) and the levels their plots come in
GET /studies/{studyDbId}One study
POST /search/studiesStudies, filtered by a JSON body
GET /studytypesThe trial stages, which are each study's studyType

Filters: studyDbId, trialDbId, programDbId, locationDbId, seasonDbId, studyName, trialName, programName, locationName, commonCropName, germplasmDbId, observationVariableDbId, active.

active=true keeps studies still being scored: draft, active or harvested, in a trial that's still active.

Locations and seasons#

CallReturns
GET /locationsYour programs' locations, and the ones your trials are at. coordinates is a GeoJSON point (longitude, latitude, altitude).
GET /locations/{locationDbId}One location
POST /search/locationsLocations, filtered by a JSON body
GET /seasonsThe seasons your trials are in, the latest first
GET /seasons/{seasonDbId}One season

Location filters: locationDbId, locationName, abbreviation. Season filters: seasonDbId, seasonName, year.

A season's id has a space in it, so write it as Summer%202026 in an address.

Germplasm#

CallReturns
GET /germplasmYour programs' lines, and public ones (released varieties and checks), with pedigree, origin and synonyms
GET /germplasm/{germplasmDbId}One line
POST /search/germplasmLines, filtered by a JSON body

Filters: germplasmDbId, germplasmName, accessionNumber, germplasmPUI, programDbId, commonCropName, studyDbId, trialDbId, synonym.

A line's germplasmPUI is its page in Crossling. synonym matches part of a synonym, ignoring case. studyDbId and trialDbId keep the lines planted in them.

Traits#

Traits are BrAPI's observation variables: each is a trait, a method and a scale.

CallReturns
GET /variablesThe traits you can see, with their scales
GET /variables/{observationVariableDbId}One trait
POST /search/variablesTraits, filtered by a JSON body

Filters: observationVariableDbId, observationVariableName, traitClass, traitDbId, methodDbId, scaleDbId, commonCropName, studyDbId, trialDbId.

  • observationVariableName matches the trait's name or its abbreviation (HT).
  • With studyDbId or trialDbId, you get only that trial's traits, and additionalInfo.observationLevelNames says what each is scored on there: plot, plant or subplot.
  • scale.validValues has the range: minimumValue and maximumValue exactly ("8.5"), min and max as whole numbers. For a 0–9 or 1–9 score, categories lists every level, labelled where the trait bible describes it.
  • synonyms starts with the abbreviation. trait.traitDescription says what the trait is, how and when it's scored.

Plots and plants#

Plots, and plants or subplots in them, are BrAPI's observation units.

CallReturns
GET /observationlevelsThe levels units come in: plot, and subplot and plant where there are any
GET /observationunitsPlots and plants, in plot order
GET /observationunits/{observationUnitDbId}One unit, with its current scores
POST /search/observationunitsUnits, filtered by a JSON body

Filters: observationUnitDbId, studyDbId, trialDbId, programDbId, germplasmDbId, locationDbId, seasonDbId, seedLotDbId, observationLevel, observationUnitName, includeObservations.

  • observationLevel is plot, subplot or plant. In a search body, it's "observationLevels": [{"levelName": "plot"}].
  • observationUnitName is the code on the stake: LAC-112, or LAC-112-P7 for a plant. Give studyDbId as well, since other trials at the same location use the same codes.
  • includeObservations=true adds each unit's current scores.
  • GET /observationlevels takes studyDbId, trialDbId or programDbId, to give the levels those trials have.

Each unit says where it is: positionCoordinateX is the column and positionCoordinateY the row, rep and block are in observationLevelRelationships, entryType is CHECK or TEST, and additionalInfo has the plot and entry numbers. Mapped plots carry their outline in geoCoordinates.

Scores#

Scores are BrAPI's observations.

CallReturns
GET /observationsThe current score for each plot and trait
GET /observations/{observationDbId}One score, current or not
POST /observationsSaves new scores (Sending scores)
PUT /observationsChanges scores, keyed by observationDbId
PUT /observations/{observationDbId}Changes one score
POST /search/observationsScores, filtered by a JSON body

Filters: observationDbId, observationUnitDbId, observationVariableDbId, studyDbId, trialDbId, programDbId, germplasmDbId, locationDbId, seasonDbId, observationLevel, observationTimeStampRangeStart, observationTimeStampRangeEnd.

  • A plot keeps every score it's had, and the latest is the one that counts. Lists give only that one. Ask for scores by observationDbId to get older ones too.
  • The time filters take an ISO 8601 date and time, like 2026-07-01T00:00:00Z. A time with no zone is read as UTC.
  • collector is the name of the person who scored it, and additionalInfo.notes holds a note if there is one.

Sending scores#

POST /observations saves new scores. Send a list (or {"observations": [...]}):

JSON
[
  {
    "observationUnitDbId": "your-observationUnitDbId",
    "observationVariableDbId": "your-observationVariableDbId",
    "value": "97",
    "observationTimeStamp": "2026-07-20T10:12:00Z",
    "additionalInfo": {"notes": "lodged at the edge"},
    "externalReferences": [{"referenceId": "row-17", "referenceSource": "my-script"}]
  }
]
FieldWhat it does
observationUnitDbIdThe plot, or the plant or subplot. Required.
observationVariableDbIdThe trait. Required, and it must be one of that trial's traits.
valueThe score, as text or a number. NA, n/a or empty saves nothing for that plot.
observationTimeStampWhen it was scored, in ISO 8601. Kept as the score's time; with no zone it's UTC. Left out, unreadable or in the future, it's the time the score arrives.
additionalInfo.notesA note on the score
externalReferencesSent back as they came, so you can match each answer to your own row

Everything else in the item is ignored. The scorer is always the person whose key it is.

The checks#

Each score is checked the same way as scoring in the app, against the trait's scale in the trait bible:

  • Numbers must be within the trait's lowest and highest values. They're rounded to its decimal places, and a decimal comma is fine.
  • 0–9 and 1–9 scores must be whole numbers in the range.
  • Categories must be one of the trait's values.
  • Dates are best written 2026-07-14.
  • Traits scored on each plant go on the plants. A plot that has plants for that trait won't take it.

What comes back#

The good scores are saved and the others aren't, as in the app. The answer is 200, with:

  • the saved scores in result.data, in the order you sent them, each with its observationDbId and your externalReferences;
  • in metadata.status, an INFO message saying how many were saved, and an ERROR message for each score that wasn't, saying why. Fix those and send them again.
Part of an answer
{
  "metadata": {
    "status": [
      {"message": "Saved 1 observation", "messageType": "INFO"},
      {"message": "1 marked NA or empty: nothing saved for those", "messageType": "INFO"},
      {"message": "2026 Advanced Yield Trial – Edmonton, Plot 101: YR is a whole-number score from 0 to 9; “12” isn't",
       "messageType": "ERROR"}
    ]
  }
}
  • Sending the same score again (same value and note) saves nothing new: you get the score already there. A retried sync is safe.
  • A score taken before the current one goes into the plot's history, and a WARNING says which score stays.
  • Nothing saved, and something wrong: the answer is 400 with the first five reasons, or 404 if every score named a plot or trait that isn't found.

With an access key, the last sync's count and its problems also show on the Connect page, beside the key.

Changing a score#

PUT /observations takes an object keyed by observationDbId; PUT /observations/{observationDbId} takes one score. Send the new value, and a new observationTimeStamp if you have one:

JSON
{"your-observationDbId": {"value": "5", "observationTimeStamp": "2026-07-21T09:00:00Z"}}

The change is saved as a new score on the same plot and trait, and the old one stays in its history. A score can't move to another plot or trait: send that as a new score. The checks are the same, and so is the answer.

Examples#

Set your key once in the shell, and the examples below pick it up:

Shell
export CROSSLING_KEY='crl_EXAMPLE_00000000000000000000000000000000000'

Find a trial's sites#

curl
curl -H "Authorization: Bearer $CROSSLING_KEY" \
  "https://crossling.app/brapi/v2/studies?active=true&pageSize=100"
Python
import os

import requests

BASE = "https://crossling.app/brapi/v2"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['CROSSLING_KEY']}"


def get_all(path, **params):
    """Every row of a list, a page at a time."""
    page = 0
    while True:
        r = session.get(f"{BASE}/{path}", timeout=60,
                        params={**params, "page": page, "pageSize": 1000})
        r.raise_for_status()
        body = r.json()
        yield from body["result"]["data"]
        page += 1
        if page >= body["metadata"]["pagination"]["totalPages"]:
            break


for study in get_all("studies", active="true"):
    print(study["studyDbId"], study["studyName"])
R
library(httr2)

resp <- request("https://crossling.app/brapi/v2/studies") |>
  req_url_query(active = "true", pageSize = 100) |>
  req_auth_bearer_token(Sys.getenv("CROSSLING_KEY")) |>
  req_perform()

studies <- resp_body_json(resp, simplifyVector = TRUE)$result$data
studies[, c("studyDbId", "studyName", "locationName")]

Get a site's plots, with their scores#

curl
curl -X POST https://crossling.app/brapi/v2/search/observationunits \
  -H "Authorization: Bearer $CROSSLING_KEY" \
  -H "Content-Type: application/json" \
  -d '{"studyDbIds": ["your-studyDbId"],
       "observationLevels": [{"levelName": "plot"}],
       "includeObservations": true}'
Python
# uses session and get_all from the example above
study = "your-studyDbId"
for unit in get_all("observationunits", studyDbId=study,
                    observationLevel="plot", includeObservations="true"):
    scores = {o["observationVariableName"]: o["value"] for o in unit["observations"]}
    print(unit["observationUnitName"], unit["germplasmName"], scores)

Download a site's scores#

Python
import csv

# uses session and get_all from the first example
study = "your-studyDbId"
with open("scores.csv", "w", newline="") as f:
    out = csv.writer(f)
    out.writerow(["plot", "line", "trait", "value", "scored"])
    for o in get_all("observations", studyDbId=study):
        out.writerow([o["observationUnitName"], o["germplasmName"],
                      o["observationVariableName"], o["value"], o["observationTimeStamp"]])
R
library(httr2)

study <- "your-studyDbId"
resp <- request("https://crossling.app/brapi/v2/observations") |>
  req_url_query(studyDbId = study, pageSize = 5000) |>
  req_auth_bearer_token(Sys.getenv("CROSSLING_KEY")) |>
  req_perform()

scores <- resp_body_json(resp, simplifyVector = TRUE)$result$data
keep <- c("observationUnitName", "germplasmName", "observationVariableName", "value")
write.csv(scores[, keep], "scores.csv", row.names = FALSE)

A site with more than 5,000 scores needs more than one page: in R, ask again with page = 1, page = 2 and so on, until you reach metadata$pagination$totalPages.

Send a score#

curl
curl -X POST https://crossling.app/brapi/v2/observations \
  -H "Authorization: Bearer $CROSSLING_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"observationUnitDbId": "your-observationUnitDbId",
        "observationVariableDbId": "your-observationVariableDbId",
        "value": "4", "observationTimeStamp": "2026-07-20T10:12:00Z"}]'
Python
# uses session and BASE from the first example
r = session.post(f"{BASE}/observations", json=[{
    "observationUnitDbId": "your-observationUnitDbId",
    "observationVariableDbId": "your-observationVariableDbId",
    "value": "4",
    "observationTimeStamp": "2026-07-20T10:12:00Z",
}], timeout=60)
if r.status_code == 200:
    for m in r.json()["metadata"]["status"]:
        print(m["messageType"], m["message"])
else:
    print(r.status_code, r.json())  # the reason, as a string

Errors#

An error's body is a JSON string with the time and the reason, the way the BrAPI spec writes them:

JSON
"ERROR - 2026-11-08T16:04:16Z - The requested object DbId is not found"
StatusWhen
400A filter or body Crossling can't use: pageSize must be at least 1, a time it can't read, Malformed JSON Request Object, an unknown observationLevel, or scores that all failed their checks
401No key, or one that's wrong, revoked or run out: Missing or expired authorization token. The answer carries WWW-Authenticate: Bearer realm="brapi".
404Something that isn't there or isn't yours, or a call this server doesn't answer (the reason points you to /serverinfo)
405A method the call doesn't take, like DELETE
415A body that isn't JSON
429Too many calls. Wait the number of seconds in the Retry-After header.

The browser sign-in's token address answers in OAuth's shape instead: {"error": "invalid_grant", "error_description": "…"}.

Limits#

WhatLimit
Calls10,000 an hour per person, across all their keys. Calls without a key (/serverinfo) are counted per address.
Page size1,000 rows by default, 5,000 at most
Working keys20 per person. Revoke one to make another.
Making keys30 an hour per person
Browser sign-in60 code swaps an hour per address

A big Field Book sync pages through thousands of scores and stays well inside these. If a script does hit one, the answer is 429 with Retry-After: wait that many seconds, then carry on.