NexaScreen API
Order drug and alcohol tests for your drivers from your own system, track their progress, and download results and custody forms as soon as they are available.
The API lets your system:
- Create a test case for a driver working for a motor carrier.
- List and look up the test cases you have created.
- Download the result and the chain-of-custody form (CCF) as PDFs.
- Receive webhook notifications when a test case changes or its result is ready.
Base URL
All endpoints below are relative to:
https://api.nexascreen.com/v1
Available endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /test-cases |
Create a test case |
| GET | /test-cases |
List test cases (paginated) |
| GET | /test-cases/{id} |
Retrieve a single test case |
| GET | /test-cases/{id}/result |
Retrieve the result and CCF of a test case |
Integration overview
A complete integration follows these steps:
- Get your credentials. Your testing provider issues you an API key. If you want webhook notifications, send them the HTTPS URL of your receiving endpoint; they will give you a signing secret for it.
-
Create a test case with
POST /test-cases, identifying the driver by license number and state and the company by USDOT number. Save theidfrom the response; you need it for every later call. -
Wait for the result. Listen for the
test_case.result_addedwebhook, or pollGET /test-cases/{id}/resultuntilresultis no longernull. -
Download the result from
GET /test-cases/{id}/result. The result PDF and the CCF PDF are included as base64 strings.
Test the integration end to end before going live: create one test case, retrieve it, and ask your provider to send a test webhook event to your endpoint.
Authentication
Authenticate every request by sending your API key as a Bearer token
in the Authorization header. Requests without a valid key
are rejected with 401 Unauthorized.
Authorization: Bearer YOUR_API_KEY
Keep your API key secret. Use it only from your servers, never from a browser or mobile app, and don't commit it to source control. The full key is shown only once when it is issued. If it is lost or exposed, ask your provider to revoke it and issue a new one.
Each key is enabled for specific endpoints. If a request returns
403 with This API key is not permitted to use this
endpoint, ask your provider to enable that endpoint for your
key.
Requests & responses
- All requests must use HTTPS.
-
Request bodies are JSON. Send
Content-Type: application/json. - All responses are JSON, encoded as UTF-8.
-
Dates are
YYYY-MM-DDstrings. Timestamps (createdAt,updatedAt,…AddedAt) are ISO 8601 in UTC, for example2026-09-20T14:03:11.000Z. -
Fields with no value are returned as
null. Optional fields that don't apply, such asotherReason, are left out. - IDs are UUID strings. Treat them as opaque values and don't parse them.
Errors
The API uses standard HTTP status codes. A 2xx code means
success. Any other code means an error, and the body has an
error field with a readable message:
{
"error": "reason must be one of: pre-employment, post-accident, reasonable-suspicion, follow-up, random, return-to-duty, other"
}
| Status | Meaning | What to do |
|---|---|---|
200 / 201 |
Success. 201 means a test case was created. |
— |
400 |
The request is invalid: a required field is missing or a value isn't allowed. | Fix the request using the message. Don't retry it unchanged. |
401 |
The API key is missing, malformed, revoked or expired. | Check the Authorization header. Ask your provider for a new key if needed. |
403 |
The key isn't allowed to call this endpoint, or API access is disabled for the account. | Contact your provider. |
404 |
The test case or endpoint doesn't exist. | Check the id and the URL. |
429 |
Too many requests. See rate limits. | Wait for the number of seconds in Retry-After, then retry. |
5xx |
Server-side error. | Retry with exponential backoff. |
Rate limits
Each API key can make up to 100 requests per minute. Every response includes headers showing how much of the limit is left:
RateLimit-Policy: 100;w=60
RateLimit: limit=100, remaining=87, reset=42
reset is the number of seconds until the window resets.
Requests over the limit get 429 Too Many Requests with a
Retry-After header. To stay under the limit, use
webhooks instead of frequent polling.
The test case object
A test case is one drug or alcohol test ordered for a driver. Its
result is retrieved separately from
/test-cases/{id}/result.
| Attribute | Description |
|---|---|
idstring (UUID) |
Unique identifier of the test case. |
driverExternalIdstring | null |
Your identifier for the driver, as you sent it in driver.externalId. |
clientExternalIdstring | null |
Your identifier for the company, as you sent it in client.externalId. |
typestring (enum) |
Specimen or exam type. See Test types. |
reasonstring (enum) |
Why the test is being done. See Test reasons. |
otherReasonstring |
Your description of the reason. Present only when reason is other. |
orderDatestring (YYYY-MM-DD) |
The date the test is scheduled for. |
createdAtstring (ISO 8601) |
When the test case was created. |
updatedAtstring (ISO 8601) |
When the test case was last changed. |
Driver and company details (name, license, contact information) are
never returned in responses. Use driverExternalId and
clientExternalId to match test cases to records in your
own system.
Create a test case
Orders a new test for a driver. You don't need any IDs from our system. The driver is matched by license number + license state and the company by USDOT number. If no match exists, the driver or company is created automatically, and the driver is linked to the company.
Each successful call creates a new test case. If a
request times out, check
GET /test-cases before
retrying, so you don't order the same test twice.
Body parameters
| Parameter | Description | |
|---|---|---|
driverobject |
Required | The driver being tested. Fields below. |
driver.firstNamestring |
Required | Driver's first name. |
driver.lastNamestring |
Required | Driver's last name. |
driver.licenseNumberstring |
Required | Driver's license number. Not case-sensitive. Used with licenseState to match an existing driver. |
driver.licenseStatestring |
Required | Two-letter code of the state that issued the license, for example TX. |
driver.phoneNumberstring |
Required | Driver's phone number in E.164 format, for example +15125550123. Required even when the driver already exists. It is saved if the existing driver has no phone number on file. |
driver.emailstring |
Optional | Driver's email address. |
driver.externalIdstring |
Optional | Your own identifier for the driver. Stored as-is and returned as driverExternalId. Not used for matching. |
clientobject |
Required | The motor carrier (company) the driver works for. Fields below. |
client.companyNamestring |
Required | Company's legal name. |
client.usdotstring |
Required | Company's USDOT number. Used to match an existing company. |
client.emailstring |
Required | Company's contact email. |
client.phoneNumberstring |
Required | Company's contact phone number, for example +15125550199. |
client.contactPersonstring |
Required | Name of the company's point of contact. |
client.externalIdstring |
Optional | Your own identifier for the company. Stored as-is and returned as clientExternalId. Not used for matching. |
typestring (enum) |
Required | Specimen or exam type. One of the test types. |
reasonstring (enum) |
Required | Why the test is being done. One of the test reasons. |
otherReasonstring |
Conditional | Required when reason is other: a short description of the reason, up to 200 characters. Ignored for any other reason. |
orderDatestring (YYYY-MM-DD) |
Required | The date the test is scheduled for. |
zipCodestring |
Required | ZIP code where the driver will be tested. Used to find a nearby collection site. |
Example request
curl -X POST https://api.nexascreen.com/v1/test-cases \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"driver": {
"externalId": "partner-driver-4821",
"firstName": "Jane",
"lastName": "Doe",
"licenseNumber": "D1234567",
"licenseState": "TX",
"phoneNumber": "+15125550123",
"email": "jane.doe@example.com"
},
"client": {
"externalId": "partner-company-102",
"companyName": "Acme Trucking LLC",
"usdot": "1234567",
"email": "compliance@acmetrucking.com",
"phoneNumber": "+15125550199",
"contactPerson": "John Smith"
},
"type": "urine",
"reason": "pre-employment",
"orderDate": "2026-09-18",
"zipCode": "90210"
}'
const response = await fetch("https://api.nexascreen.com/v1/test-cases", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
driver: {
externalId: "partner-driver-4821",
firstName: "Jane",
lastName: "Doe",
licenseNumber: "D1234567",
licenseState: "TX",
phoneNumber: "+15125550123",
},
client: {
externalId: "partner-company-102",
companyName: "Acme Trucking LLC",
usdot: "1234567",
email: "compliance@acmetrucking.com",
phoneNumber: "+15125550199",
contactPerson: "John Smith",
},
type: "urine",
reason: "other",
otherReason: "Annual company policy test",
orderDate: "2026-09-18",
zipCode: "90210",
}),
});
if (!response.ok) {
const { error } = await response.json();
throw new Error(`Create failed (${response.status}): ${error}`);
}
const testCase = await response.json(); // save testCase.id
Response — 201 Created
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "created",
"type": "urine",
"reason": "pre-employment",
"orderDate": "2026-09-18"
}
otherReason is included in the response when
reason is other.
Common errors
| Status | Example message |
|---|---|
400 |
type, orderDate are required |
400 |
driver.uuid, or driver.phoneNumber, are required (a required driver field is missing) |
400 |
client.uuid, or client.usdot, are required (a required company field is missing) |
400 |
otherReason is required when reason is other |
400 |
reason must be one of: pre-employment, post-accident, … |
List test cases
Returns the test cases on your account, newest first, one page at a time.
Query parameters
| Parameter | Description | |
|---|---|---|
pageinteger |
Optional | Page number, starting at 1. Default 1. |
limitinteger |
Optional | Results per page, from 1 to 100. Default 20. |
Example request
curl "https://api.nexascreen.com/v1/test-cases?page=1&limit=20" \
-H "Authorization: Bearer YOUR_API_KEY"
Response — 200 OK
{
"data": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"driverExternalId": "partner-driver-4821",
"clientExternalId": "partner-company-102",
"type": "urine",
"reason": "pre-employment",
"orderDate": "2026-09-18",
"createdAt": "2026-09-17T14:03:11.000Z",
"updatedAt": "2026-09-17T14:03:11.000Z"
}
],
"pagination": {
"total": 1,
"page": 1,
"limit": 20,
"totalPages": 1
}
}
data is an array of
test case objects. To read every
page, keep increasing page until it equals
pagination.totalPages.
Retrieve a test case
Returns one test case by its id.
Path parameters
| Parameter | Description | |
|---|---|---|
idstring |
Required | The id returned when the test case was created. |
Example request
curl https://api.nexascreen.com/v1/test-cases/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: Bearer YOUR_API_KEY"
Response — 200 OK
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"driverExternalId": "partner-driver-4821",
"clientExternalId": "partner-company-102",
"type": "urine",
"reason": "other",
"otherReason": "Annual company policy test",
"orderDate": "2026-09-18",
"createdAt": "2026-09-17T14:03:11.000Z",
"updatedAt": "2026-09-18T09:12:40.000Z"
}
Returns 404 with {"error": "Test case not found"}
if the id doesn't exist on your account.
Retrieve a result
Returns the result of a test case and its chain-of-custody form (CCF),
with both documents included as PDF files. Fields stay
null until the information is available, so this endpoint
also works for checking progress.
Path parameters
| Parameter | Description | |
|---|---|---|
idstring |
Required | The test case id. |
Example request
curl https://api.nexascreen.com/v1/test-cases/a1b2c3d4-e5f6-7890-abcd-ef1234567890/result \
-H "Authorization: Bearer YOUR_API_KEY"
Response — 200 OK
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"result": "negative",
"resultDate": "2026-09-22",
"resultAddedAt": "2026-09-22T16:40:02.000Z",
"resultPdf": "JVBERi0xLjQKJc...",
"ccfNumber": "ABC123",
"ccfAddedAt": "2026-09-18T14:03:11.000Z",
"ccfForm": "JVBERi0xLjQKJc..."
}
Response fields
| Field | Description |
|---|---|
idstring |
The test case id. |
resultstring (enum) | null |
Test outcome. See Result values. null until the result is available. |
resultDatestring | null |
Date the result was reported (YYYY-MM-DD). |
resultAddedAtstring (ISO 8601) | null |
When the result was recorded. |
resultPdfstring (base64) | null |
The result report as a base64-encoded PDF. |
ccfNumberstring | null |
Chain-of-custody form number, available once the specimen is collected. |
ccfAddedAtstring (ISO 8601) | null |
When the CCF was recorded. |
ccfFormstring (base64) | null |
The chain-of-custody form as a base64-encoded PDF. |
Saving the PDFs
const fs = require("fs");
const res = await fetch(`https://api.nexascreen.com/v1/test-cases/${id}/result`, {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
});
const result = await res.json();
if (result.resultPdf) {
fs.writeFileSync(`result-${id}.pdf`, Buffer.from(result.resultPdf, "base64"));
}
if (result.ccfForm) {
fs.writeFileSync(`ccf-${id}.pdf`, Buffer.from(result.ccfForm, "base64"));
}
Webhooks
Webhooks notify your system when something happens to a test case, so
you don't need to poll. When an event happens, we send an HTTPS
POST request to the endpoint URL you registered with your
provider.
Payloads are deliberately small: they say what changed, not
the full data. When you receive an event, read
resourceId and fetch the latest state from the API, for
example GET /test-cases/{id}/result
after test_case.result_added.
Setting up your endpoint
- Expose a public HTTPS URL that accepts
POSTrequests with a JSON body. - Send that URL to your provider. They register it and give you its signing secret. Store the secret securely; it is shown only once.
- Verify the signature of every request before you trust it.
- Respond with any
2xxstatus within 10 seconds. Do slow work, such as downloading PDFs, after you respond.
Event types
| Event | Sent when | Suggested action |
|---|---|---|
test_case.created |
A test case was created. | Confirm the order on your side. |
test_case.updated |
A test case was updated, for example when the specimen was collected and the CCF was added. | Call GET /test-cases/{id}/result to get ccfNumber and ccfForm. |
test_case.result_added |
The test result is available. | Call GET /test-cases/{id}/result to download the result. |
Payload
{
"event": "test_case.result_added",
"tenantId": "8f14e45f-ceea-467e-b7f1-9f0a3d2c1b45",
"resourceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"resourceType": "test_case",
"timestamp": "2026-09-22T16:40:02.000Z"
}
| Field | Description |
|---|---|
event |
The event type. |
tenantId |
Identifier of your provider's account that sent the event. |
resourceId |
The test case id. Use it with the API endpoints. |
resourceType |
Always test_case. |
timestamp |
When the event happened (ISO 8601, UTC). |
Request headers
| Header | Description |
|---|---|
X-Webhook-Signature |
Signature used to verify the request. Format: t=<unix_seconds>,v1=<hex_hmac>. |
X-Webhook-Event |
The event type, same as event in the body. |
X-Webhook-Event-ID |
Unique ID of the event. Stays the same across retries, so use it to skip duplicates. |
X-Webhook-Delivery-ID |
ID of this delivery to your endpoint. |
Content-Type |
application/json |
Verifying signatures
Every request is signed with your endpoint's signing secret, so you can confirm it came from us and wasn't changed. To verify a request:
- Read the header
X-Webhook-Signatureand split it intot(timestamp) andv1(signature). - Build the signed string:
{t}.{raw request body}. Use the raw body exactly as received, before parsing it as JSON. - Compute an HMAC-SHA256 of that string, using your signing secret as the key, and hex-encode it.
- Compare it with
v1using a constant-time comparison. - Reject the request if
tis more than 5 minutes away from your current time. This prevents replay attacks.
const crypto = require("crypto");
const express = require("express");
const app = express();
const SIGNING_SECRET = process.env.WEBHOOK_SIGNING_SECRET;
const TOLERANCE_SECONDS = 300;
function isValidSignature(header, rawBody) {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const timestamp = Number(parts.t);
if (!timestamp || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const received = Buffer.from(parts.v1, "hex");
const computed = Buffer.from(expected, "hex");
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
// Use the raw body: re-serialized JSON will not match the signature.
app.post("/webhooks/testing", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!isValidSignature(req.get("X-Webhook-Signature"), rawBody)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(rawBody);
res.sendStatus(200); // acknowledge first, then process
// e.g. queue a job: fetch GET /test-cases/{event.resourceId}/result
});
import hashlib, hmac, time
def is_valid_signature(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = int(parts["t"])
received = parts["v1"]
except (AttributeError, KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(received, expected)
Delivery & retries
A delivery succeeds when your endpoint responds with a
2xx status within 10 seconds. Any other
status, a timeout or a connection error counts as a failure, and the
delivery is retried on this schedule:
| Attempt | Sent |
|---|---|
| 1 | Right after the event |
| 2 | 1 minute after attempt 1 fails |
| 3 | 5 minutes after attempt 2 fails |
| 4 | 15 minutes after attempt 3 fails |
| 5 | 1 hour after attempt 4 fails |
| 6 | 6 hours after attempt 5 fails (last attempt) |
If all six attempts fail, the event is not sent again. You can still get the current state at any time from the API.
Best practices
- Handle duplicates. The same event can arrive more than once. Store
X-Webhook-Event-IDand ignore events you have already processed. - Don't rely on order. Events can arrive out of order. Always fetch the current state from the API rather than relying on the event sequence.
- Respond quickly. Return
2xxright away and do the processing in the background. - Reconcile periodically. As a safety net, use
GET /test-casesnow and then to catch any events you missed.
Reference
Test types
Allowed values for type.
| Value | Description |
|---|---|
urine | Urine drug test |
hair | Hair drug test |
saliva | Oral fluid (saliva) drug test |
breath | Breath alcohol test |
dotPhysical | DOT physical examination |
nonDotPhysical | Non-DOT physical examination |
Test reasons
Allowed values for reason.
| Value | Description |
|---|---|
pre-employment | Before the driver starts safety-sensitive work |
post-accident | After a qualifying accident |
reasonable-suspicion | Based on observed signs of drug or alcohol use |
follow-up | Unannounced tests after a return to duty |
random | Random selection from a testing pool |
return-to-duty | Before returning to duty after a violation |
other | Any other reason. You must describe it in otherReason (up to 200 characters). |
Result values
Possible values for result.
| Value | Description |
|---|---|
negative | No prohibited substances detected |
negative-dilute | Negative, but the specimen was dilute |
positive | One or more prohibited substances detected |
positive-dilute | Positive, and the specimen was dilute |
dilute | The specimen was dilute |
invalid | The specimen could not be tested |
pass | Passed (physicals and breath alcohol tests) |
fail | Failed (physicals and breath alcohol tests) |