Draft for the beta. This is the API we're building. Names, fields and limits may change before or during the beta, and beta members will hear about changes first.
Overview
The Posolix API is a JSON REST API over three public US sources: ClinicalTrials.gov, Drugs@FDA and openFDA drug labels. We store every version of every record and record each change as a typed event. See data coverage for what's tracked and how often.
All requests go to the base URL below over HTTPS. Responses are JSON, dates are ISO 8601, and timestamps are UTC.
https://api.posolix.com/v1
Authentication
Send your API key as a bearer token on every request. Beta members get a key by email and can create or revoke keys in the dashboard. Keep keys secret: don't put them in client-side code or public repositories.
curl https://api.posolix.com/v1/trials/NCT0XXXXXXX \
-H "Authorization: Bearer psx_live_..."
A missing or revoked key returns 401 Unauthorized.
Pagination
List endpoints use cursor pagination. Pass limit (default 50, max 200) and, for the next page, the next_cursor from the previous response as cursor. When next_cursor is null you've reached the end.
GET /v1/events?type=drug.approved&limit=100
GET /v1/events?type=drug.approved&limit=100&cursor=eyJpZCI6...
{
"data": [ ... ],
"next_cursor": "eyJpZCI6..."
}
Rate limits
Each key has a request limit per minute. Every response includes headers showing where you stand, and going over returns 429 Too Many Requests. Beta limits are sent with your key.
| Header | Meaning |
|---|---|
RateLimit-Limit | Requests allowed in the current window |
RateLimit-Remaining | Requests left in the current window |
RateLimit-Reset | Seconds until the window resets |
Errors
Errors use standard HTTP status codes and a JSON body with a machine-readable code and a human-readable message.
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "invalid_parameter",
"message": "phase must be one of EARLY_PHASE1, PHASE1, PHASE2, PHASE3, PHASE4, NA"
}
}
| Status | When |
|---|---|
400 | Malformed request |
401 | Missing, invalid or revoked API key |
404 | The record doesn't exist or we haven't seen it yet |
422 | A parameter has an invalid value |
429 | Rate limit exceeded; retry after RateLimit-Reset seconds |
5xx | Something went wrong on our side; retry with backoff |
Trials
Clinical trials from ClinicalTrials.gov, identified by NCT ID. See the clinical trials API page for an overview.
GET /v1/trials
Search trials. Results are sorted by latest change, newest first.
| Parameter | Description |
|---|---|
q | Free-text search over title, conditions and interventions |
condition | Condition or disease, e.g. lung cancer |
intervention | Drug or other intervention name |
sponsor | Lead sponsor name |
phase | EARLY_PHASE1, PHASE1, PHASE2, PHASE3, PHASE4 or NA; comma-separated for several |
status | Overall status, e.g. RECRUITING, ACTIVE_NOT_RECRUITING, COMPLETED |
changed_since | Only trials with a change on or after this date |
limit, cursor | See pagination |
GET /v1/trials/:nct_id
One trial's current record: title, conditions, interventions, sponsor, phase, status, enrollment, key dates and sites.
{
"nct_id": "NCT0XXXXXXX",
"title": "First-line combination in advanced disease",
"conditions": ["Non-small cell lung cancer"],
"interventions": ["Examplinib", "Carboplatin"],
"sponsor": "Sample Bio",
"phase": "PHASE3",
"overall_status": "RECRUITING",
"enrollment": { "count": 780, "type": "ESTIMATED" },
"start_date": "2026-02",
"primary_completion_date": "2027-11",
"last_changed_at": "2026-09-16T08:40:00Z"
}
GET /v1/trials/:nct_id/history
Every version we've stored, newest first, with the event types it produced and the fields that changed. The first version is the trial as it looked when we started tracking it.
Drug applications
FDA drug applications from Drugs@FDA, identified by application number (NDA, ANDA or BLA plus six digits). See the FDA drug approvals API page for an overview.
GET /v1/drugs/applications/:application_number
The application, its products (brand name, active ingredients, strength, dosage form, route) and every submission in order, with type, status and date.
To list recent approvals across all applications, use events with type=drug.approved,drug.supplement_approved.
Labels
Prescribing labels from openFDA, identified by SPL set ID. See the drug label API page for an overview.
GET /v1/labels/:set_id/history
Every version of the label we've stored, with its version number, effective date and the sections that changed from the version before, e.g. boxed_warning, indications_and_usage, dosage_and_administration, warnings_and_cautions.
Events
Every change we detect is stored as an event. Events are the same objects that webhooks deliver, with the same IDs.
GET /v1/events
| Parameter | Description |
|---|---|
type | One or more event types, comma-separated; trial.* matches a whole group |
since, until | Date range on when the change happened |
condition, sponsor, phase | Same as on trials |
application_type | NDA, ANDA or BLA |
limit, cursor | See pagination |
GET /v1/events/:id
One event.
{
"id": "evt_01J8...",
"api_version": "2026-09",
"event": "trial.status_changed",
"occurred_at": "2026-09-18T14:02:11Z",
"detected_at": "2026-09-18T16:00:04Z",
"source": "clinicaltrials.gov",
"trial": { "nct_id": "NCT0XXXXXXX", "phase": "PHASE3", ... },
"changes": {
"overall_status": { "from": "RECRUITING", "to": "ACTIVE_NOT_RECRUITING" }
}
}
occurred_at is when the source says the change happened; detected_at is when we saw it.
Event types
| Type | Fires when |
|---|---|
trial.registered | A new NCT ID appears |
trial.status_changed | Overall status changes |
trial.phase_changed | Phase changes |
trial.enrollment_changed | Enrollment count or type changes |
trial.completion_date_changed | Primary or study completion date moves |
trial.results_posted | A results section appears |
drug.approved | An application gets its original approval |
drug.supplement_approved | A supplement is approved (new indication, form, strength, labeling) |
label.updated | A new label version is published |
label.boxed_warning_changed | A boxed warning is added, removed or edited |
Subscriptions
A subscription sends every event matching its filter to your endpoint as a webhook. You can also manage subscriptions in the dashboard.
POST /v1/subscriptions
{
"endpoint_url": "https://your-app.com/hooks/posolix",
"filter": {
"type": ["trial.status_changed", "trial.results_posted"],
"condition": "lung cancer",
"phase": ["PHASE3"]
}
}
The response includes the subscription id and a signing_secret. The secret is shown once, so store it right away.
Other subscription endpoints
GET /v1/subscriptions: list your subscriptions and their delivery status.DELETE /v1/subscriptions/:id: stop a subscription.POST /v1/subscriptions/:id/test: send a sample event to check your handler.
Delivery
- Each event is sent as an HTTPS
POSTwith a JSON body (the event object). - Endpoints must use
https. - Any
2xxresponse within 10 seconds counts as delivered. - Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours. A subscription that keeps failing is paused and we email you.
- Delivery is at-least-once. Use the
Posolix-Event-Idheader to skip events you've already handled. - Missed events can be backfilled from
GET /v1/events.
Verifying signatures
Every delivery has a Posolix-Signature header:
Posolix-Signature: t=1758204131,v1=5f2c...
v1 is the hex HMAC-SHA256 of t + "." + raw_body, keyed with your subscription's signing secret. Compute it over the raw request body (before JSON parsing), compare in constant time, and reject requests where t is more than 5 minutes old.
Ruby
require "openssl"
def posolix_valid?(raw_body, header, secret)
parts = header.split(",").to_h { |p| p.split("=", 2) }
return false if (Time.now.to_i - parts["t"].to_i).abs > 300
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts["t"]}.#{raw_body}")
OpenSSL.secure_compare(expected, parts["v1"].to_s)
end
Node.js
const crypto = require("crypto");
function posolixValid(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(parts.t + "." + rawBody).digest("hex");
if (!parts.v1 || parts.v1.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Python
import hashlib, hmac, time
def posolix_valid(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Versioning
The URL carries the major version (/v1) and every event carries an api_version date. We add fields without notice, so ignore fields you don't recognize. Removing or renaming fields means a new api_version, announced to all key holders in advance.
Questions or a missing endpoint? Email us or mention it when you request beta access.