Developer Guide
Connect your retail or brand site to Axiom and pull your own orders, sample status, and certificates of analysis straight into your systems, then show published certificates on your product pages. This guide covers getting your keys, connecting securely, the full order-to-COA lifecycle, and how batches map to certificates. The goal is less back-and-forth: your site can reflect testing status in real time and display each certificate the moment we publish it.
1. Your two keys
Axiom gives every account two distinct keys with very different security profiles. Use the right one for the job.
Public embed key (axk_live_pub_…)
Non-secret. Safe to paste into your website HTML or front-end code. It can only reach certificates you have already published (released and public), which are world-readable anyway. It powers the embed widgets and the public read API. It cannot read orders, submit samples, or see anything unpublished.
Secret API key (ax_live_… in production, ax_test_… on the development server)
Secret. Treat it like a password: keep it on your backend only, never in browser code, a mobile app, or a public repository. It authenticates server-to-server calls to pull your orders and sample status, and (if granted) submit samples. Keys are scoped, hashed at rest, and rate-limited. We show the secret once at creation and store only a hash, so if you lose it you create a new one.
2. Get your keys
Sign in to your Axiom portal and open Embed & Integrate. Your public embed key is shown at the top. To create a secret API key, give it a label (for example "Production site"), choose its scopes, and click Create. Copy the secret immediately; it will not be shown again. You can revoke a key at any time, which takes effect instantly.
Scopes
Grant the minimum a key needs. Basic scopes are yours to switch on in the portal:
- coas:read – List and fetch your published COAs.
- orders:read – Track order and sample status across the lifecycle.
- samples:update – Correct a sample's declared fields before its certificate is released.
- compendium:read – Read the full compendium record: properties, hazards, clinical data, and the evidence ledger.
- support:read – Read your support threads and our replies.
- support:write – Open support threads and reply to them.
- webhooks:write – Register, edit, test and replay your webhook endpoints.
- brands:read – Read your partner-brand roster: names, contact emails, websites and logos.
- brands:write – Create and update partner brands, and upload the logo printed on their certificates.
Advanced scopes are enabled per company rather than switched on from a checkbox, and each one below says why. They are available to company accounts only: a key from a personal account cannot hold one, so create or join a company in the portal first. A key may hold one only while its company is entitled – the check runs on every request, so access can be withdrawn without anyone rotating a key.
- samples:submit – Create orders and submit samples programmatically. Programmatic submission creates billable laboratory work, and API-driven submission is arranged per company rather than switched on from a checkbox.
- billing:pay – See what an order owes and start a card payment for it. Programmatic payment is part of the API-driven ordering arrangement enabled alongside programmatic submission.
- recipients:write – Declare the partner companies each certificate on an order is also issued under. Issuing a certificate under another company's name is a commercial arrangement and carries a per-order fee.
Asking for one your company is not entitled to returns 403, naming the scope. Contact us to enable advanced API access.
Test against our development server
- Create an account on the development portal.
- Mint a test key there, under Embed & Integrate.
- Call
https://dev.axiomanalyticslab.com/api/v1
Production keys do not work on dev, and dev keys do not work on production.
3. Connect securely
A few rules keep both sides safe:
- Send your secret key in the Authorization: Bearer header over HTTPS only. Never put it in a URL, query string, or client-side code.
- Store the key in an environment variable or a secrets manager on your server. Do not commit it.
- Use one key per integration and scope it minimally. Revoke and rotate by creating a new key, switching to it, then revoking the old one.
- If a key is ever exposed, revoke it immediately in the portal and create a replacement.
- Every call is tenant-scoped: a key only ever returns data for the account that owns it. There is no way to read another company's orders or unpublished certificates.
- For anything that runs in a browser, use the public embed key, never the secret key.
A first call to confirm your key works:
curl https://axiomanalyticslab.com/api/v1/me \ -H "Authorization: Bearer ax_live_xxxxxxxx_..."
4. The order to COA lifecycle
Every sample moves through an ordered set of stages. The lifecycle field on a sample tells you exactly where it is, so your site can render a live progress tracker from submit to published without emailing us.
| lifecycle | What it means |
|---|---|
| submitted | Order placed; we are awaiting the quoted physical sample articles. |
| received | Your vial has arrived at the lab. |
| validated | Staff have validated the vial identity and condition. |
| in_analysis | Undergoing the 4X or 8X analysis at our partner lab. |
| data_review | Results are in; the certificate is being prepared and signed. |
| published | The certificate is released, verifiable, and embeddable. |
5. Orders, batches and certificates
How the pieces relate, so you can map a certificate back to one of your products:
Order (ORD-2026-00042)
└─ Sample (your product + lot number) ── lifecycle ──▶ published
└─ Certificate (AX-2026-00031) ◀── one COA per sample when publishedYour lotNumber is the batch handle that ties everything together: you set it at submission, it stays on the sample, and it appears on the published certificate. To show the right certificate on a product page, match by that lot number (see the per-product embed and the by-lot endpoint below). One order can carry many samples; each sample yields one certificate when its analysis is published.
You may also send an optional lotCode – your own short code for the same lot, the one printed on the vial. We store it, echo it on the sample and the order, emit it on coa.* and sample.* webhooks, and print it on the certificate captioned “Lot code”. It is trimmed and otherwise untouched: no case-folding, no confusable substitution, no alphabet validation, so whatever you mint round-trips byte-for-byte.
The two are unrelated strings. lotCode is not a truncation, hash or checksum of lotNumber: you cannot derive either from the other, you cannot validate one against the other, and you must store both independently. Join on the lot number; use the lot code as a second assertion that the certificate matches the vial in someone’s hand.
Continuous Circle accounts
Active Circle accounts on monthly terms can register 1 to 100 sample records per request with intakeMode: "continuous". The account stays open. Each accepted request creates a fixed submission record with a quoted price. Each sample becomes billable only when Axiom records physical receipt, in that receipt month. Partial receipts in different months appear on those respective monthly invoices. No close-order call is needed. Keep one stable Idempotency-Key per request and one clientReference per sample.
const submission = await axiom.circle.register({
idempotencyKey: "inventory-delivery-2026-09-16-001",
clientReference: "SUPPLIER-DELIVERY-001",
testBundle: "4x",
samples: declaredSamples,
});
// Only ship when submission.shipToStatus === "ready".
await axiom.circle.declareShipment({
carrier: "ups", trackingNumber: "YOUR_TRACKING_NUMBER",
sampleIds: submission.samples.map(sample => sample.id),
});
const account = await axiom.circle.intake({ limit: 100 });GET /api/v1/circle/intake returns registered samples, actual receipt, validation, tracking and billing references across the account, including earlier open consignments. Follow nextCursor until null. POST /api/v1/circle/shipments accepts 1–500 unique sampleIds across submission records, all for the same receiving destination. All shipment associations commit together. Retry the same carrier, tracking number and exact manifest safely. A selection covers every declared vial of that sample; splitting a single sample across parcels is not supported.
Completed billing months close automatically using the existing UTC billing calendar and the account’s NET terms and waivers. Billing does not assert physical arrival. Use sample.received for Axiom receipt and the existing sample and certificate events for progress. Calls without intakeMode keep their existing consignment behavior and explicit submission step; adopting the new SDK method does not silently close earlier drafts.
account.billingTrigger is receipt. Intake exposes billingMonth (null until receipt assignment), quotedAmountCents, billableAmountCents and billingStatus. awaiting_receipt is unbilled; billing_reconciling means billing assignment is being repaired without blocking laboratory work. axiom.billing.get() (also billing.summary()) and orders.payment(orderNumber) return receiptBilling with quoted, awaiting-receipt, billable, balance, collected and waived cents plus each sample’s receipt date and billing period. Reconcile monthly balances once; quoted work is not outstanding debt. Legacy consignments keep their existing billing workflow.
5b. Batch orders (consignments)
When a complete request is accepted, every POST /api/v1/samples call creates its own order by default. That is right for a one-off and wrong if you receive vials from your own suppliers over days: one integration submitting per-vial ended up with 98 orders holding 100 samples: 98 invoices and 98 shipment references for what physically shipped in a handful of boxes.
Pass order.ref and accepted samples accumulate on ONE order. Omitting microbialTests on a 4X or 8X request defaults to Rapid DNA, and the server calculates one required vial. Selecting USP <71> keeps Rapid DNA and adds culture testing; the server calculates three required vials for submitted-container scope. The order stays open, nothing invoiced, nothing tested, until you submit it. That is the handshake: you name the consignment, we hold it open, you close it when the box is ready to ship.
Nonblocking qualification finding. Unresolved method, criterion, suitability, control, or material evidence is surfaced to our staff for resolution and does not block intake, entry, signing, or release. An explicit microbialTests: [] is rejected; omit the field to accept the Rapid default. USP <71> is a robust add-on and does not replace required Rapid DNA.
// Controlled Rapid-only accession: one API sample is one product lot, not one physical vial.
// Omitting microbialTests on a 4X or 8X request defaults to Rapid DNA; the server calculates one required vial.
for (const lot of lots) {
const response = await fetch("https://axiomanalyticslab.com/api/v1/samples", {
method: "POST",
headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json",
"Idempotency-Key": lot.id },
body: JSON.stringify({
testBundle: "4x",
order: { ref: "AUG-BATCH-1" },
samples: [{
lotNumber: lot.lotNumber,
lotCode: lot.shortCode, // optional: your vial code
clientReference: lot.id,
compound: { canonicalId: lot.canonicalCompoundId },
labelClaim: { value: lot.labelValue, unit: lot.labelUnit },
presentation: { matrix: lot.matrix }
}]
})
});
if (!response.ok) throw new Error(JSON.stringify(await response.json()));
}
// Only after every lot declaration was accepted:
await fetch("https://axiomanalyticslab.com/api/v1/orders?status=open", { headers });
const { shipTo, sampleCount, invoice } =
await (await fetch("https://axiomanalyticslab.com/api/v1/orders/ORD-2026-AXIO-004/submit",
{ method: "POST", headers })).json();| Step | What happens |
|---|---|
| POST /api/v1/samples with order.ref | A customer-known product declaration finds or creates an OPEN order, appends the sample, and freezes the server-calculated accession plan. Qualification gaps are surfaced to our staff for resolution and do not block entry, signing, or release. |
| …repeat with the same ref | Each accepted declaration lands on the same order. Invalid identity or an explicit invalid service selection appends nothing. |
| GET /api/v1/orders?status=open | Lists consignments that were previously accepted, with openForDays and a stale flag. |
| POST /api/v1/orders/{n}/submit | For an accepted nonempty consignment, closes it, issues one invoice, releases ship-to, and emits order.submitted. Safe to retry. |
order: { ref, submit: true } opens and closes an accepted complete consignment in one call. Omitting order changes only grouping; microbial defaults and the required vial calculation are identical either way.
5c. Lot numbers must be unique
A lot number identifies a batch of your product, and a certificate is a statement about that lot. If the same lot is submitted twice you end up with two certificates describing one physical batch and nothing saying which is authoritative, a state that is genuinely hard to unwind once the documents are in circulation.
So a lot number may be submitted once. A repeat returns 409 duplicate_submission naming the existing sample and order:
{
"error": "duplicate_submission",
"message": "1 sample is already on our books. Nothing was created.",
"duplicates": [{
"index": 0,
"kind": "duplicate_lot",
"lotNumber": "AXIOM-LOT-0001",
"existingSampleId": "…",
"existingOrderNumber": "ORD-2026-AXIO-004",
"message": "Lot \"AXIOM-LOT-0001\" has already been submitted on ORD-2026-AXIO-004 and is complete. …"
}]
}| You want to… | Do this instead |
|---|---|
| Add more layers to a lot you already sent (4X → 8X) | Declare it: resubmission: { supersedes: "<the sampleId in the 409>", reason: "panel_upgrade" }. The new certificate supersedes the earlier one automatically. |
| Re-test a lot after a failed or disputed result | Declare it: resubmission: { supersedes: "…", reason: "retest" }. The earlier certificate is marked superseded, not deleted. |
| Pull the same lot again at a later timepoint | resubmission: { supersedes: "…", reason: "stability_timepoint" }. BOTH certificates stay in force: a timepoint describes the lot at a given age, and superseding the earlier pull would destroy the comparison. |
| Test several vials from one lot | Submit the lot once and ask Axiom to add independently analysed repeats. The customer request does not ask you to calculate physical fill, microbiology class, or vial allocation. |
| Submit genuinely different material | Use the lot number printed on the vial. Never append a suffix to get past this check: the certificate would then name a lot that does not exist on your product, and no check on either side can detect that. |
| Retry a call whose response you lost | Send the same Idempotency-Key. Nothing is created twice and you get the original response back. |
| Find out whether a lot is already done | GET /api/v1/lots/{lotNumber}: see below. |
Re-testing a lot on purpose
A lot legitimately gets tested more than once: you upgrade a 4X to an 8X, you re-test after a failure, you pull a stability timepoint. Those are not duplicates, and refusing them would leave you inventing a lot number your vial does not carry, which is worse than the duplicate: nothing can detect it afterwards.
Under controlled accession, the duplicate rule allows a second submission only when it says what it re-tests. Every 409 duplicate_lot hands you the sample id and a resolutions array containing the resubmission link to merge into a complete declaration. It does not turn unresolved conformance findings into PASS:
{
"lotNumber": "AXIOM-LOT-0001",
"compound": { "canonicalId": "cmp_bpc_157_tb_500" },
"labelClaim": { "value": 20, "unit": "mg" },
"microbialTests": ["usp71"],
"presentation": { "matrix": "lyophilized_powder" },
"resubmission": {
"supersedes": "ee50712e-30d4-4e34-a242-d7143fc17f40",
"reason": "panel_upgrade"
}
}| reason | What happens to the earlier certificate |
|---|---|
| panel_upgrade | Superseded. The new certificate carries every layer the earlier one reported plus the additional layers. |
| retest | Superseded. The new result is the current statement about the lot; the earlier one stays on the record, marked. |
| second_opinion | Superseded, same as a retest. |
| stability_timepoint | Left in force. Both certificates remain valid: they describe the lot at different ages. |
supersedes must name a sample on your account carrying that same lot. A mismatch is a 422 with unknown_supersedes or supersedes_lot_mismatch, so an invalid declaration can never buy an exemption from the duplicate check.
The rule is enforced by a database constraint as well as by the check, so two requests racing each other cannot both land. A submission is inserted as a single statement: if it is refused, nothing was written, never some of your samples, so a retry is always clean and reusing the Idempotency-Key is safe.
GET /api/v1/lots/{lotNumber} answers “is this lot certified, and by which certificate?” using your lot number, the only identifier your inventory system already has. The reply is a verdict, not a list: certified, in_progress, revoked, or ambiguous when more than one certificate is published for a lot and none supersedes the others. We report that honestly rather than guessing, because you are holding both documents.
GET /api/v1/lots/AXIOM-LOT-0002
{
"lotNumber": "AXIOM-LOT-0002",
"status": "certified",
"summary": "Certificate AX-2026-0000-EXMP is current.",
"certificate": { "id": "AX-2026-0000-EXMP", "version": 1, "url": "…", "verifyUrl": "…" },
"submissions": [{ "orderNumber": "ORD-2026-AXIO-027", "status": "completed", … }],
"submissionCount": 1
}6. API reference
Base URL https://axiomanalyticslab.com. All /api/v1 endpoints require a secret key in the Authorization header and return JSON. Responses are tenant-scoped to your account.
Partner brands: advanced
The companies your certificates may also be issued under. When a vial is certified, one sealed certificate is minted per declaration from the same test data, printed under that partner's name, legal name, website and logo.
Send coaRecipients on a new POST /api/v1/samples submission, using a saved partnerBrandId or a partner name, email and Person/Company type. Every partner costs $7.00 per sample, from the first partner: two partners covering ten samples cost $140.00 in sharing fees. Each partner may choose All or Selected samples; on a new submission, selected IDs refer to the samples' unique draft keys. Existing accounts are linked, and sharing can begin while a new partner's invitation is pending.
Sharing more sample COAs on an existing order
The same workflow supports existing and new partners before or after release. Every new partner/sample pairing costs $7.00. Already shared samples are not charged again. All covers the samples currently on the order, including their future COA releases and later revisions. Samples added to the order later need a new quote and sharing charge. The rate is the same on a new submission and on a later addition; no partner is ever included free.
Approved EOM credit is checked for the whole batch, including outstanding reservations. Circle membership alone does not grant credit. Eligible additions use the current open billing month, or the next open month if the current one is closed. Prepaid accounts, held credit and insufficient approved credit use explicit card payment. The response supplies a checkout link; no certificate is issued before verified funding. Original invoice totals remain unchanged.
POST /api/v1/orders/ORD-2026-00042/partner-names
{
"action": "preview",
"recipients": [{
"quoteKey": "acme-selected-1",
"displayName": "Acme Reseller",
"email": "partner@acme.com",
"entityKind": "company",
"scope": { "mode": "selected", "sampleIds": [
"11111111-1111-4111-8111-111111111111",
"22222222-2222-4222-8222-222222222222"
] }
}]
}
// 200: read-only quote, two new partner/sample pairings.
{
"lines": [{
"quoteKey": "acme-selected-1",
"sampleIds": ["11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222"],
"newSampleIds": ["11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222"],
"alreadyGrantedSampleIds": [], "reservedSampleIds": [],
"paidSampleCount": 2, "amountCents": 1400
}],
"amountCents": 1400, "paymentMethod": "card",
"eomBilled": false, "billingMonth": null,
"quoteFingerprint": "copy-the-returned-fingerprint"
}// After displaying the quote, submit the SAME recipients.
POST /api/v1/orders/ORD-2026-00042/partner-names
{
"recipients": [{
"quoteKey": "acme-selected-1", "displayName": "Acme Reseller",
"email": "partner@acme.com", "entityKind": "company",
"scope": { "mode": "selected", "sampleIds": [
"11111111-1111-4111-8111-111111111111",
"22222222-2222-4222-8222-222222222222"
] }
}],
"idempotencyKey": "acme-sharing-order-42-attempt-1",
"expectedQuote": {
"quoteFingerprint": "copy-the-returned-fingerprint",
"amountCents": 1400, "billingMonth": null
},
"photoSharingAck": true
}
// 202: inspect requests and outcomes; follow checkoutUrl if paymentRequired.
// Retrying this exact payload and key resumes the admitted batch.A lost response or cancelled checkout does not require a new sharing request. Retry the same admission payload and idempotency key to resume it. If paymentRequired is true but checkoutUrl is null, the request is saved and checkoutError explains the payment retry. Poll the request IDs for per-certificate progress and notification retries. Refunds are separate auditable credits against the admitted charge.
Billing
Before you ship
Examples
# Rapid DNA Layer 4 is the 4X/8X default.
# Leave the microbial test selection out; the server calculates one required vial.
curl -X POST https://axiomanalyticslab.com/api/v1/samples \
-H "Authorization: Bearer $AXIOM_KEY" \
-H "Idempotency-Key: ship_4471_rapid" \
-H "Content-Type: application/json" \
-d '{
"testBundle": "4x",
"samples": [{
"lotNumber": "RT-10MG-0042",
"compound": { "canonicalId": "cmp_retatrutide" },
"labelClaim": { "value": 10, "unit": "mg" },
"presentation": { "matrix": "lyophilized_powder" }
}]
}'# Track an order's samples and certificates curl https://axiomanalyticslab.com/api/v1/orders/ORD-2026-00042 \ -H "Authorization: Bearer $AXIOM_KEY"
# Controlled Rapid plus USP <71> submitted-container accession.
# Ask only for USP <71>; the server retains required Rapid DNA and calculates three required vials.
# That is one analytical/Rapid reservation plus one whole sealed container for each USP <71> medium.
# It is not batch-representative and does not authorize a PASS, sterility, or COA-release claim.
curl -X POST https://axiomanalyticslab.com/api/v1/samples \
-H "Authorization: Bearer $AXIOM_KEY" \
-H "Idempotency-Key: ship_4471_4x" \
-H "Content-Type: application/json" \
-d '{
"testBundle": "4x",
"samples": [{
"lotNumber": "RT-10MG-0042",
"compound": { "canonicalId": "cmp_retatrutide" },
"labelClaim": { "value": 10, "unit": "mg" },
"microbialTests": ["usp71"],
"presentation": { "matrix": "lyophilized_powder" }
}]
}'Legacy identity fields still parse for compatibility, but a name must resolve exactly to the controlled catalog; an unknown name is rejected rather than handed to the bench for interpretation. For 4X/8X, omission means the Rapid-only default; an explicit empty service set is normalized the same way. Selecting USP <71> keeps Rapid DNA and adds culture testing. Customers provide the active-strength label claim; Axiom derives the routing class and physical reservation. A total label claim still cannot verify a blend's ratio, so declare each labeled component when applicable.
// Complete Rapid plus USP <71> submitted-container accession declaration.
{
"testBundle": "4x",
"clientReference": "SHIP-2026-0714-A",
"samples": [{
"lotNumber": "AXIOM-LOT-0001",
"clientReference": "batch:4471",
"compound": {
"canonicalId": "cmp_bpc_157_tb_500",
"displayName": "Wolverine",
"isBlend": true,
"components": [
{ "name": "BPC-157", "massMg": 10 },
{ "name": "TB-500", "massMg": 10 }
]
},
"labelClaim": { "text": "20 mg lyophilized powder", "value": 20, "unit": "mg" },
"microbialTests": ["usp71"],
"presentation": { "matrix": "lyophilized_powder" }
}]
}// DX stays a separate diluent panel.
// Omit microbialTests; the resolved article supplies its own route and vial reservation.
// The lab verifies total fill, formulation, preservative evidence, and release criteria after receipt.
{
"testBundle": "dx",
"samples": [{
"lotNumber": "BAC-WATER-260824-A",
"compound": { "canonicalId": "cmp_bacteriostatic_water_benzyl_alcohol_preserved" },
"labelClaim": { "text": "Benzyl alcohol 0.9%" },
"presentation": { "matrix": "solution" }
}]
}For 4X/8X, the server calculates one required vial for the default Rapid-only 4X/8X path, or three required vials for Rapid plus USP <71> submitted-container scope. Customers do not enter that count. The reservation is a shipping estimate; an active-strength label claim does not establish total usable material, so the laboratory may request supplemental material for complete execution. If material remains unresolved, the finding is surfaced to our staff for resolution and does not block entry, signing, or release.
// Accepted controlled-accession response: the allocation is server-calculated.
{
"order": { "orderNumber": "ORD-2026-60822", "status": "pending" },
"samples": [{
"lotNumber": "AXIOM-LOT-0001",
"microbial": {
"tests": ["rapid_screen", "usp71"],
"requiredVials": 3,
"totalVials": 3,
"policyVersion": "axiom-microbial-accession-plan/2026-08-24.1"
}
}]
}
// The accession policyVersion freezes the entry scope. Unresolved criteria or material
// evidence is surfaced to staff for resolution and blocks neither entry, signing, nor release.If we cannot map a compound to our dictionary we reject the whole submission with a 422 and a candidate list, and create nothing. We do not guess. A rejected submission costs you a retry; a silently mis-assigned one costs you a certificate that names the wrong molecule.
// GET /api/v1/orders/ORD-2026-00042 (shape)
{
"orderNumber": "ORD-2026-00042",
"status": "in_processing",
"samples": [{
"productName": "Retatrutide 10mg",
"lotNumber": "RT-10MG-0042",
"lifecycle": "in_analysis",
"coa": null
}]
}6b. Compendium API - the compound reference database
The Axiom Chem Compendium is the reference database for every compound and blend we test, sourced from PubChem and ChemicalBook, with CAS numbers, molecular weights, physicochemical properties, GHS hazard data, blend compositions and ratios, and clinical information. Axiom is the naming authority: canonical names, certified market names, and public synonyms all resolve to one compound.
How compounds are named (read this once)
Every row carries three public naming layers. Any of them resolves the row:
| field | What it is | Example |
|---|---|---|
| name | Canonical Axiom name. For blends without one dominant market name, the component list. | BPC-157/TB-500 |
| commonName | The certified market name. We fix contested names to one composition and publish that as the standard. | Wolverine |
| aliases | Public market synonyms and PubChem synonym blobs. | Wolverine Stack, BPC/TB4 |
Lookups are separator-insensitive: glow 70 and GLOW-70land on the same row.
Public endpoints (no key)
Example: resolve a market name
curl "https://https://axiomanalyticslab.com/api/compendium/compounds/wolverine"
# → {
# "name": "BPC-157/TB-500", "commonName": "Wolverine",
# "casNumber": null, "isBlend": true,
# "components": [{ "name": "BPC-157", "expectedRatio": 1, ... }, ...]
# }Example: look up a single compound
curl "https://https://axiomanalyticslab.com/api/compendium/compounds/Semaglutide"
# → { "name": "Semaglutide", "casNumber": "910463-87-0", "molecularFormula": "C23H28H31N2O7", ... }Example: request something we do not list
curl -X POST "https://https://axiomanalyticslab.com/api/compendium/request-compound" \
-H "Content-Type: application/json" \
-d '{ "requestedName": "Methylene Blue", "casNumber": "61-73-4",
"context": "USP grade, 10mg/mL aqueous" }'
# → 201 { "id": "...", "status": "pending" }Example: detailed data with your key
curl -H "x-api-key: ax_live_..." \
"https://https://axiomanalyticslab.com/api/compendium/detailed/Semaglutide"
# → { ...identity, properties: [...], hazards: [...], clinical: [...], evidence: [...] }Browse the compendium in the UI
7. Embed certificates
For anything client-side, use your public embed key and one script tag. No secret, no build step. Full snippets (single certificate, full library, per-product) are on your Embed & Integrate page.
<!-- A searchable library of all your published certificates -->
<div data-axiom-library="axk_live_pub_xxxxxxxxxxxxxxxxxxxxxxxx"></div>
<!-- The certificate for one product lot, on a product page -->
<div data-axiom-product="RT-10MG-0042"
data-axiom-client="axk_live_pub_xxxxxxxxxxxxxxxxxxxxxxxx"
data-match="lot"></div>
<script async src="https://axiomanalyticslab.com/embed.js"></script>The same data is available as JSON, with no key required, for your published certificates:
curl "https://axiomanalyticslab.com/api/public/clients/axk_live_pub_xxxx/coas?q=retatrutide" curl "https://axiomanalyticslab.com/api/public/coas/by-lot?client=axk_live_pub_xxxx&lot=RT-10MG-0042"
8. Webhooks
Instead of polling, register a webhook and we POST a coa.released event to your endpoint the moment a certificate publishes. Two ways to register, with identical behaviour: on your Embed & Integrate page, or headless with your secret key via POST /api/v1/clients/me/webhook-endpoints (scope samples:submit) - so a fully API-driven integration never needs a browser session. Your endpoint must be a public https URL. On creation we show a signing secret once; use it to verify each request.
Each delivery carries these headers and a JSON body:
POST /your-endpoint
Content-Type: application/json
Axiom-Event: coa.released
Axiom-Webhook-Id: <unique delivery id, dedupe on this>
Axiom-Signature: t=<unix>,v1=<hex hmac-sha256>
{
"type": "coa.released",
"data": {
"coaId": "AX-2026-00031", "version": 1, "lotNumber": "RT-30MG-...",
"productName": "Retatrutide", "sha384": "...",
"verifyUrl": "...", "embedUrl": "...", "apiUrl": "..."
}
}Verify the signature (the body is HMAC-SHA256 of `t`.`rawBody`) and reject stale timestamps:
import crypto from "node:crypto";
export function verifyAxiom(rawBody, header, secret) {
const [tPart, vPart] = header.split(","); // "t=...", "v1=..."
const t = Number(tPart.slice(2));
const sig = vPart.slice(3);
if (Math.abs(Date.now() / 1000 - t) > 300) return false; // 5 min skew
const expected = crypto.createHmac("sha256", secret)
.update(\`\${t}.\${rawBody}\`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}Deliveries are at-least-once with retry/backoff, so dedupe on Axiom-Webhook-Id. Treat the webhook as a notification plus a sha384 anchor; fetch the apiUrl for the verified certificate body.
Order-level events - know your box arrived
Historically every event we delivered was coa.released. That meant nothing reached you between shipping a box and a certificate appearing days later, so “arrived safely” and “lost in the post” were the same silence. Three order events close that gap:
| event | Fires when |
|---|---|
| order.submitted | A consignment is closed. Carries sampleCount and how long it was open. |
| order.received | The package physically arrives at a receiving facility. |
| order.completed | Every sample on the order is finished. |
| sample.received | The sample's complete required material allocation is booked in: the signal that separates a slow lab from a lost package. |
| sample.in_analysis | The sample's planned material is in analytical execution. |
| sample.data_review | Results are in; the certificate is being prepared. |
Subscribe to at least one of these. An endpoint listening only to coa.* is flagged on your Embed & Integrate page for exactly this reason.
When an endpoint stops working
Deliveries retry with backoff, and an endpoint that keeps failing is switched off automatically. This is the part worth understanding: a disabled endpoint stops QUEUEING. Events that occur while it is off are not delayed: they never exist to be replayed. So do not register a second endpoint when the first goes quiet; fix and re-enable the first, then replay its delivery log.
Your endpoint list reports isActive, consecutiveFailures, disabledReason, a delivery tally and plain-language findings, including duplicate endpoints pointing at the same URL, which silently double every delivery while both are live.
Event types - subscribe to the full lifecycle
New endpoints subscribe to the full event set by default (all four coa.* events; endpoints registered over the API also get the sample.* and invoice.* progress events). coa.revoked, coa.amended, and coa.superseded are safety events: when you receive one, downgrade or re-fetch the affected certificate immediately so you never keep showing a pulled cert as "verified." Each payload carries a normalized data.state plus revokedAt / revocationReason / supersededBy.
// coa.revoked
{
"type": "coa.revoked",
"data": {
"coaId": "AX-2026-00031", "state": "revoked",
"revokedAt": "2026-06-27T18:00:00.000Z",
"revocationReason": "Lot recalled by manufacturer",
"verifyUrl": "...", "apiUrl": "..."
}
}Test and replay from your Embed & Integrate page - send a signed coa.test event, view the delivery log, and replay any delivery - or over the API: POST /api/v1/clients/me/webhook-endpoints/{id}/test, GET …/{id}/deliveries, and POST …/{id}/deliveries/{deliveryId}/replay.
8b. Billing and what you owe
GET /api/v1/billing is the company-level answer: your terms, what is outstanding, lifetime totals, every monthly invoice with its due date, and the per-order money behind it. The per-order view at /api/v1/orders/{orderNumber}/payment already existed; building the total meant paging every order and adding it up, and a sweep that stops early understates a bill rather than failing. It reuses the same money and invoice shapes, not a second vocabulary.
Read outstanding.unit before you sum anything. On prepaid it is order and each order settles on its own card charge. On end-of-month terms it is billing_period: an individual order carries a balance that is not separately payable, because the monthly invoice already contains it. Summing orders[].money on an EOM account counts the same money twice.
const bill = await axiom.billing.summary();
// The one number, already computed against the unit that actually bills.
console.log(bill.outstanding.balanceCents, bill.outstanding.unit);
// Invoices past their net terms. `overdue` is computed against our clock,
// so two clients in different timezones cannot disagree about it.
for (const p of bill.periods.filter((p) => p.overdue)) {
console.log(p.periodMonth, p.money.balanceCents, p.dueAt, p.invoice?.url);
}Needs orders:read or billing:pay. Reading what you owe is not a payment act, so a read-only reconciliation does not need a scope that can start a charge.
9. SDKs
Three client libraries, one release train, version 1.2.5. Axiom hosts them: they are not on npm and not on any public registry. Installing needs no credential. A dependency that requires a secret to fetch becomes a build-time secret - present in CI, in every deploy target and on every developer machine - and it breaks lockfile reproducibility. So the tarballs are served from a public, version-pinned URL that npm installs from directly.
npm install https://axiomanalyticslab.com/sdk/axiomanalyticslab-sdk-1.2.5.tgz npm install https://axiomanalyticslab.com/sdk/axiomanalyticslab-react-1.2.5.tgz
A published file never changes and is never deleted, so your lockfile can name it forever. A withdrawn release is a new version plus a note, never a 404 in your CI. Each response carries the digest in X-Axiom-Sdk-Sha256, matching the manifest.
- @axiomanalyticslab/sdkServer-side TypeScript. Isomorphic: Node 20+, Deno, Bun, Cloudflare Workers, edge. Takes your SECRET key.npm install https://axiomanalyticslab.com/sdk/axiomanalyticslab-sdk-1.2.5.tgz
- @axiomanalyticslab/reactBrowser components and hooks. Takes the PUBLIC embed key and throws on a secret one.npm install https://axiomanalyticslab.com/sdk/axiomanalyticslab-react-1.2.5.tgz
- Axiom Analytics for WordPressWordPress and WooCommerce plugin. PHP 8.0+, no build step, no Composer dependencies.Upload the .zip under Plugins → Add New → Upload Plugin.
Keeping a client current, from CI or an agent
Hosting our own libraries is deliberate, but it took away the one thing a registry gives you free: a way to ask whether what is in your lockfile is still current. So there is an endpoint for exactly that. GET /api/v1/sdk returns every package, its latest version, every version we still serve, a sha256 for each, and a version-pinned download URL. Any valid key may call it; no scope is required, because every key holder is entitled to the libraries.
# What is published, and is mine current? curl -H "x-api-key: $AXIOM_API_KEY" https://axiomanalyticslab.com/api/v1/sdk # Pull a specific build. Same key, no browser session needed. curl -H "x-api-key: $AXIOM_API_KEY" -O -J \ "https://axiomanalyticslab.com/developers/sdk/download?package=sdk&version=1.2.5"
The download takes an API key as well as a portal session. It used to take only the session, so a CI job or an agent got redirected to a login page with a 200 on it, which does not read as an auth failure - it reads as a corrupt tarball. A request with no credential that does not look like a browser now gets a 401 JSON body instead.
?version= never falls back: an unknown version is a 404 listing what does exist, because a pinned build that quietly becomes a different build defeats the reason anyone pins. Omit it for the newest. Verify before installing - the response carries the digest in Repr-Digest and, in hex, in X-Axiom-Sdk-Sha256, matching the manifest.
The legacy v0.2.0 archive stays withdrawn and should not be installed from a cache. Its submission types presented presentation.fill and conformity.lotSize as orderable when both are laboratory accession facts established from the articles we receive, so an integrator could believe they had declared a physical fill and a batch size the lab never saw. v1.0.0 removes them and the API now says so out loud: send one and the 201 carries a server_owned_field:accession_fact warning naming it.
Which key goes where
There are two kinds of key and they are four characters apart in a .env file. Both libraries enforce the difference rather than documenting it: new Axiom({ apiKey }) throws on a public embed key, <AxiomProvider publicKey> throws on a secret one, and the WordPress settings screen refuses to save a secret key into the public field. Anything a browser holds is public, so advice is not a control. If you need authenticated data in a UI, put the server SDK behind a route in your own backend and return only what that user may see.
// SERVER - the secret key, never in a bundle
import { Axiom } from "@axiomanalyticslab/sdk";
const axiom = new Axiom({
apiKey: process.env.AXIOM_API_KEY!,
// Wire this on day one. A 201 with a silently dropped field is byte-identical
// to a clean one everywhere except warnings[].
onWarning: (w) => log.warn({ code: w.code, path: w.path }, w.message),
});
// BROWSER - the public embed key, reads only published certificates
import { AxiomProvider, CoaBadge } from "@axiomanalyticslab/react";
<AxiomProvider publicKey={process.env.NEXT_PUBLIC_AXIOM_PUBLIC_KEY!}>
<CoaBadge lot={product.lotNumber} />
</AxiomProvider>Microbial-service submission over the live OpenAPI contract
// Send this shape with fetch or a client generated from the live OpenAPI contract.
// Selecting USP <71> retains Rapid DNA; the server calculates the three-vial reservation.
const proposedSubmission = {
testBundle: "4x",
samples: [{
lotNumber: "RT-10-260601",
compound: { canonicalId: "cmp_retatrutide" },
labelClaim: { value: 10, unit: "mg" },
microbialTests: ["usp71"],
presentation: { matrix: "lyophilized_powder" }
}]
} as const;
// Controlled accession is writable. The legacy SDK submit type is not.
// Qualification gaps are surfaced to staff for resolution and block neither entry, signing, nor release.Unresolved qualification is surfaced to our staff for resolution and does not close intake, entry, signing, or release. Inspect the returned microbial policy version and preserve the frozen reservation; never reinterpret accession as a PASS, sterility, CFU, or completed-panel claim.
Resolve compounds by any name
Resolve your house name, a certified market name, or a public alias to the canonical row once, at product-create time, then retain the canonicalId for controlled accession and later release evidence. An unrecognized name throws with a candidate list: the lab refuses to guess which molecule you meant.
import { AxiomCompoundUnresolvedError } from "@axiomanalyticslab/sdk";
try {
const c = await axiom.compounds.resolve("Wolverine");
console.log(c.canonicalId, c.canonicalName);
} catch (e) {
if (e instanceof AxiomCompoundUnresolvedError) {
console.log("Did you mean:", e.candidates.map((x) => x.canonicalName));
}
}
// Teach the lab your vocabulary once (all-or-nothing upsert):
await axiom.compounds.map.put([
{ clientTerm: "VP-RT", canonicalId: "cmp_retatrutide" },
{ clientTerm: "Wolverine", canonicalId: "cmp_bpc_157_tb_500" },
]);Webhooks in five lines
// Register an endpoint headless; the signing secret is shown EXACTLY ONCE.
const { endpoint, signingSecret } = await axiom.webhooks.endpoints.create({
url: "https://shop.example.com/webhooks/axiom",
});
await secrets.put("AXIOM_WEBHOOK_SECRET", signingSecret);
// Prove the path before real traffic: signed test event end to end.
const probe = await axiom.webhooks.endpoints.test(endpoint.id);
if (!probe.ok) throw new Error(probe.error);In your receiver, pass the RAW body (express.raw, not express.json): the signature covers the exact bytes we sent, and a JSON round-trip is free to change them.
import { constructEvent } from "@axiomanalyticslab/sdk";
export async function POST(req: Request) {
const raw = await req.text(); // RAW BYTES, not req.json()
const event = await constructEvent(raw, req.headers, process.env.AXIOM_WEBHOOK_SECRET!);
if (event.type === "coa.released") onReleased(event.data);
if (event.type === "coa.revoked") pullFromStore(event.data.coaId); // the only push signal a cached cert no longer stands
return Response.json({ received: true });
}
// Dedupe on the Axiom-Webhook-Id header. Delivery is at-least-once: a network
// blip after your 200 leaves our dispatcher believing it failed.Verify a certificate offline
Three separate facts, and collapsing any two of them is a real bug. sealVerified means a published Axiom key signed this digest - provable by anyone. contentBound means the certificate you are holding hashes to that digest, which needs signing.canonicalPayload: those bytes carry the complete analytical record, so they go to the certificate’s owner and are omitted from the public envelope. state is current standing and is not a property of the bytes at all.
const envelope = await axiom.coas.fetchPublic(reportId);
const jwks = await axiom.coas.jwks(); // cache or pin this
const r = await axiom.coas.verifyOffline(envelope, { jwks });
// authentic = sealVerified && contentBound (eternal; TRUE on a revoked cert)
// verified = authentic && state === "valid" <- the badge boolean
if (r.verified) showVerifiedCheck();
else if (r.sealVerified) showSealedBadge(r.reason); // a PUBLIC envelope lands here
else if (r.unsupportedRuntime) showCannotCheckHere(); // old browser, not a forgery
else showWarning(r.reason);A public relying party gets sealVerified: true and contentBound: false, and the honest render is “sealed by Axiom” linking here, not a verification check. A green check a forger could reproduce by pairing a genuine seal with a doctored document is worse than no badge - it launders the forgery. <CoaBadge> makes exactly these distinctions.
10. Verifying a certificate (offline)
The live verdict endpoint is the easy path - no key required:
REPORT_ID="<report id from a certificate you are authorized to verify>" curl "https://axiomanalyticslab.com/api/coa/$REPORT_ID"
But you do not have to trust our server. Every certificate is sealed with Ed25519 over a SHA-384 digestof a canonical payload, and we publish the public keys (JWKS) and the exact recipe. How far you can get depends on which envelope you hold: a public reader verifies the seal offline, and the certificate’s own client, authenticated, additionally receives the canonical bytes and can verify it fully offline, end to end. We do not publish signed laboratory-result fixtures.
curl https://axiomanalyticslab.com/.well-known/jwks.json # org seal + staff public keys curl https://axiomanalyticslab.com/.well-known/axiom-coa-signing.json # the exact signing recipe (axiom-coa-sig-v1)
The verdict response includes a signing block carrying the digest that was signed and one entry per signer. It does not carry the signed bytes themselves on this endpoint: signing.canonicalPayload is the complete analytical record, so it goes to the certificate’s owner and is omitted from the public envelope. That splits the check into the two facts section 9 names, and the sample below keeps them apart rather than reporting one as the other.
import { createHash } from "node:crypto";
import { ed25519 } from "@noble/curves/ed25519.js";
const reportId = process.env.AXIOM_REPORT_ID;
if (!reportId) throw new Error("Set AXIOM_REPORT_ID to a certificate you are authorized to verify");
const r = await (await fetch("https://axiomanalyticslab.com/api/coa/" + encodeURIComponent(reportId))).json();
const jwks = await (await fetch("https://axiomanalyticslab.com/.well-known/jwks.json")).json();
const s = r.signing;
// 1. SEAL VERIFICATION - a published Axiom key signed this digest.
// Any public reader can do this, from the public envelope alone.
for (const sig of s.signatures) {
const jwk = jwks.keys.find((k) => k.kid === sig.kid); // match on kid, never on position
if (!jwk) throw new Error("no published key for kid " + sig.kid);
const pub = Buffer.from(jwk.x, "base64url");
const ok = ed25519.verify(Buffer.from(sig.signature.slice(8), "hex"), Buffer.from(s.sha384, "hex"), pub);
if (!ok) throw new Error("bad signature: " + sig.role);
}
const sealVerified = true;
// 2. CONTENT BINDING - the document you hold hashes to that digest.
// Needs signing.canonicalPayload, which is OWNER-SCOPED and is absent here.
// Guard it: from a public envelope this is undefined, and hashing it throws.
const contentBound = typeof s.canonicalPayload === "string"
&& createHash("sha384").update(Buffer.from(s.canonicalPayload, "utf8")).digest("hex") === s.sha384;
// 3. What you may claim. A PUBLIC reader lands on sealVerified && !contentBound.
const authentic = sealVerified && contentBound; // eternal; TRUE on a revoked cert
const verified = authentic && r.state === "valid"; // the badge boolean
console.log({ sealVerified, contentBound, authentic, verified, standing: r.state });
// Public reader: render "sealed by Axiom", NOT a verification check.
// A green check reachable without step 2 is one a forger reproduces by pairing
// a genuine seal with a doctored body.Authenticity vs. standing. The signature proves the certificate is genuine and unaltered - this is true forever, even after revocation (status is not part of the signed bytes, exactly like X.509/OCSP). Whether a cert is still in force is a separate, live property: read state (or subscribe to coa.revoked). verified === signature.authentic && state === "valid".
And note who is speaking. The verified and signature.authentic in that response are our determination, made with the canonical bytes. Run step 2 above against a public envelope and you will correctly get contentBound: false and therefore authentic: false, on a certificate this endpoint reports as true. That is not a disagreement about the certificate and neither side is broken - it is “Axiom determined this is authentic” beside “I could not prove it unaided”. See scope.public and scope.owner in /.well-known/axiom-coa-signing.json.
11. Revocation & standing
Certificates can be revoked or superseded. The verdict always carries a normalized state: valid, revoked, superseded, amended, plus the transition fields. A revoked certificate flips verified to false and is served no-store so it can never be cached as valid.
// GET /api/coa/<id> for a revoked certificate
{
"verified": false,
"signature": { "authentic": true, "algorithm": "Ed25519", "digest": "SHA-384" },
"state": "revoked",
"revokedAt": "2026-06-27T18:00:00.000Z",
"revocationReason": "Lot recalled by manufacturer",
"supersededBy": null
}Build for this: drive your badge off verified (not just the signature), honor the coa.revoked webhook, and re-fetch (or subscribe) rather than cache a verdict indefinitely.
12. Rate limits, status & versioning
Public reads are rate limited per IP. Every response carries RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset; a 429 adds Retry-After. Back off on those headers.
Stable endpoints live under /api/v1 (the unversioned public paths remain as permanent aliases). The machine-readable contract is the OpenAPI spec; service health is a single poll:
curl https://axiomanalyticslab.com/api/v1/openapi.json # OpenAPI 3.1 contract curl https://axiomanalyticslab.com/api/status # health + webhook backlog + version curl https://axiomanalyticslab.com/api/v1/export -H "Authorization: Bearer $AXIOM_KEY" # export all your data (offboarding)
Live uptime and incident history are published on the System status and uptime history page.
Compliance docs: DPA, SLA, Security & breach notification, Subprocessors.
13. Safe integration fixtures
Axiom does not publish signed laboratory-result fixtures. Use an explicitly illustrative local object to test layout and downgrade behavior, and use a certificate your account is authorized to retrieve for end-to-end signature verification. An illustrative object is never evidence of testing.
Exercise a revoked-state UI locally
{
"illustrative": true,
"verified": false,
"signature": { "authentic": false },
"state": "revoked",
"coa": null
}Your UI should remove any positive badge and display the non-current state. Do not add result fields, signatures, analyst names, or measured values to this fixture; doing so would make invented laboratory evidence look real.
Exercise a live account-scoped retrieval
REPORT_ID="<report id from your own published certificate>" curl "https://axiomanalyticslab.com/api/coa/$REPORT_ID"
The illustrative layout at /example-coa is intentionally unsigned and non-verifiable.