posolix Get beta access

Clinical trial and FDA API documentation.

Everything you need to call the Posolix API: authentication, pagination, the clinical trial, FDA approval and drug label endpoints, event types and webhook signatures.

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.

HeaderMeaning
RateLimit-LimitRequests allowed in the current window
RateLimit-RemainingRequests left in the current window
RateLimit-ResetSeconds 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"
  }
}
StatusWhen
400Malformed request
401Missing, invalid or revoked API key
404The record doesn't exist or we haven't seen it yet
422A parameter has an invalid value
429Rate limit exceeded; retry after RateLimit-Reset seconds
5xxSomething 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.

ParameterDescription
qFree-text search over title, conditions and interventions
conditionCondition or disease, e.g. lung cancer
interventionDrug or other intervention name
sponsorLead sponsor name
phaseEARLY_PHASE1, PHASE1, PHASE2, PHASE3, PHASE4 or NA; comma-separated for several
statusOverall status, e.g. RECRUITING, ACTIVE_NOT_RECRUITING, COMPLETED
changed_sinceOnly trials with a change on or after this date
limit, cursorSee 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

ParameterDescription
typeOne or more event types, comma-separated; trial.* matches a whole group
since, untilDate range on when the change happened
condition, sponsor, phaseSame as on trials
application_typeNDA, ANDA or BLA
limit, cursorSee 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

TypeFires when
trial.registeredA new NCT ID appears
trial.status_changedOverall status changes
trial.phase_changedPhase changes
trial.enrollment_changedEnrollment count or type changes
trial.completion_date_changedPrimary or study completion date moves
trial.results_postedA results section appears
drug.approvedAn application gets its original approval
drug.supplement_approvedA supplement is approved (new indication, form, strength, labeling)
label.updatedA new label version is published
label.boxed_warning_changedA 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 POST with a JSON body (the event object).
  • Endpoints must use https.
  • Any 2xx response 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-Id header 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.