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:

Base URL

All endpoints below are relative to:

Base URL
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:

  1. 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.
  2. Create a test case with POST /test-cases, identifying the driver by license number and state and the company by USDOT number. Save the id from the response; you need it for every later call.
  3. Wait for the result. Listen for the test_case.result_added webhook, or poll GET /test-cases/{id}/result until result is no longer null.
  4. 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.

Header
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

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 response
{
  "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:

Response headers
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

POST/test-cases

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
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"
  }'
Node.js
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

JSON
{
  "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

GET/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
curl "https://api.nexascreen.com/v1/test-cases?page=1&limit=20" \
  -H "Authorization: Bearer YOUR_API_KEY"

Response — 200 OK

JSON
{
  "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

GET/test-cases/{id}

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
curl https://api.nexascreen.com/v1/test-cases/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
  -H "Authorization: Bearer YOUR_API_KEY"

Response — 200 OK

JSON
{
  "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

GET/test-cases/{id}/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
curl https://api.nexascreen.com/v1/test-cases/a1b2c3d4-e5f6-7890-abcd-ef1234567890/result \
  -H "Authorization: Bearer YOUR_API_KEY"

Response — 200 OK

JSON
{
  "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

Node.js
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

  1. Expose a public HTTPS URL that accepts POST requests with a JSON body.
  2. Send that URL to your provider. They register it and give you its signing secret. Store the secret securely; it is shown only once.
  3. Verify the signature of every request before you trust it.
  4. Respond with any 2xx status 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

POST body
{
  "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:

  1. Read the header X-Webhook-Signature and split it into t (timestamp) and v1 (signature).
  2. Build the signed string: {t}.{raw request body}. Use the raw body exactly as received, before parsing it as JSON.
  3. Compute an HMAC-SHA256 of that string, using your signing secret as the key, and hex-encode it.
  4. Compare it with v1 using a constant-time comparison.
  5. Reject the request if t is more than 5 minutes away from your current time. This prevents replay attacks.
Node.js (Express)
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
});
Python
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
1Right after the event
21 minute after attempt 1 fails
35 minutes after attempt 2 fails
415 minutes after attempt 3 fails
51 hour after attempt 4 fails
66 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

Reference

Test types

Allowed values for type.

Value Description
urineUrine drug test
hairHair drug test
salivaOral fluid (saliva) drug test
breathBreath alcohol test
dotPhysicalDOT physical examination
nonDotPhysicalNon-DOT physical examination

Test reasons

Allowed values for reason.

Value Description
pre-employmentBefore the driver starts safety-sensitive work
post-accidentAfter a qualifying accident
reasonable-suspicionBased on observed signs of drug or alcohol use
follow-upUnannounced tests after a return to duty
randomRandom selection from a testing pool
return-to-dutyBefore returning to duty after a violation
otherAny other reason. You must describe it in otherReason (up to 200 characters).

Result values

Possible values for result.

Value Description
negativeNo prohibited substances detected
negative-diluteNegative, but the specimen was dilute
positiveOne or more prohibited substances detected
positive-dilutePositive, and the specimen was dilute
diluteThe specimen was dilute
invalidThe specimen could not be tested
passPassed (physicals and breath alcohol tests)
failFailed (physicals and breath alcohol tests)