Skip to main content

NDA Verification API Endpoints Design

Classification: Internal — Engineering Date: 2026-02-16 Status: Active Governance: ADR-196 (NDA-Gated Conditional Access)


Table of Contents

  1. Overview
  2. API Conventions
  3. POST /api/v1/nda/verify
  4. POST /api/v1/nda/sign
  5. GET /api/v1/nda/status
  6. POST /api/v1/tokens/grant
  7. POST /api/v1/tokens/validate
  8. POST /api/v1/tokens/revoke
  9. GET /api/v1/nda/document/{version}
  10. Security Architecture
  11. Django Implementation
  12. OpenAPI Specification
  13. Testing Strategy
  14. Error Handling
  15. Performance Considerations

1. Overview

1.1 Purpose

The NDA Verification API provides REST endpoints for managing Non-Disclosure Agreement (NDA) signatures, verification, and document access token grants for the BIO-QMS platform. This API enforces NDA-gated conditional access as specified in ADR-196.

1.2 Scope

ComponentCoverage
NDA LifecycleVerification, signing, status tracking, renewal
Token ManagementGrant, validate, revoke document view tokens
Document AccessNDA document retrieval with watermarking
Audit TrailComplete audit logging of all NDA operations
SecurityOWASP-compliant authentication, authorization, rate limiting

1.3 Architecture Context

┌─────────────────────────────────────────────────────────────┐
│ External Clients (Browser, Mobile, Third-party) │
└───────────────────────┬─────────────────────────────────────┘
│ HTTPS/TLS 1.3
┌───────────────────────▼─────────────────────────────────────┐
│ API Gateway (CORS, Rate Limiting, DDoS Protection) │
└───────────────────────┬─────────────────────────────────────┘

┌───────────────────────▼─────────────────────────────────────┐
│ Django REST Framework API Layer │
│ ┌─────────────────────────────────────────────────────────┤
│ │ NDA ViewSets Token ViewSets │
│ │ - NDAVerifyView - TokenGrantView │
│ │ - NDASignView - TokenValidateView │
│ │ - NDAStatusView - TokenRevokeView │
│ │ - NDADocumentView │
│ └─────────────────────────────────────────────────────────┤
│ │ │
│ ┌─────────────────────▼─────────────────────────────────┐ │
│ │ Authentication & Authorization Middleware │ │
│ │ - JWT Validation - API Key Validation │ │
│ │ - Permission Checks - Rate Limiting │ │
│ └─────────────────────┬───────────────────────────────────┘ │
└────────────────────────┼─────────────────────────────────────┘

┌────────────────────────▼─────────────────────────────────────┐
│ Business Logic Layer │
│ - NDA Verification Service - Token Management Service │
│ - Signature Validation - Redis Cache Layer │
│ - Duplicate Detection - Email Notification Service │
└────────────────────────┬─────────────────────────────────────┘

┌────────────────────────▼─────────────────────────────────────┐
│ Data Layer │
│ - PostgreSQL (NDA records, signatures, audit trail) │
│ - Redis (Token cache, rate limiting) │
│ - Cloud Storage (NDA PDFs, signature artifacts) │
└──────────────────────────────────────────────────────────────┘

1.4 Design Principles

PrincipleImplementation
Security FirstAll endpoints require authentication; sensitive operations require re-authentication
Audit EverythingImmutable audit trail for all NDA and token operations
PerformanceRedis-backed token validation for high-frequency viewer requests
OWASP ComplianceInput validation, output encoding, CSRF protection, secure headers
Fail SecureInvalid NDA → no token grant; expired token → immediate rejection
IdempotencyDuplicate NDA signatures detected and rejected

2. API Conventions

2.1 Base URL & Versioning

Production:  https://api.coditect-bio-qms.io/v1
Staging: https://api-staging.coditect-bio-qms.io/v1
Development: http://localhost:8000/v1

Versioning: URL path (/v1/, /v2/)
Current: v1

2.2 Authentication Methods

MethodHeaderUse CaseLifetime
JWT (User)Authorization: Bearer <jwt>Authenticated users signing NDAs, checking status1 hour (access), 7 days (refresh)
API Key (Service)X-API-Key: <key>Service-to-service NDA verification90-day rotation
Bearer Token (Viewer)Authorization: Bearer <token>Document viewer validation (high frequency)Project-specific TTL (7-90 days)

2.3 Standard Headers

HeaderDirectionRequiredDescription
AuthorizationRequestYesJWT or Bearer token
Content-TypeRequestYes (POST/PUT)application/json
X-Request-IDRequestRecommendedIdempotency key (UUID v7)
X-Correlation-IDResponseAlwaysTrace ID for debugging
X-RateLimit-RemainingResponseAlwaysRemaining requests in window
X-RateLimit-ResetResponseAlwaysUnix timestamp when window resets

2.4 Rate Limits

EndpointAuthenticated UsersAPI KeysAnonymousWindow
/nda/verify60/min300/min10/minRolling 60s
/nda/sign10/minN/AN/ARolling 60s
/nda/status30/min100/minN/ARolling 60s
/tokens/grant30/min100/minN/ARolling 60s
/tokens/validate300/min1000/minN/ARolling 60s
/tokens/revoke10/min30/minN/ARolling 60s
/nda/document/*20/min50/min5/minRolling 60s

Rate Limiting Strategy:

  • Per-IP for anonymous requests
  • Per-user-ID for authenticated users
  • Per-API-key for service accounts
  • Redis-backed sliding window
  • 429 Too Many Requests with Retry-After header

2.5 Error Response Format (RFC 7807)

{
"type": "https://docs.coditect-bio-qms.io/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "NDA signature already exists for this user and version",
"instance": "/v1/nda/sign",
"correlation_id": "01HRX7QBPK4N8JMZV3Y5K2DWTF",
"errors": [
{
"field": "signer_email",
"code": "DUPLICATE_SIGNATURE",
"message": "Active NDA signature already exists for user@example.com on version 1.0"
}
],
"timestamp": "2026-02-16T14:30:00.000Z"
}

2.6 CORS Configuration

# Django settings.py
CORS_ALLOWED_ORIGINS = [
"https://docs.coditect.ai",
"https://staging-docs.coditect.ai",
"https://bio-qms.coditect.ai",
]

CORS_ALLOW_METHODS = [
"GET",
"POST",
"OPTIONS",
]

CORS_ALLOW_HEADERS = [
"authorization",
"content-type",
"x-request-id",
"x-api-key",
]

CORS_EXPOSE_HEADERS = [
"x-correlation-id",
"x-ratelimit-remaining",
"x-ratelimit-reset",
]

CORS_ALLOW_CREDENTIALS = True

2.7 Security Headers (OWASP)

# Django middleware configuration
SECURE_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "geolocation=(), microphone=(), camera=()",
}

3. POST /api/v1/nda/verify

3.1 Purpose

Check if a user has a valid, active NDA signature for a specific project. This is a lightweight verification endpoint called before granting document access.

3.2 Endpoint Specification

POST /api/v1/nda/verify

Authentication: JWT (user) or API Key (service) Authorization: Any authenticated user or service Rate Limit: 60 req/min (user), 300 req/min (API key)

3.3 Request Schema

{
"email": "researcher@biotech.com",
"project_id": "proj_01HRX7QBPK4N8JMZV3Y5K2DWTF"
}
FieldTypeRequiredValidationDescription
emailstringYesValid email format, max 254 charsUser email to check
project_idstringYesULID format, prefix proj_Project identifier

DRF Serializer:

from rest_framework import serializers
from django.core.validators import EmailValidator

class NDAVerifyRequestSerializer(serializers.Serializer):
email = serializers.EmailField(
max_length=254,
validators=[EmailValidator()],
help_text="Email address to verify NDA status"
)
project_id = serializers.CharField(
max_length=64,
help_text="Project ULID (e.g., proj_01HRX...)"
)

def validate_project_id(self, value):
if not value.startswith('proj_'):
raise serializers.ValidationError("Project ID must start with 'proj_'")
if len(value) != 32:
raise serializers.ValidationError("Invalid project ID format")
return value

3.4 Response Schema (200 OK)

{
"has_valid_nda": true,
"nda_version": "1.0",
"signed_at": "2026-01-15T10:30:00Z",
"expires_at": "2027-01-15T10:30:00Z",
"renewal_needed": false,
"days_until_expiry": 334,
"nda_id": "nda_01HRX8ABCD1234567890EFGHIJ"
}
FieldTypeDescription
has_valid_ndabooleantrue if active NDA exists, false otherwise
nda_versionstringNDA document version (null if no NDA)
signed_atdatetimeISO 8601 timestamp (null if no NDA)
expires_atdatetimeISO 8601 timestamp (null if no expiry)
renewal_neededbooleantrue if expiry < 30 days
days_until_expiryintegerDays remaining (null if no expiry)
nda_idstringNDA record ULID (null if no NDA)

DRF Serializer:

class NDAVerifyResponseSerializer(serializers.Serializer):
has_valid_nda = serializers.BooleanField()
nda_version = serializers.CharField(max_length=16, allow_null=True)
signed_at = serializers.DateTimeField(allow_null=True)
expires_at = serializers.DateTimeField(allow_null=True)
renewal_needed = serializers.BooleanField()
days_until_expiry = serializers.IntegerField(allow_null=True)
nda_id = serializers.CharField(max_length=64, allow_null=True)

3.5 Response Schema (No NDA Found)

{
"has_valid_nda": false,
"nda_version": null,
"signed_at": null,
"expires_at": null,
"renewal_needed": false,
"days_until_expiry": null,
"nda_id": null
}

3.6 Error Responses

StatusError CodeDescriptionRetry
400INVALID_EMAILEmail format invalidNo
400INVALID_PROJECT_IDProject ID format invalidNo
401TOKEN_EXPIREDJWT expiredYes (refresh)
401TOKEN_INVALIDToken signature invalidNo
404PROJECT_NOT_FOUNDProject does not existNo
429RATE_EXCEEDEDToo many requestsYes (after reset)

Example 400 Error:

{
"type": "https://docs.coditect-bio-qms.io/errors/invalid-email",
"title": "Invalid Email",
"status": 400,
"detail": "Email address format is invalid",
"instance": "/v1/nda/verify",
"correlation_id": "01HRX7QBPK4N8JMZV3Y5K2DWTF",
"errors": [
{
"field": "email",
"code": "INVALID_EMAIL",
"message": "Enter a valid email address."
}
],
"timestamp": "2026-02-16T14:30:00.000Z"
}

3.7 Business Logic

def verify_nda(email: str, project_id: str) -> NDAVerifyResponse:
"""
Verify if user has valid NDA for project.

Logic:
1. Query NDA table for active signature (email + project_id)
2. Check expiry date if present
3. Calculate renewal_needed (< 30 days to expiry)
4. Return verification result
"""
try:
nda = NDA.objects.get(
signer_email=email,
project_id=project_id,
is_active=True
)
except NDA.DoesNotExist:
return NDAVerifyResponse(
has_valid_nda=False,
nda_version=None,
signed_at=None,
expires_at=None,
renewal_needed=False,
days_until_expiry=None,
nda_id=None
)

# Check expiry
now = timezone.now()
expired = nda.expires_at and nda.expires_at < now

if expired:
return NDAVerifyResponse(
has_valid_nda=False,
nda_version=nda.nda_version,
signed_at=nda.signed_at,
expires_at=nda.expires_at,
renewal_needed=True,
days_until_expiry=0,
nda_id=nda.id
)

# Calculate days until expiry
days_until_expiry = None
renewal_needed = False
if nda.expires_at:
delta = nda.expires_at - now
days_until_expiry = delta.days
renewal_needed = days_until_expiry < 30

return NDAVerifyResponse(
has_valid_nda=True,
nda_version=nda.nda_version,
signed_at=nda.signed_at,
expires_at=nda.expires_at,
renewal_needed=renewal_needed,
days_until_expiry=days_until_expiry,
nda_id=nda.id
)

3.8 Audit Trail

# Audit log entry
AuditTrail.objects.create(
entity_type="NDA",
entity_id=nda.id if nda else None,
action="VERIFY",
performed_by=request.user.id,
performed_at=timezone.now(),
ip_address=get_client_ip(request),
user_agent=request.META.get('HTTP_USER_AGENT', ''),
metadata={
"email": email,
"project_id": project_id,
"has_valid_nda": has_valid_nda
}
)

4. POST /api/v1/nda/sign

4.1 Purpose

Record an NDA signature through an electronic signing flow. This endpoint captures the signer's consent, signature data, and metadata, then auto-grants a document view token for the associated project.

4.2 Endpoint Specification

POST /api/v1/nda/sign

Authentication: JWT (user only — no API keys) Authorization: Any authenticated user Rate Limit: 10 req/min (user) Idempotency: Duplicate signatures rejected

4.3 Request Schema

{
"signer_email": "researcher@biotech.com",
"signer_name": "Dr. Jane Smith",
"company": "BioTech Research Inc.",
"nda_version": "1.0",
"project_id": "proj_01HRX7QBPK4N8JMZV3Y5K2DWTF",
"signature_data": {
"type": "click-to-sign",
"consent_text": "I agree to the terms of the NDA version 1.0",
"ip_address": "203.0.113.42",
"timestamp": "2026-02-16T14:30:00.000Z",
"user_agent": "Mozilla/5.0 ...",
"coordinates": null,
"canvas_data": null
},
"consent_timestamp": "2026-02-16T14:30:00.000Z",
"metadata": {
"referrer": "https://bio-qms.coditect.ai/documents/project-123",
"utm_source": "email"
}
}
FieldTypeRequiredValidationDescription
signer_emailstringYesValid email, max 254 charsSigner's email address
signer_namestringYes1-200 chars, no HTMLFull name of signer
companystringNoMax 200 charsCompany/organization name
nda_versionstringYesSemantic version (e.g., "1.0")NDA document version being signed
project_idstringYesULID formatProject the NDA applies to
signature_dataobjectYesSee signature schema belowSignature capture details
consent_timestampdatetimeYesISO 8601 UTCWhen user clicked final consent
metadataobjectNoMax 5KB JSONAdditional tracking data

Signature Data Schema:

FieldTypeRequiredDescription
typeenumYes"click-to-sign", "draw", or "typed"
consent_textstringYesExact consent text shown to user
ip_addressstringYesIPv4 or IPv6 address
timestampdatetimeYesISO 8601 UTC timestamp
user_agentstringYesBrowser user agent string
coordinatesobjectConditionalRequired if type="draw": {x: number, y: number}
canvas_datastringConditionalRequired if type="draw": base64-encoded PNG

DRF Serializer:

class SignatureDataSerializer(serializers.Serializer):
SIGNATURE_TYPES = ['click-to-sign', 'draw', 'typed']

type = serializers.ChoiceField(choices=SIGNATURE_TYPES)
consent_text = serializers.CharField(max_length=1000)
ip_address = serializers.IPAddressField()
timestamp = serializers.DateTimeField()
user_agent = serializers.CharField(max_length=500)
coordinates = serializers.JSONField(required=False, allow_null=True)
canvas_data = serializers.CharField(required=False, allow_null=True)

def validate(self, data):
if data['type'] == 'draw':
if not data.get('coordinates') or not data.get('canvas_data'):
raise serializers.ValidationError(
"coordinates and canvas_data required for draw signatures"
)
return data

class NDASignRequestSerializer(serializers.Serializer):
signer_email = serializers.EmailField(max_length=254)
signer_name = serializers.CharField(max_length=200)
company = serializers.CharField(max_length=200, required=False, allow_blank=True)
nda_version = serializers.CharField(max_length=16)
project_id = serializers.CharField(max_length=64)
signature_data = SignatureDataSerializer()
consent_timestamp = serializers.DateTimeField()
metadata = serializers.JSONField(required=False, default=dict)

def validate_project_id(self, value):
if not value.startswith('proj_'):
raise serializers.ValidationError("Invalid project ID format")
return value

def validate_signer_name(self, value):
if '<' in value or '>' in value:
raise serializers.ValidationError("HTML not allowed in name")
return value

4.4 Multi-Step Signing Flow

The NDA signing process follows a 4-step flow:

Step 1: Present NDA
↓ User reviews NDA document
Step 2: Confirm Reading
↓ User confirms they have read and understood
Step 3: Capture Signature
↓ User provides signature (click/draw/type)
Step 4: Confirm and Submit
↓ POST /api/v1/nda/sign

Frontend Implementation Pattern:

// Step 1: Fetch NDA document
const ndaDoc = await fetch(`/api/v1/nda/document/${version}`, {
headers: { 'Authorization': `Bearer ${jwt}` }
});

// Step 2: Require scroll to bottom + checkbox
<Checkbox required>
I have read and understood the NDA
</Checkbox>

// Step 3: Capture signature
const signatureData = {
type: 'click-to-sign',
consent_text: 'I agree to the terms...',
ip_address: await getClientIP(),
timestamp: new Date().toISOString(),
user_agent: navigator.userAgent
};

// Step 4: Submit
await fetch('/api/v1/nda/sign', {
method: 'POST',
headers: {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
signer_email: userEmail,
signer_name: userName,
nda_version: '1.0',
project_id: projectId,
signature_data: signatureData,
consent_timestamp: new Date().toISOString()
})
});

4.5 Response Schema (201 Created)

{
"nda_id": "nda_01HRX8ABCD1234567890EFGHIJ",
"signer_email": "researcher@biotech.com",
"signer_name": "Dr. Jane Smith",
"nda_version": "1.0",
"signed_at": "2026-02-16T14:30:00.000Z",
"expires_at": "2027-02-16T14:30:00.000Z",
"is_active": true,
"document_view_token": {
"token": "dvt_01HRX9BCDE2345678901FGHIJK",
"expires_at": "2026-05-16T14:30:00.000Z",
"scope": "read",
"project_id": "proj_01HRX7QBPK4N8JMZV3Y5K2DWTF"
},
"confirmation_email_sent": true
}
FieldTypeDescription
nda_idstringCreated NDA record ULID
signer_emailstringEmail address of signer
signer_namestringFull name of signer
nda_versionstringNDA version signed
signed_atdatetimeISO 8601 timestamp of signature
expires_atdatetimeExpiry timestamp (null if no expiry)
is_activebooleanAlways true on creation
document_view_tokenobjectAuto-granted token for document access
confirmation_email_sentbooleanEmail delivery status

DRF Serializer:

class DocumentViewTokenSerializer(serializers.Serializer):
token = serializers.CharField(max_length=64)
expires_at = serializers.DateTimeField()
scope = serializers.CharField(max_length=16)
project_id = serializers.CharField(max_length=64)

class NDASignResponseSerializer(serializers.Serializer):
nda_id = serializers.CharField(max_length=64)
signer_email = serializers.EmailField()
signer_name = serializers.CharField(max_length=200)
nda_version = serializers.CharField(max_length=16)
signed_at = serializers.DateTimeField()
expires_at = serializers.DateTimeField(allow_null=True)
is_active = serializers.BooleanField()
document_view_token = DocumentViewTokenSerializer()
confirmation_email_sent = serializers.BooleanField()

4.6 Duplicate Detection

def check_duplicate_signature(email: str, nda_version: str, project_id: str) -> bool:
"""
Check if active NDA signature already exists.

Returns True if duplicate (should reject), False otherwise.
"""
existing = NDA.objects.filter(
signer_email=email,
nda_version=nda_version,
project_id=project_id,
is_active=True
).exists()

return existing

4.7 Error Responses

StatusError CodeDescriptionRetry
400INVALID_INPUTMalformed JSON or missing required fieldNo
400INVALID_EMAILEmail format invalidNo
400INVALID_SIGNATURE_DATASignature data validation failedNo
401TOKEN_EXPIREDJWT expiredYes (refresh)
404PROJECT_NOT_FOUNDProject does not existNo
404NDA_VERSION_NOT_FOUNDNDA version does not existNo
409DUPLICATE_SIGNATUREActive signature already existsNo
422VALIDATION_FAILEDBusiness rule validation failedNo
429RATE_EXCEEDEDToo many requestsYes (after reset)

Example 409 Duplicate Error:

{
"type": "https://docs.coditect-bio-qms.io/errors/duplicate-signature",
"title": "Duplicate Signature",
"status": 409,
"detail": "An active NDA signature already exists for this user and version",
"instance": "/v1/nda/sign",
"correlation_id": "01HRX7QBPK4N8JMZV3Y5K2DWTF",
"errors": [
{
"field": "signer_email",
"code": "DUPLICATE_SIGNATURE",
"message": "Active NDA signature already exists for researcher@biotech.com on version 1.0"
}
],
"existing_nda_id": "nda_01HRX8ABCD1234567890EFGHIJ",
"timestamp": "2026-02-16T14:30:00.000Z"
}

4.8 Business Logic

from django.db import transaction
from django.utils import timezone
import ulid

@transaction.atomic
def sign_nda(request_data: dict, user: User) -> NDASignResponse:
"""
Record NDA signature and auto-grant document view token.

Steps:
1. Validate NDA version exists
2. Check for duplicate signature
3. Create NDA record
4. Store signature data in secure storage
5. Auto-grant DocumentViewToken
6. Send confirmation email
7. Create audit trail entries
"""
# 1. Validate NDA version exists
try:
nda_version_doc = NDAVersion.objects.get(version=request_data['nda_version'])
except NDAVersion.DoesNotExist:
raise ValidationError(f"NDA version {request_data['nda_version']} not found")

# 2. Check duplicate
if check_duplicate_signature(
request_data['signer_email'],
request_data['nda_version'],
request_data['project_id']
):
raise DuplicateSignatureError("Active signature already exists")

# 3. Create NDA record
nda = NDA.objects.create(
id=f"nda_{ulid.new()}",
signer_email=request_data['signer_email'],
signer_name=request_data['signer_name'],
company=request_data.get('company', ''),
nda_version=request_data['nda_version'],
project_id=request_data['project_id'],
signed_at=timezone.now(),
expires_at=calculate_expiry(nda_version_doc.ttl_days),
is_active=True,
consent_timestamp=request_data['consent_timestamp'],
metadata=request_data.get('metadata', {})
)

# 4. Store signature data
signature_file = store_signature_data(
nda.id,
request_data['signature_data']
)

# 5. Auto-grant token
token = DocumentViewToken.objects.create(
id=f"dvt_{ulid.new()}",
nda_id=nda.id,
token=generate_secure_token(),
project_id=request_data['project_id'],
scope='read',
expires_at=calculate_token_expiry(request_data['project_id']),
is_active=True,
granted_at=timezone.now()
)

# Cache token in Redis for fast validation
cache_token(token.token, token.id, token.expires_at)

# 6. Send confirmation email
email_sent = send_nda_confirmation_email(
to=nda.signer_email,
nda=nda,
signed_pdf=generate_signed_nda_pdf(nda, signature_file)
)

# 7. Audit trail
AuditTrail.objects.create(
entity_type="NDA",
entity_id=nda.id,
action="SIGN",
performed_by=user.id,
performed_at=timezone.now(),
ip_address=request_data['signature_data']['ip_address'],
user_agent=request_data['signature_data']['user_agent'],
metadata={
"nda_version": nda.nda_version,
"signature_type": request_data['signature_data']['type']
}
)

return NDASignResponse(
nda_id=nda.id,
signer_email=nda.signer_email,
signer_name=nda.signer_name,
nda_version=nda.nda_version,
signed_at=nda.signed_at,
expires_at=nda.expires_at,
is_active=nda.is_active,
document_view_token={
"token": token.token,
"expires_at": token.expires_at,
"scope": token.scope,
"project_id": token.project_id
},
confirmation_email_sent=email_sent
)

4.9 Confirmation Email

def send_nda_confirmation_email(to: str, nda: NDA, signed_pdf: bytes) -> bool:
"""
Send NDA signature confirmation with signed PDF attachment.

Template variables:
- signer_name
- nda_version
- signed_at
- project_name
- expires_at
"""
subject = f"NDA Signature Confirmation - {nda.project.name}"

html_body = render_to_string('emails/nda_confirmation.html', {
'signer_name': nda.signer_name,
'nda_version': nda.nda_version,
'signed_at': nda.signed_at,
'project_name': nda.project.name,
'expires_at': nda.expires_at,
'document_url': f"https://docs.coditect.ai/nda/{nda.id}"
})

email = EmailMessage(
subject=subject,
body=html_body,
from_email='noreply@coditect-bio-qms.io',
to=[to]
)
email.content_subtype = 'html'
email.attach(
filename=f"NDA_{nda.nda_version}_signed.pdf",
content=signed_pdf,
mimetype='application/pdf'
)

try:
email.send()
return True
except Exception as e:
logger.error(f"Failed to send NDA confirmation email: {e}")
return False

5. GET /api/v1/nda/status

5.1 Purpose

Retrieve current NDA status for the authenticated user, including all NDA signatures and associated document view tokens.

5.2 Endpoint Specification

GET /api/v1/nda/status

Authentication: JWT (user only) Authorization: Authenticated user (can only see own NDAs) Rate Limit: 30 req/min

5.3 Request

No request body. Authentication via JWT in Authorization header.

GET /api/v1/nda/status HTTP/1.1
Host: api.coditect-bio-qms.io
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

5.4 Response Schema (200 OK)

{
"user_email": "researcher@biotech.com",
"ndas": [
{
"nda_id": "nda_01HRX8ABCD1234567890EFGHIJ",
"nda_version": "1.0",
"project_id": "proj_01HRX7QBPK4N8JMZV3Y5K2DWTF",
"project_name": "Phase 2 Clinical Trial Data",
"signed_at": "2026-01-15T10:30:00Z",
"expires_at": "2027-01-15T10:30:00Z",
"is_active": true,
"renewal_needed": false,
"days_until_expiry": 334,
"tokens": [
{
"token_id": "dvt_01HRX9BCDE2345678901FGHIJK",
"scope": "read",
"granted_at": "2026-01-15T10:30:00Z",
"expires_at": "2026-04-15T10:30:00Z",
"is_active": true,
"last_used_at": "2026-02-10T14:20:00Z"
}
]
},
{
"nda_id": "nda_01HRX8ZYXW9876543210VWXYZ",
"nda_version": "1.1",
"project_id": "proj_01HRX7MNOP4321FEDCBA123456",
"project_name": "Biomarker Discovery Platform",
"signed_at": "2025-12-01T09:00:00Z",
"expires_at": "2026-03-01T09:00:00Z",
"is_active": true,
"renewal_needed": true,
"days_until_expiry": 13,
"tokens": []
}
],
"total_active_ndas": 2
}
FieldTypeDescription
user_emailstringAuthenticated user's email
ndasarrayList of NDA records for this user
total_active_ndasintegerCount of active NDAs

NDA Object Schema:

FieldTypeDescription
nda_idstringNDA record ULID
nda_versionstringNDA document version
project_idstringAssociated project ULID
project_namestringHuman-readable project name
signed_atdatetimeSignature timestamp
expires_atdatetimeExpiry timestamp (null if no expiry)
is_activebooleanActive status
renewal_neededbooleanTrue if < 30 days to expiry
days_until_expiryintegerDays remaining (null if no expiry)
tokensarrayAssociated document view tokens

Token Object Schema:

FieldTypeDescription
token_idstringToken ULID (NOT the token itself)
scopestringAccess scope (e.g., "read")
granted_atdatetimeWhen token was granted
expires_atdatetimeToken expiry
is_activebooleanActive status
last_used_atdatetimeLast validation timestamp (null if never used)

DRF Serializers:

class TokenStatusSerializer(serializers.Serializer):
token_id = serializers.CharField(max_length=64)
scope = serializers.CharField(max_length=16)
granted_at = serializers.DateTimeField()
expires_at = serializers.DateTimeField()
is_active = serializers.BooleanField()
last_used_at = serializers.DateTimeField(allow_null=True)

class NDAStatusSerializer(serializers.Serializer):
nda_id = serializers.CharField(max_length=64)
nda_version = serializers.CharField(max_length=16)
project_id = serializers.CharField(max_length=64)
project_name = serializers.CharField(max_length=200)
signed_at = serializers.DateTimeField()
expires_at = serializers.DateTimeField(allow_null=True)
is_active = serializers.BooleanField()
renewal_needed = serializers.BooleanField()
days_until_expiry = serializers.IntegerField(allow_null=True)
tokens = TokenStatusSerializer(many=True)

class NDAStatusResponseSerializer(serializers.Serializer):
user_email = serializers.EmailField()
ndas = NDAStatusSerializer(many=True)
total_active_ndas = serializers.IntegerField()

5.5 Response (No NDAs)

{
"user_email": "newuser@example.com",
"ndas": [],
"total_active_ndas": 0
}

5.6 Error Responses

StatusError CodeDescriptionRetry
401TOKEN_EXPIREDJWT expiredYes (refresh)
401TOKEN_INVALIDToken signature invalidNo
429RATE_EXCEEDEDToo many requestsYes (after reset)

5.7 Business Logic

def get_nda_status(user: User) -> NDAStatusResponse:
"""
Retrieve all NDAs and tokens for authenticated user.

Includes:
- Active NDAs
- Expired NDAs (last 90 days)
- Associated tokens
- Renewal indicators
"""
now = timezone.now()
ninety_days_ago = now - timedelta(days=90)

# Query NDAs (active + recently expired)
ndas = NDA.objects.filter(
signer_email=user.email,
signed_at__gte=ninety_days_ago
).select_related('project').prefetch_related('tokens')

nda_list = []
active_count = 0

for nda in ndas:
# Calculate expiry status
days_until_expiry = None
renewal_needed = False
is_active = nda.is_active

if nda.expires_at:
delta = nda.expires_at - now
days_until_expiry = delta.days

if days_until_expiry < 0:
is_active = False
else:
renewal_needed = days_until_expiry < 30

if is_active:
active_count += 1

# Get tokens
tokens = []
for token in nda.tokens.all():
tokens.append({
"token_id": token.id,
"scope": token.scope,
"granted_at": token.granted_at,
"expires_at": token.expires_at,
"is_active": token.is_active and token.expires_at > now,
"last_used_at": token.last_used_at
})

nda_list.append({
"nda_id": nda.id,
"nda_version": nda.nda_version,
"project_id": nda.project_id,
"project_name": nda.project.name,
"signed_at": nda.signed_at,
"expires_at": nda.expires_at,
"is_active": is_active,
"renewal_needed": renewal_needed,
"days_until_expiry": days_until_expiry,
"tokens": tokens
})

return NDAStatusResponse(
user_email=user.email,
ndas=nda_list,
total_active_ndas=active_count
)

6. POST /api/v1/tokens/grant

6.1 Purpose

Explicitly grant a document view token for a project after validating an active NDA exists. This endpoint is typically called by admin users or services to issue tokens beyond the auto-grant on NDA signing.

6.2 Endpoint Specification

POST /api/v1/tokens/grant

Authentication: JWT (admin/system owner) or API Key (service) Authorization: token:grant permission (ADMIN, SYSTEM_OWNER roles) Rate Limit: 30 req/min (user), 100 req/min (API key)

6.3 Request Schema

{
"nda_id": "nda_01HRX8ABCD1234567890EFGHIJ",
"project_id": "proj_01HRX7QBPK4N8JMZV3Y5K2DWTF",
"scope": "read",
"ip_whitelist": ["203.0.113.0/24", "198.51.100.42"],
"ttl_days": 90,
"metadata": {
"purpose": "External reviewer access",
"requestor": "admin@biotech.com"
}
}
FieldTypeRequiredValidationDescription
nda_idstringYesULID format, prefix nda_NDA record to validate
project_idstringYesULID format, prefix proj_Project for token scope
scopeenumNo"read" (default) or "read-write"Token access scope
ip_whitelistarrayNoValid CIDR or IP addressesIP restrictions (empty = no restriction)
ttl_daysintegerNo1-365 daysToken lifetime (default: project config)
metadataobjectNoMax 5KB JSONAdditional tracking data

DRF Serializer:

import ipaddress

class TokenGrantRequestSerializer(serializers.Serializer):
SCOPE_CHOICES = ['read', 'read-write']

nda_id = serializers.CharField(max_length=64)
project_id = serializers.CharField(max_length=64)
scope = serializers.ChoiceField(
choices=SCOPE_CHOICES,
default='read'
)
ip_whitelist = serializers.ListField(
child=serializers.CharField(max_length=43),
required=False,
default=list
)
ttl_days = serializers.IntegerField(min_value=1, max_value=365, required=False)
metadata = serializers.JSONField(required=False, default=dict)

def validate_nda_id(self, value):
if not value.startswith('nda_'):
raise serializers.ValidationError("Invalid NDA ID format")
return value

def validate_project_id(self, value):
if not value.startswith('proj_'):
raise serializers.ValidationError("Invalid project ID format")
return value

def validate_ip_whitelist(self, value):
for ip_or_cidr in value:
try:
ipaddress.ip_network(ip_or_cidr, strict=False)
except ValueError:
raise serializers.ValidationError(f"Invalid IP/CIDR: {ip_or_cidr}")
return value

6.4 Response Schema (201 Created)

{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkdnRfMDFIUlg5QkNERTIzNDU2Nzg5MDFGR0hJSksiLCJwcm9qZWN0X2lkIjoicHJval8wMUhSWDdRQlBLNE44Sk1aVjNZNUsyRFdURiIsInNjb3BlIjoicmVhZCIsImV4cCI6MTcyNjQ5NzYwMH0.signature",
"token_id": "dvt_01HRX9BCDE2345678901FGHIJK",
"expires_at": "2026-05-16T14:30:00.000Z",
"scope": "read",
"project_id": "proj_01HRX7QBPK4N8JMZV3Y5K2DWTF",
"ip_whitelist": ["203.0.113.0/24", "198.51.100.42"],
"granted_at": "2026-02-16T14:30:00.000Z"
}
FieldTypeDescription
tokenstringSigned JWT token for document access
token_idstringToken record ULID
expires_atdatetimeToken expiry timestamp
scopestringAccess scope
project_idstringProject the token grants access to
ip_whitelistarrayIP restrictions (empty if none)
granted_atdatetimeWhen token was granted

DRF Serializer:

class TokenGrantResponseSerializer(serializers.Serializer):
token = serializers.CharField(max_length=512)
token_id = serializers.CharField(max_length=64)
expires_at = serializers.DateTimeField()
scope = serializers.CharField(max_length=16)
project_id = serializers.CharField(max_length=64)
ip_whitelist = serializers.ListField(child=serializers.CharField())
granted_at = serializers.DateTimeField()

6.5 Token Format (JWT)

{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"sub": "dvt_01HRX9BCDE2345678901FGHIJK",
"project_id": "proj_01HRX7QBPK4N8JMZV3Y5K2DWTF",
"scope": "read",
"ip_whitelist": ["203.0.113.0/24"],
"iat": 1708095000,
"exp": 1726497600
},
"signature": "..."
}

6.6 Error Responses

StatusError CodeDescriptionRetry
400INVALID_INPUTMalformed requestNo
401TOKEN_EXPIREDJWT expiredYes (refresh)
403PERMISSION_DENIEDUser lacks token:grant permissionNo
404NDA_NOT_FOUNDNDA ID does not existNo
404PROJECT_NOT_FOUNDProject ID does not existNo
422NDA_INACTIVENDA exists but is not active (expired or revoked)No
422NDA_PROJECT_MISMATCHNDA is not for the specified projectNo
429RATE_EXCEEDEDToo many requestsYes (after reset)

Example 422 Error (NDA Inactive):

{
"type": "https://docs.coditect-bio-qms.io/errors/nda-inactive",
"title": "NDA Inactive",
"status": 422,
"detail": "Cannot grant token: NDA has expired",
"instance": "/v1/tokens/grant",
"correlation_id": "01HRX7QBPK4N8JMZV3Y5K2DWTF",
"errors": [
{
"field": "nda_id",
"code": "NDA_INACTIVE",
"message": "NDA nda_01HRX8ABCD... expired on 2026-01-15"
}
],
"nda_id": "nda_01HRX8ABCD1234567890EFGHIJ",
"expires_at": "2026-01-15T10:30:00.000Z",
"timestamp": "2026-02-16T14:30:00.000Z"
}

6.7 Business Logic

import jwt
from datetime import timedelta
from django.conf import settings

def grant_token(request_data: dict, user: User) -> TokenGrantResponse:
"""
Grant document view token after NDA validation.

Steps:
1. Validate NDA exists and is active
2. Validate NDA is for the specified project
3. Get project-specific TTL or use provided ttl_days
4. Generate JWT token
5. Create DocumentViewToken record
6. Cache token in Redis
7. Audit trail
"""
# 1. Validate NDA
try:
nda = NDA.objects.get(id=request_data['nda_id'])
except NDA.DoesNotExist:
raise NotFoundError("NDA not found")

# Check NDA is active
now = timezone.now()
if not nda.is_active or (nda.expires_at and nda.expires_at < now):
raise ValidationError("NDA is not active", code="NDA_INACTIVE")

# 2. Validate project match
if nda.project_id != request_data['project_id']:
raise ValidationError(
f"NDA is for project {nda.project_id}, not {request_data['project_id']}",
code="NDA_PROJECT_MISMATCH"
)

# 3. Calculate TTL
ttl_days = request_data.get('ttl_days')
if not ttl_days:
project = Project.objects.get(id=request_data['project_id'])
ttl_days = project.default_token_ttl_days or 90

expires_at = now + timedelta(days=ttl_days)

# 4. Create token record
token_record = DocumentViewToken.objects.create(
id=f"dvt_{ulid.new()}",
nda_id=nda.id,
project_id=request_data['project_id'],
scope=request_data.get('scope', 'read'),
ip_whitelist=request_data.get('ip_whitelist', []),
expires_at=expires_at,
is_active=True,
granted_at=now,
granted_by=user.id,
metadata=request_data.get('metadata', {})
)

# 5. Generate JWT
jwt_payload = {
"sub": token_record.id,
"project_id": token_record.project_id,
"scope": token_record.scope,
"ip_whitelist": token_record.ip_whitelist,
"iat": int(now.timestamp()),
"exp": int(expires_at.timestamp())
}

jwt_token = jwt.encode(
jwt_payload,
settings.TOKEN_SIGNING_KEY,
algorithm="HS256"
)

# Store JWT in token record
token_record.token = jwt_token
token_record.save(update_fields=['token'])

# 6. Cache in Redis (TTL = token expiry)
cache_token(
token=jwt_token,
token_id=token_record.id,
expires_at=expires_at,
project_id=token_record.project_id,
scope=token_record.scope
)

# 7. Audit trail
AuditTrail.objects.create(
entity_type="DocumentViewToken",
entity_id=token_record.id,
action="GRANT",
performed_by=user.id,
performed_at=now,
metadata={
"nda_id": nda.id,
"project_id": token_record.project_id,
"scope": token_record.scope,
"ttl_days": ttl_days
}
)

return TokenGrantResponse(
token=jwt_token,
token_id=token_record.id,
expires_at=expires_at,
scope=token_record.scope,
project_id=token_record.project_id,
ip_whitelist=token_record.ip_whitelist,
granted_at=now
)

6.8 Redis Caching

import redis
import json

redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB_TOKENS,
decode_responses=True
)

def cache_token(token: str, token_id: str, expires_at: datetime,
project_id: str, scope: str):
"""
Cache token in Redis for fast validation.

Key: token:{token_id}
Value: JSON with project_id, scope, expires_at
TTL: Until token expiry
"""
ttl_seconds = int((expires_at - timezone.now()).total_seconds())

token_data = json.dumps({
"token_id": token_id,
"project_id": project_id,
"scope": scope,
"expires_at": expires_at.isoformat()
})

redis_client.setex(
name=f"token:{token_id}",
time=ttl_seconds,
value=token_data
)

I'll continue with the remaining endpoints in the next response.