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.
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."
}
}
Status
Code
Meaning
400
VALIDATION
A parameter or a field is not valid. The message names it.
401
API_KEY_MISSING
No Authorization header.
401
API_KEY_INVALID
The key is malformed, unknown or revoked.
403
TIER_FEATURE_LOCKED
The lab's plan has no API access.
403
READ_ONLY_KEY
A read-only key tried to change something.
403
READ_ONLY_MODE
The lab's subscription is canceled, so its records are read-only.
404
NOT_FOUND
No such record in this lab.
409
CONFLICT
The animal ID is already used in this lab.
429
RATE_LIMITED
Too many requests. Wait for the seconds in Retry-After.
500
INTERNAL
Something 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.
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.
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+".
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:
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.created
An animal was added.
animal.updated
An animal's record was edited.
animal.moved
An animal moved to another cage or tank.
animal.sacrificed
An animal was marked as sacrificed.
animal.photo_updated
An animal's ID photo was replaced.
animal.weighed
A weight was logged for an animal.
animal.pain_category_changed
An animal's USDA pain category changed.
Genotype
genotype.recorded
Genotype results were saved for one or more animals.
Cage
cage.created
A cage or tank was added.
cage.updated
A cage or tank was edited or moved to another lab.
cage.changed
A cage change was recorded.
cage.decommissioned
A cage or tank was taken out of use.
Strain
strain.created
A strain or line was added.
strain.updated
A strain or line was edited.
Breeding pair
breeding_pair.created
A breeding pair was set up.
breeding_pair.updated
A breeding pair was edited.
Litter
litter.created
A litter was recorded.
litter.updated
A litter was edited.
Clutch
clutch.recorded
A clutch of eggs was recorded.
Protocol
protocol.created
A protocol was added.
protocol.updated
A protocol was edited.
protocol.allowances_updated
A protocol's approved numbers by species changed.
protocol.surgery_rule_updated
A protocol's survival surgery limit or interval changed.
Procedure
procedure.recorded
A procedure was recorded.
procedure.updated
A procedure record was edited.
procedure.limit_overridden
A surgery went ahead past the protocol's limit, with a reason.
Health case
health_case.opened
A health case was opened.
health_case.updated
A health case was updated.
health_case.closed
A health case was closed.
health_case.photo_added
A photo was added to a health case.
Group
group.created
A group of animals (embryos, larvae or tadpoles) was added.
group.updated
A group was edited.
group.closed
A group was closed.
group.count_changed
A group's count changed.
group.genotype_recorded
A genotype was recorded or changed for a group (a tank's line).
group.genotype_removed
A genotype was removed from a group.
group.individuals_made
Individual animal records were made from a counted group, such as frogs from tadpoles.
group.inventory_imported
A tank inventory was imported as groups of fish.
group.inventory_import_undone
A tank inventory import was undone.
Location
location.created
A room, rack or other location was added.
System
system.created
An aquatic system was added.
system.updated
An aquatic system was edited.
Water reading
water_reading.recorded
A water reading was logged.
Room check
room_check.recorded
A room check (temperature, humidity, lights) was recorded.
Task template
task_template.created
A recurring task was set up.
task_template.updated
A recurring task was edited.
Census
census.taken
The daily census was taken.
census.corrected
A census line was corrected.
census.checked
A room's census was checked by scanning cage cards.
Rate
rate.created
A per diem rate was added.
rate.updated
A per diem rate was edited.
Service charge
service_charge.created
A service charge was added.
Statement
statement.built
A month's statements were built.
statement.finalized
A statement was finalized.
statement.exported
A month's statements were exported.
Lab
lab.created
A lab was added to the facility.
lab.updated
A lab was edited.
lab.member_added
A person was added to a lab.
lab.member_removed
A person was removed from a lab.
Member
member.invited
A person was invited to the organization.
member.joined
A person joined the organization by signing in through its institution.
Organization
organization.type_changed
The organization's type changed, for example to a core facility.
organization.trial_started
A free trial started.
organization.sign_in_changed
The organization's institution sign-in settings changed.
organization.facility_settings_updated
A core facility's settings changed.
Import
import.completed
A spreadsheet import finished.
import.undone
An import was undone.
Sample data
sample_data.cleared
The sample colony was cleared.
Breeding plan
breeding_plan.run
A breeding plan was run.
Genetic flag
genetic_flag.viewed
A genetic flag was opened.
Linnaeus
linnaeus.query_run
A question was asked of Linnaeus.
linnaeus.action_confirmed
Someone confirmed changes that the Linnaeus assistant prepared.
API key
api_key.created
An API key was created.
api_key.revoked
An API key was revoked.
Webhook
webhook.created
A webhook was added.
webhook.updated
A webhook was edited, turned on or turned off.
webhook.deleted
A webhook was deleted.
webhook.secret_rolled
A webhook's signing secret was replaced.
webhook.disabled
A 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);
});
}
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
Create a key with read and write access for the system that holds the results.
Label each sample with the animal ID from ConductColony, so a result can name its animal with animalId.
When a plate is done, post its results to /genotypes, up to 500 a call.
Read the answer row by row. Fix and resend the rows with an error; the saved rows are already on the animals.
Load everything once: page through /animals, /cages and the other lists, and store each record under its id.
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.
Once a day, ask each list for updatedSince your last run, minus a minute. This catches anything a missed delivery left out.
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.