Developers

API and webhooks

Read and update a lab's colony records from a LIMS, an ELN or a script, and get a signed message when something changes.

Included with Lab Pro and higher plans, and Biotech Pro and higher.

Start

  1. In ConductColony, open Settings, then Integrations, and create an API key. Choose read only unless the system must change records. Copy the key: it is shown once.
  2. Check the key:
curl "https://conductcolony.com/api/v1/me" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"

The answer names your lab and the key. From here, every endpoint works the same way.

Authentication

Send the key in the Authorization header of every request:

Authorization: Bearer cck_your_key
  • A key belongs to one lab and reads all of that lab's records. In a core facility it reads every lab.
  • A read-only key can only read. A read and write key can also add and update animals and post genotyping results.
  • Only people who manage the lab's settings can create or revoke keys. A revoked key stops working at once.
  • Every change made with a key appears in the lab's Activity with the key's name.
  • Keep keys on a server. Never put one in a web page or a phone app.

Requests and paging

The base address is https://conductcolony.com/api/v1. Requests and answers are JSON. Field names are camelCase. Dates are ISO 8601 in UTC, such as 2026-10-11T15:04:05.123Z.

Two IDs matter. id is the record id ConductColony makes. animalId is your lab's own ID for an animal, the one on the cage card and the sample tube.

Lists return up to 50 rows, or up to 200 with limit, ordered by updatedAt with the oldest change first. When there is more, nextCursor is set: pass it as cursor to read the next page. On the last page it is null.

updatedSince returns only records changed at or after a time. To keep a copy in step, remember the newest updatedAt you received and ask from one minute before it next time.

Errors and limits

An error has an HTTP status, a stable code and a message that says how to fix it:

{
  "error": {
    "code": "VALIDATION",
    "message": "status must be one of: alive, dead, sacrificed, transferred, missing."
  }
}
StatusCodeMeaning
400VALIDATIONA parameter or a field is not valid. The message names it.
401API_KEY_MISSINGNo Authorization header.
401API_KEY_INVALIDThe key is malformed, unknown or revoked.
403TIER_FEATURE_LOCKEDThe lab's plan has no API access.
403READ_ONLY_KEYA read-only key tried to change something.
403READ_ONLY_MODEThe lab's subscription is canceled, so its records are read-only.
404NOT_FOUNDNo such record in this lab.
409CONFLICTThe animal ID is already used in this lab.
429RATE_LIMITEDToo many requests. Wait for the seconds in Retry-After.
500INTERNALSomething went wrong on our side. Try again.

Each key may make 120 requests a minute. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Over the limit, the answer is 429 with Retry-After in seconds.

Endpoints

Paths follow the base address. The same list, with every field, is in the OpenAPI document.

GET/meCheck a key

Returns the organization and the key behind the request. Use it to check that a key works.

curl "https://conductcolony.com/api/v1/me" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"
Answer
{
  "data": {
    "organization": {
      "id": "cmg1org0000",
      "name": "Rivera Lab",
      "type": "lab",
      "plan": "pro",
      "planName": "Lab Pro"
    },
    "key": {
      "id": "cmg1key0000",
      "name": "Genotyping LIMS",
      "scope": "read_write"
    },
    "apiVersion": "2026-10-11"
  }
}

GET/animalsList animals

Every animal of the lab, whatever its status, oldest change first.

status
One of alive, dead, sacrificed, transferred, missing.
strainId
Animals of one strain (record id).
cageId
Animals in one cage (record id).
animalId
The animal with this lab animal ID, such as M-2026-0142.
limit
Rows per page, 1 to 200. Default 50.
cursor
The nextCursor of the page before.
updatedSince
Only records changed at or after this time (ISO 8601).
curl "https://conductcolony.com/api/v1/animals?status=alive&limit=100" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"
Answer
{
  "data": [
    {
      "id": "cmg1animal0000",
      "animalId": "M-2026-0142",
      "sex": "female",
      "status": "alive",
      "species": "mouse",
      "dateOfBirth": "2026-08-14T00:00:00.000Z",
      "dateOfDeath": null,
      "strain": {
        "id": "cmg1strain0000",
        "name": "Pten flox"
      },
      "cage": {
        "id": "cmg1cage0000",
        "cageNumber": "C-014"
      },
      "protocol": {
        "id": "cmg1protocol00",
        "protocolNumber": "IACUC-2026-001"
      },
      "notes": null,
      "createdAt": "2026-09-04T15:20:11.000Z",
      "updatedAt": "2026-10-09T18:02:44.000Z"
    }
  ],
  "nextCursor": "eyJ0IjoiMjAyNi0xMC0wOVQxODowMjo0NC4wMDBaIiwiaSI6ImNtZzFhbmltYWwwMDAwIn0"
}

POST/animalsAdd an animal

Needs a key with read and write access.

Only sex is required. Leave animalId out and ConductColony makes one. Strain, cage, protocol and lab are record ids of your own lab.

animalId
Your lab's ID for the animal. Must be new in the lab.
sex
male, female or unknown. Required.
dateOfBirth
ISO 8601 date.
strainId, cageId, protocolId
Record ids.
earTag, coatColor, weight, notes
weight is in grams.
curl -X POST "https://conductcolony.com/api/v1/animals" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"animalId": "M-2026-0150", "sex": "male", "dateOfBirth": "2026-09-30", "cageId": "cmg1cage0000"}'

GET/animals/{id}Read an animal

One animal with its genotype results. {id} is the record id, not the lab's animal ID.

curl "https://conductcolony.com/api/v1/animals/cmg1animal0000" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"

PATCH/animals/{id}Update an animal

Needs a key with read and write access.

Changes only the fields you send. A new cageId is logged as a move. Status sacrificed is logged as a sacrifice and, with no dateOfDeath, records today. The animal ID cannot be changed.

status
One of alive, dead, sacrificed, transferred, missing.
cageId, protocolId, strainId
Record ids. null clears the link.
dateOfDeath, dateOfBirth
ISO 8601 date, or null.
notes, earTag, coatColor, weight, sex
Send null to clear a value.
curl -X PATCH "https://conductcolony.com/api/v1/animals/cmg1animal0000" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "sacrificed", "dateOfDeath": "2026-10-10", "notes": "Tissue collected"}'

GET/genotypesList genotype results

animalId
Results for the animal with this lab animal ID.
locus
Results at one locus.
limit
Rows per page, 1 to 200. Default 50.
cursor
The nextCursor of the page before.
updatedSince
Only records changed at or after this time (ISO 8601).
curl "https://conductcolony.com/api/v1/genotypes?animalId=M-2026-0142" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"

POST/genotypesPost genotyping results

Needs a key with read and write access.

Up to 500 results a call. Each row names its animal by your lab's animal ID or by record id. Good rows are saved together and every row gets its own answer, so one bad row never blocks the rest. A result replaces the animal's earlier result at that locus.

animalId
Your lab's animal ID (the ID on the sample). Or use animalRecordId.
locus
Such as Cre or Pten.
alleles
Two alleles as a list, ["fl", "+"], or as text: "fl/+", "+/-", "HET", "Cre+".
method, testedDate, testedBy, confirmed
Optional.
curl -X POST "https://conductcolony.com/api/v1/genotypes" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"results": [
    {"animalId": "M-2026-0142", "locus": "Cre", "alleles": ["Cre", "+"], "method": "PCR"},
    {"animalId": "M-2026-0143", "locus": "Pten", "alleles": "fl/+"},
    {"animalId": "M-2026-9999", "locus": "Cre", "alleles": ["+", "+"]}
  ]}'
Answer
{
  "data": {
    "saved": 2,
    "failed": 1,
    "results": [
      {
        "index": 0,
        "status": "saved",
        "id": "cmg1genotype00"
      },
      {
        "index": 1,
        "status": "saved",
        "id": "cmg1genotype01"
      },
      {
        "index": 2,
        "status": "error",
        "error": "Animal not found (animalId M-2026-9999)."
      }
    ]
  }
}

GET/cagesList cages and tanks

Each cage carries aliveAnimals, the number of live animals in it now.

status
active, inactive, retired or quarantine.
kind
cage or tank.
strainId
Cages of one strain (record id).
limit
Rows per page, 1 to 200. Default 50.
cursor
The nextCursor of the page before.
updatedSince
Only records changed at or after this time (ISO 8601).
curl "https://conductcolony.com/api/v1/cages?status=active" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"

GET/cages/{id}Read a cage or tank

One cage with the live animals in it.

curl "https://conductcolony.com/api/v1/cages/cmg1cage0000" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"

GET/strainsList strains and lines

limit
Rows per page, 1 to 200. Default 50.
cursor
The nextCursor of the page before.
updatedSince
Only records changed at or after this time (ISO 8601).
curl "https://conductcolony.com/api/v1/strains" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"

GET/breeding-pairsList breeding pairs

Each pair names its male, female and cage.

status
active, retired, separated or failed.
limit
Rows per page, 1 to 200. Default 50.
cursor
The nextCursor of the page before.
updatedSince
Only records changed at or after this time (ISO 8601).
curl "https://conductcolony.com/api/v1/breeding-pairs?status=active" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"

GET/littersList litters

breedingPairId
Litters of one pair (record id).
limit
Rows per page, 1 to 200. Default 50.
cursor
The nextCursor of the page before.
updatedSince
Only records changed at or after this time (ISO 8601).
curl "https://conductcolony.com/api/v1/litters?updatedSince=2026-10-01" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"

GET/protocolsList protocols

status
active, expired, pending or suspended.
limit
Rows per page, 1 to 200. Default 50.
cursor
The nextCursor of the page before.
updatedSince
Only records changed at or after this time (ISO 8601).
curl "https://conductcolony.com/api/v1/protocols?status=active" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"

GET/eventsRead the event feed

The events webhooks send, oldest first, for systems that poll. An event appears about a minute after it happens.

after
An event id. Lists the events after it.
since
Only events at or after this time (ISO 8601).
types
Comma-separated event types, such as animal.created,genotype.recorded.
limit
Events per page, 1 to 200. Default 100.
curl "https://conductcolony.com/api/v1/events?after=evt_cmg1x2y3z0000&types=genotype.recorded" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"

GET/openapi.jsonThe OpenAPI document

The whole API as an OpenAPI 3.1 document, for generating a client or loading into an API tool. No key needed.

curl "https://conductcolony.com/api/v1/openapi.json"

Python example

Read every animal, page by page, with the requests library:

import os

import requests

BASE = "https://conductcolony.com/api/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["CONDUCTCOLONY_API_KEY"]}

animals, cursor = [], None
while True:
    params = {"limit": 200}
    if cursor:
        params["cursor"] = cursor
    response = requests.get(BASE + "/animals", headers=HEADERS, params=params, timeout=30)
    response.raise_for_status()
    page = response.json()
    animals += page["data"]
    cursor = page["nextCursor"]
    if not cursor:
        break

print(len(animals), "animals")

Webhooks

A webhook is an address of yours that ConductColony sends a message to when something changes. Add one in Settings, Integrations: an https:// address, the events to send, and a description. The signing secret is shown once, when the webhook is added.

Each message is a POST with a JSON body like this:

{
  "id": "evt_cmg1x2y3z0000",
  "type": "animal.created",
  "createdAt": "2026-10-11T15:04:05.123Z",
  "apiVersion": "2026-10-11",
  "data": {
    "action": "animal_added",
    "details": {
      "animalId": "M-2026-0150",
      "sex": "male",
      "strainId": "cmg1strain0000",
      "species": "mouse"
    },
    "animal": {
      "id": "cmg1animal0001",
      "animalId": "M-2026-0150"
    },
    "cage": {
      "id": "cmg1cage0000",
      "cageNumber": "C-014"
    }
  }
}
  • Events are small. They carry what the app logged for the change, and the animal and cage it names. Read the record from the API when you need the rest.
  • Headers: ConductColony-Signature, ConductColony-Event (the type), ConductColony-Delivery (the same for every retry of a delivery) and User-Agent: ConductColony-Webhooks/1.
  • Answer with any 2xx status within 10 seconds. Do slow work after you answer.
  • Anything else is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. After that the delivery is marked failed. You can send it again from Settings.
  • After 3 days of nothing but failures, the webhook is turned off and the lab gets an alert. Events that happen while a webhook is off are not sent later. Read them from the event feed.
  • A message can arrive twice, or out of order. Use the event id to skip one you have handled, and createdAt for order.
  • The address must be public. Private and local network addresses are refused, and redirects are not followed.
  • A webhook sends what happens from the moment it is added. Its first message is its own webhook.created event.
  • Send test event sends a webhook.test event at once, so you can check an address before real events arrive.

Event types

Animal
animal.createdAn animal was added.
animal.updatedAn animal's record was edited.
animal.movedAn animal moved to another cage or tank.
animal.sacrificedAn animal was marked as sacrificed.
animal.photo_updatedAn animal's ID photo was replaced.
animal.weighedA weight was logged for an animal.
animal.pain_category_changedAn animal's USDA pain category changed.
Genotype
genotype.recordedGenotype results were saved for one or more animals.
Cage
cage.createdA cage or tank was added.
cage.updatedA cage or tank was edited or moved to another lab.
cage.changedA cage change was recorded.
cage.decommissionedA cage or tank was taken out of use.
Strain
strain.createdA strain or line was added.
strain.updatedA strain or line was edited.
Breeding pair
breeding_pair.createdA breeding pair was set up.
breeding_pair.updatedA breeding pair was edited.
Litter
litter.createdA litter was recorded.
litter.updatedA litter was edited.
Clutch
clutch.recordedA clutch of eggs was recorded.
Protocol
protocol.createdA protocol was added.
protocol.updatedA protocol was edited.
protocol.allowances_updatedA protocol's approved numbers by species changed.
protocol.surgery_rule_updatedA protocol's survival surgery limit or interval changed.
Procedure
procedure.recordedA procedure was recorded.
procedure.updatedA procedure record was edited.
procedure.limit_overriddenA surgery went ahead past the protocol's limit, with a reason.
Health case
health_case.openedA health case was opened.
health_case.updatedA health case was updated.
health_case.closedA health case was closed.
health_case.photo_addedA photo was added to a health case.
Group
group.createdA group of animals (embryos, larvae or tadpoles) was added.
group.updatedA group was edited.
group.closedA group was closed.
group.count_changedA group's count changed.
group.genotype_recordedA genotype was recorded or changed for a group (a tank's line).
group.genotype_removedA genotype was removed from a group.
group.individuals_madeIndividual animal records were made from a counted group, such as frogs from tadpoles.
group.inventory_importedA tank inventory was imported as groups of fish.
group.inventory_import_undoneA tank inventory import was undone.
Location
location.createdA room, rack or other location was added.
System
system.createdAn aquatic system was added.
system.updatedAn aquatic system was edited.
Water reading
water_reading.recordedA water reading was logged.
Room check
room_check.recordedA room check (temperature, humidity, lights) was recorded.
Task template
task_template.createdA recurring task was set up.
task_template.updatedA recurring task was edited.
Census
census.takenThe daily census was taken.
census.correctedA census line was corrected.
census.checkedA room's census was checked by scanning cage cards.
Rate
rate.createdA per diem rate was added.
rate.updatedA per diem rate was edited.
Service charge
service_charge.createdA service charge was added.
Statement
statement.builtA month's statements were built.
statement.finalizedA statement was finalized.
statement.exportedA month's statements were exported.
Lab
lab.createdA lab was added to the facility.
lab.updatedA lab was edited.
lab.member_addedA person was added to a lab.
lab.member_removedA person was removed from a lab.
Member
member.invitedA person was invited to the organization.
member.joinedA person joined the organization by signing in through its institution.
Organization
organization.type_changedThe organization's type changed, for example to a core facility.
organization.trial_startedA free trial started.
organization.sign_in_changedThe organization's institution sign-in settings changed.
organization.facility_settings_updatedA core facility's settings changed.
Import
import.completedA spreadsheet import finished.
import.undoneAn import was undone.
Sample data
sample_data.clearedThe sample colony was cleared.
Breeding plan
breeding_plan.runA breeding plan was run.
Genetic flag
genetic_flag.viewedA genetic flag was opened.
Linnaeus
linnaeus.query_runA question was asked of Linnaeus.
linnaeus.action_confirmedSomeone confirmed changes that the Linnaeus assistant prepared.
API key
api_key.createdAn API key was created.
api_key.revokedAn API key was revoked.
Webhook
webhook.createdA webhook was added.
webhook.updatedA webhook was edited, turned on or turned off.
webhook.deletedA webhook was deleted.
webhook.secret_rolledA webhook's signing secret was replaced.
webhook.disabledA webhook was turned off after 3 days of failed deliveries.

Checking signatures

Check every message before you trust it. The ConductColony-Signature header is t=<unix seconds>,v1=<signature>. The signature is the HMAC-SHA256, in hex, of the timestamp, a dot and the body exactly as received, keyed with the whole signing secret (including whsec_). Refuse a message whose timestamp is more than 5 minutes old, and compare in constant time.

Node

const crypto = require("node:crypto");

// secret: the webhook's signing secret (whsec_...).
// rawBody: the request body exactly as received, as a Buffer or string.
function verifyConductColonySignature(secret, rawBody, header, toleranceSeconds = 300) {
  const fields = {};
  for (const part of String(header || "").split(",")) {
    const at = part.indexOf("=");
    if (at > 0) (fields[part.slice(0, at).trim()] ||= []).push(part.slice(at + 1).trim());
  }
  const timestamp = Number(fields.t && fields.t[0]);
  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(timestamp + ".")
    .update(rawBody)
    .digest();
  return (fields.v1 || []).some((given) => {
    const candidate = Buffer.from(given, "hex");
    return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
  });
}
A receiver that uses it
const http = require("node:http");

http
  .createServer((req, res) => {
    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => {
      const rawBody = Buffer.concat(chunks);
      const ok = verifyConductColonySignature(
        process.env.CONDUCTCOLONY_WEBHOOK_SECRET,
        rawBody,
        req.headers["conductcolony-signature"],
      );
      if (!ok) {
        res.writeHead(400).end("Bad signature");
        return;
      }
      const event = JSON.parse(rawBody.toString("utf8"));
      console.log(event.type, event.id);
      res.writeHead(200).end("OK");
    });
  })
  .listen(3000);

Python

import hashlib
import hmac
import time


def verify_conductcolony_signature(secret, raw_body, header, tolerance_seconds=300):
    """secret: the webhook's signing secret (whsec_...).
    raw_body: the request body as bytes, exactly as received."""
    fields = {}
    for part in (header or "").split(","):
        name, _, value = part.strip().partition("=")
        fields.setdefault(name, []).append(value)
    try:
        timestamp = int(fields["t"][0])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance_seconds:
        return False
    signed = str(timestamp).encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, given) for given in fields.get("v1", []))
A Flask receiver that uses it
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["CONDUCTCOLONY_WEBHOOK_SECRET"]


@app.post("/conductcolony")
def conductcolony_webhook():
    ok = verify_conductcolony_signature(
        SECRET, request.get_data(), request.headers.get("ConductColony-Signature")
    )
    if not ok:
        abort(400)
    event = request.get_json()
    print(event["type"], event["id"])
    return "", 200

Read the body as raw bytes before any JSON parsing. A body that was parsed and written out again will not match the signature.

Recipes

Send genotyping results from a LIMS or a genotyping service

  1. Create a key with read and write access for the system that holds the results.
  2. Label each sample with the animal ID from ConductColony, so a result can name its animal with animalId.
  3. When a plate is done, post its results to /genotypes, up to 500 a call.
  4. Read the answer row by row. Fix and resend the rows with an error; the saved rows are already on the animals.
import os

import requests

BASE = "https://conductcolony.com/api/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["CONDUCTCOLONY_API_KEY"]}

results = [
    {"animalId": "M-2026-0142", "locus": "Cre", "alleles": ["Cre", "+"], "method": "PCR"},
    {"animalId": "M-2026-0143", "locus": "Pten", "alleles": "fl/+", "testedDate": "2026-10-09"},
]
response = requests.post(BASE + "/genotypes", headers=HEADERS, json={"results": results}, timeout=60)
response.raise_for_status()
summary = response.json()["data"]
print(summary["saved"], "saved,", summary["failed"], "not saved")
for row in summary["results"]:
    if row["status"] == "error":
        print("Row", row["index"], row["error"])

Keep a LIMS or an ELN in step

  1. Load everything once: page through /animals, /cages and the other lists, and store each record under its id.
  2. Add a webhook for the events you follow, such as animal.created, animal.moved and genotype.recorded. When one arrives, check its signature and read the record it names from the API.
  3. Once a day, ask each list for updatedSince your last run, minus a minute. This catches anything a missed delivery left out.
curl "https://conductcolony.com/api/v1/animals?updatedSince=2026-10-10T05:59:00Z&limit=200" \
  -H "Authorization: Bearer $CONDUCTCOLONY_API_KEY"

Read the event feed instead of receiving webhooks

A system that cannot receive requests can poll. Ask /events for what came after the last event you handled, and keep that event's id for the next run.

import json
import os
import pathlib

import requests

BASE = "https://conductcolony.com/api/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["CONDUCTCOLONY_API_KEY"]}
STATE = pathlib.Path("last_event.json")

after = json.loads(STATE.read_text())["after"] if STATE.exists() else None
while True:
    params = {"limit": 200}
    if after:
        params["after"] = after
    response = requests.get(BASE + "/events", headers=HEADERS, params=params, timeout=30)
    response.raise_for_status()
    page = response.json()
    for event in page["data"]:
        print(event["createdAt"], event["type"], event["data"]["details"])
        after = event["id"]
    if after:
        STATE.write_text(json.dumps({"after": after}))
    if not page["nextCursor"]:
        break

Need something that is not here?

Tell us what your LIMS or ELN needs to exchange with ConductColony.

Contact us