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_…)
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:
- coas:read – list and fetch your published certificates.
- orders:read – track orders and sample status across the lifecycle.
- samples:submit – create orders and submit samples programmatically.
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 your vial. |
| 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.
5b. Batch orders (consignments)
By default every POST /api/v1/samples call creates its own order. 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 the samples accumulate on ONE order. It 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.
// 1. Add vials as they arrive. Same ref each time.
for (const vial of arrivals) {
await fetch("https://axiomanalyticslab.com/api/v1/samples", {
method: "POST",
headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json",
"Idempotency-Key": vial.id },
body: JSON.stringify({
order: { ref: "AUG-BATCH-1" }, // <- the consignment handle
samples: [{ lotNumber: vial.lot, clientReference: vial.id, /* ... */ }],
}),
});
}
// 2. See what is still open (poll this — an order you forget to submit
// is a box of vials nobody is working on).
await fetch("https://axiomanalyticslab.com/api/v1/orders?status=open", { headers });
// 3. Close it. ONE invoice for every vial, and the ship-to is released here.
const { shipTo, sampleCount, invoice } =
await (await fetch("https://axiomanalyticslab.com/api/v1/orders/ORD-2026-VIDA-004/submit",
{ method: "POST", headers })).json();| Step | What happens |
|---|---|
| POST /api/v1/samples with order.ref | Finds or creates an OPEN order with that ref and appends. Returns status: "open", the running samplesOnOrder count, and each vial's Shipment ID so you can label as you go. |
| …repeat with the same ref | More vials land on the same order. Still no invoice, still no lab work. |
| GET /api/v1/orders?status=open | Everything you have open, with openForDays and a stale flag. |
| POST /api/v1/orders/{n}/submit | Closes it: ONE invoice for every vial, ship-to released, order.submitted fires. Safe to retry — a second call returns alreadySubmitted: true and charges nothing. |
Shortcut: send order: { ref, submit: true } to open and close in one call when you already have every vial. Omit order entirely and the endpoint behaves exactly as it always has — one order per call.
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": "BB-20MG-260622-3T",
"existingSampleId": "…",
"existingOrderNumber": "ORD-2026-VIDA-004",
"message": "Lot \"BB-20MG-260622-3T\" has already been submitted on ORD-2026-VIDA-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 with conformity.vialsSubmitted: 3 and conformityPlan: "per_vial". You get per-vial results and a uniformity verdict — which is more than two separate submissions would have told you. |
| 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.
So the second submission is accepted when it says what it re-tests. Every 409 duplicate_lot hands you the sample id and a resolutions array containing the exact body that would be accepted:
{
"lotNumber": "BB-20MG-260622-3T",
"productName": "Wolverine 20 mg",
"clientLabelClaim": "20 mg",
"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/RT-20MG-260622-3AI
{
"lotNumber": "RT-20MG-260622-3AI",
"status": "certified",
"summary": "Certificate AX-2026-3858-37CD is current.",
"certificate": { "id": "AX-2026-3858-37CD", "version": 1, "url": "…", "verifyUrl": "…" },
"submissions": [{ "orderNumber": "ORD-2026-VIDA-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.
Examples
# Track an order's samples and certificates curl https://axiomanalyticslab.com/api/v1/orders/ORD-2026-00042 \ -H "Authorization: Bearer $AXIOM_KEY"
# Submit samples programmatically. The Idempotency-Key makes a retried
# timeout return the SAME order instead of minting a second billable one.
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": [
{ "productName": "Retatrutide 10mg", "lotNumber": "RT-10MG-0042",
"clientLabelClaim": "10 mg" }
]
}'That three-field body is the original shape and it keeps working. But a marketing name is something a person has to interpret at our bench, and a total mass cannot verify a blend's ratio: “20 mg” passes whether the vial holds four peptides in the right proportion or 20 mg of just one of them. Declare the vial instead and both problems go away.
// POST /api/v1/samples - the declared form
{
"testBundle": "4x",
"clientReference": "SHIP-2026-0714-A",
"samples": [{
"lotNumber": "BB-20MG-260622-3T",
"clientReference": "batch:4471",
"compound": {
"canonicalId": "cmp_bpc_157_tb_500", // from GET /api/v1/compounds
"displayName": "Wolverine", // your name for it; we keep it
"isBlend": true,
"components": [ // makes the RATIO verifiable
{ "name": "BPC-157", "massMg": 10 },
{ "name": "TB-500", "massMg": 10 }
]
},
"presentation": {
"matrix": "lyophilized_powder",
"fill": { "value": 20, "unit": "mg" },
"vialSize": "3R", "capColor": "Royal Blue", "crimpColor": "Silver / Aluminum",
"closure": "crimped_aluminum"
},
"labelClaim": { "text": "20 mg lyophilized powder", "value": 20, "unit": "mg" },
"conformity": { "vialsSubmitted": 3, "plan": "per_vial", "lotSize": 250 }
}]
}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 a searchable database of every compound, blend, and product we test — sourced from PubChem and ChemicalBook, with CAS numbers, molecular weights, physicochemical properties, GHS hazard data, blend compositions, and clinical information. Use it to validate product names, look up CAS numbers, scale blend ratios, and enrich your own catalogs.
Identity data is public (anonymous, no key required): name, CAS, molecular formula, theoretical mass, synonyms, blend composition. Detailed data requires an API key: physicochemical properties, GHS hazards, clinical/protocol data, and the full evidence ledger with source citations. Your existing coas:read key works.
Public endpoints (no key)
Detailed endpoint (API key required)
Example: look up a compound
curl "https://https://axiomanalyticslab.com/api/compendium/compounds/Semaglutide"
# → { "name": "Semaglutide", "casNumber": "910463-87-0", "molecularFormula": "C23H28H31N2O7", ... }Example: scale a blend
curl "https://https://axiomanalyticslab.com/api/compendium/blends/Mystique%20Protocol/scale?totalMg=70"
# → { "blendName": "Mystique Protocol", "totalMg": 70, "ratioLabel": "5:1:1",
# "components": [{ "name": "GHK-Cu", "amountMg": 50 }, { "name": "KPV", "amountMg": 10 }, ...] }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 | One vial is booked in — the signal that separates a slow lab from a lost package. |
| sample.in_analysis | That vial is on an instrument. |
| 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.
9. TypeScript SDK (private download)
The official TypeScript SDK wraps everything on this page - typed sample.*/invoice.* webhook events with signature verification, webhook endpoint management, compound mapping, and offline Ed25519 certificate verification. It is private: it is not on npm or any public registry, and this page is its only distribution point. The download requires your portal session.
Download @axiomanalyticslab/sdk (v0.1.0, .tgz)
Then install the tarball directly:
npm install ./axiomanalyticslab-sdk-0.1.0.tgz
10. Verifying a certificate (offline)
The live verdict endpoint is the easy path - no key required:
curl https://axiomanalyticslab.com/api/coa/AX-2026-00010
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 everything needed to verify it fully offline: the public keys (JWKS), the exact recipe, and a self-contained test vector.
curl https://axiomanalyticslab.com/.well-known/jwks.json # org seal + 3 staff public keys curl https://axiomanalyticslab.com/.well-known/axiom-coa-signing.json # the exact signing recipe (axiom-coa-sig-v1) curl https://axiomanalyticslab.com/.well-known/axiom-coa-testvector.json # a real cert that verifies, for your test suite
The verdict response includes a signing block with the exact bytes that were signed. Hash that string with SHA-384 to reproduce sha384, then verify each signature against the JWKS key with the matching kid. Ten lines, any language:
import { createHash } from "node:crypto";
import { ed25519 } from "@noble/curves/ed25519.js";
const r = await (await fetch("https://axiomanalyticslab.com/api/coa/AX-2026-00010")).json();
const jwks = await (await fetch("https://axiomanalyticslab.com/.well-known/jwks.json")).json();
const s = r.signing;
// 1. reproduce the digest from the exact published bytes
const sha = createHash("sha384").update(Buffer.from(s.canonicalPayload, "utf8")).digest("hex");
if (sha !== s.sha384) throw new Error("digest mismatch - altered");
// 2. verify each signature (org seal + each staff role) against its JWKS key by kid
for (const sig of s.signatures) {
const jwk = jwks.keys.find((k) => k.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);
}
console.log("authentic:", true, "| standing:", r.state); // standing is a separate, live checkAuthenticity 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".
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)
Compliance docs: DPA, SLA, Security & breach notification, Subprocessors.
13. Demo certificates & testing
Three public demo certificates are maintained for you to build and test against - no account or key required. They are stable: hard-code these ids in your test suite.
| Demo id | What it is | Verdict |
|---|---|---|
| AX-DEMO-4X | Valid 4X Essential cert | verified:true · state:valid |
| AX-DEMO-8X | Valid 8X Comprehensive cert | verified:true · state:valid |
| AX-DEMO-REVOKED | A revoked cert | verified:false · state:revoked |
Verify a valid cert offline (no key, no Axiom trust)
# Reproduce the SHA-384 and verify the ed25519 seal yourself, fully offline: curl https://axiomanalyticslab.com/api/coa/AX-DEMO-4X # verified:true, state:valid, + a 'signing' block curl https://axiomanalyticslab.com/.well-known/jwks.json # the 4 public keys (org seal + 3 staff) curl https://axiomanalyticslab.com/.well-known/axiom-coa-signing.json # the canonical recipe curl https://axiomanalyticslab.com/.well-known/axiom-coa-testvector.json # a self-contained test vector
import { createHash, createPublicKey, verify } from "node:crypto";
const r = await (await fetch("https://axiomanalyticslab.com/api/coa/AX-DEMO-4X")).json();
const jwks = await (await fetch("https://axiomanalyticslab.com/.well-known/jwks.json")).json();
const s = r.signing;
const sha = createHash("sha384").update(Buffer.from(s.canonicalPayload, "utf8")).digest("hex");
console.log("digest reproduced:", sha === s.sha384);
for (const sig of s.signatures) {
const jwk = jwks.keys.find(k => k.kid === sig.kid);
const pub = createPublicKey({ key: jwk, format: "jwk" });
const ok = verify(null, Buffer.from(s.sha384, "hex"), pub, Buffer.from(sig.signature.slice(8), "hex"));
console.log(sig.role, ok ? "VERIFIED" : "FAILED");
}
console.log("verified =", r.verified, "| state =", r.state); // standing is a separate, live checkTest your revocation / downgrade handling
Point your sync at AX-DEMO-REVOKED and confirm your UI downgradesinstead of showing a stale "verified" badge. The seal stays cryptographically authentic (a revoked cert was still genuinely issued) - drive your badge off verified and state, not the signature alone.
curl https://axiomanalyticslab.com/api/coa/AX-DEMO-REVOKED
# → { "verified": false, "state": "revoked", "signature": { "authentic": true },
# "revokedAt": "...", "revocationReason": "Demonstration certificate ...", "supersededBy": null }
curl -sI https://axiomanalyticslab.com/api/coa/AX-DEMO-REVOKED | grep -i cache-control # no-store
curl -s -o /dev/null -w "%{http_code}\n" https://axiomanalyticslab.com/api/coa/AX-DEMO-REVOKED/pdf # 410 (a pulled cert stops being served)Resolve by product lot (the recommended match path)
# The demo client's non-secret public key (safe to share): DEMO_KEY=axk_live_pub_5942dd07af880e9a10a75c4e curl "https://axiomanalyticslab.com/api/public/coas/by-lot?client=$DEMO_KEY&lot=DEMO-4X-001" # → AX-DEMO-4X curl "https://axiomanalyticslab.com/api/public/coas/by-lot?client=$DEMO_KEY&lot=DEMO-8X-001" # → AX-DEMO-8X
Embed a demo cert with one tag: <div data-axiom-coa="AX-DEMO-4X"></div> + <script async src="https://axiomanalyticslab.com/embed.js"></script>.