NDA Verification API Endpoints Design
Classification: Internal — Engineering Date: 2026-02-16 Status: Active Governance: ADR-196 (NDA-Gated Conditional Access)
Table of Contents
- Overview
- API Conventions
- POST /api/v1/nda/verify
- POST /api/v1/nda/sign
- GET /api/v1/nda/status
- POST /api/v1/tokens/grant
- POST /api/v1/tokens/validate
- POST /api/v1/tokens/revoke
- GET /api/v1/nda/document/{version}
- Security Architecture
- Django Implementation
- OpenAPI Specification
- Testing Strategy
- Error Handling
- 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
| Component | Coverage |
|---|---|
| NDA Lifecycle | Verification, signing, status tracking, renewal |
| Token Management | Grant, validate, revoke document view tokens |
| Document Access | NDA document retrieval with watermarking |
| Audit Trail | Complete audit logging of all NDA operations |
| Security | OWASP-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
| Principle | Implementation |
|---|---|
| Security First | All endpoints require authentication; sensitive operations require re-authentication |
| Audit Everything | Immutable audit trail for all NDA and token operations |
| Performance | Redis-backed token validation for high-frequency viewer requests |
| OWASP Compliance | Input validation, output encoding, CSRF protection, secure headers |
| Fail Secure | Invalid NDA → no token grant; expired token → immediate rejection |
| Idempotency | Duplicate 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
| Method | Header | Use Case | Lifetime |
|---|---|---|---|
| JWT (User) | Authorization: Bearer <jwt> | Authenticated users signing NDAs, checking status | 1 hour (access), 7 days (refresh) |
| API Key (Service) | X-API-Key: <key> | Service-to-service NDA verification | 90-day rotation |
| Bearer Token (Viewer) | Authorization: Bearer <token> | Document viewer validation (high frequency) | Project-specific TTL (7-90 days) |
2.3 Standard Headers
| Header | Direction | Required | Description |
|---|---|---|---|
Authorization | Request | Yes | JWT or Bearer token |
Content-Type | Request | Yes (POST/PUT) | application/json |
X-Request-ID | Request | Recommended | Idempotency key (UUID v7) |
X-Correlation-ID | Response | Always | Trace ID for debugging |
X-RateLimit-Remaining | Response | Always | Remaining requests in window |
X-RateLimit-Reset | Response | Always | Unix timestamp when window resets |
2.4 Rate Limits
| Endpoint | Authenticated Users | API Keys | Anonymous | Window |
|---|---|---|---|---|
/nda/verify | 60/min | 300/min | 10/min | Rolling 60s |
/nda/sign | 10/min | N/A | N/A | Rolling 60s |
/nda/status | 30/min | 100/min | N/A | Rolling 60s |
/tokens/grant | 30/min | 100/min | N/A | Rolling 60s |
/tokens/validate | 300/min | 1000/min | N/A | Rolling 60s |
/tokens/revoke | 10/min | 30/min | N/A | Rolling 60s |
/nda/document/* | 20/min | 50/min | 5/min | Rolling 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 RequestswithRetry-Afterheader
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"
}
| Field | Type | Required | Validation | Description |
|---|---|---|---|---|
email | string | Yes | Valid email format, max 254 chars | User email to check |
project_id | string | Yes | ULID 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"
}
| Field | Type | Description |
|---|---|---|
has_valid_nda | boolean | true if active NDA exists, false otherwise |
nda_version | string | NDA document version (null if no NDA) |
signed_at | datetime | ISO 8601 timestamp (null if no NDA) |
expires_at | datetime | ISO 8601 timestamp (null if no expiry) |
renewal_needed | boolean | true if expiry < 30 days |
days_until_expiry | integer | Days remaining (null if no expiry) |
nda_id | string | NDA 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
| Status | Error Code | Description | Retry |
|---|---|---|---|
| 400 | INVALID_EMAIL | Email format invalid | No |
| 400 | INVALID_PROJECT_ID | Project ID format invalid | No |
| 401 | TOKEN_EXPIRED | JWT expired | Yes (refresh) |
| 401 | TOKEN_INVALID | Token signature invalid | No |
| 404 | PROJECT_NOT_FOUND | Project does not exist | No |
| 429 | RATE_EXCEEDED | Too many requests | Yes (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"
}
}
| Field | Type | Required | Validation | Description |
|---|---|---|---|---|
signer_email | string | Yes | Valid email, max 254 chars | Signer's email address |
signer_name | string | Yes | 1-200 chars, no HTML | Full name of signer |
company | string | No | Max 200 chars | Company/organization name |
nda_version | string | Yes | Semantic version (e.g., "1.0") | NDA document version being signed |
project_id | string | Yes | ULID format | Project the NDA applies to |
signature_data | object | Yes | See signature schema below | Signature capture details |
consent_timestamp | datetime | Yes | ISO 8601 UTC | When user clicked final consent |
metadata | object | No | Max 5KB JSON | Additional tracking data |
Signature Data Schema:
| Field | Type | Required | Description |
|---|---|---|---|
type | enum | Yes | "click-to-sign", "draw", or "typed" |
consent_text | string | Yes | Exact consent text shown to user |
ip_address | string | Yes | IPv4 or IPv6 address |
timestamp | datetime | Yes | ISO 8601 UTC timestamp |
user_agent | string | Yes | Browser user agent string |
coordinates | object | Conditional | Required if type="draw": {x: number, y: number} |
canvas_data | string | Conditional | Required 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
}
| Field | Type | Description |
|---|---|---|
nda_id | string | Created NDA record ULID |
signer_email | string | Email address of signer |
signer_name | string | Full name of signer |
nda_version | string | NDA version signed |
signed_at | datetime | ISO 8601 timestamp of signature |
expires_at | datetime | Expiry timestamp (null if no expiry) |
is_active | boolean | Always true on creation |
document_view_token | object | Auto-granted token for document access |
confirmation_email_sent | boolean | Email 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
| Status | Error Code | Description | Retry |
|---|---|---|---|
| 400 | INVALID_INPUT | Malformed JSON or missing required field | No |
| 400 | INVALID_EMAIL | Email format invalid | No |
| 400 | INVALID_SIGNATURE_DATA | Signature data validation failed | No |
| 401 | TOKEN_EXPIRED | JWT expired | Yes (refresh) |
| 404 | PROJECT_NOT_FOUND | Project does not exist | No |
| 404 | NDA_VERSION_NOT_FOUND | NDA version does not exist | No |
| 409 | DUPLICATE_SIGNATURE | Active signature already exists | No |
| 422 | VALIDATION_FAILED | Business rule validation failed | No |
| 429 | RATE_EXCEEDED | Too many requests | Yes (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
}
| Field | Type | Description |
|---|---|---|
user_email | string | Authenticated user's email |
ndas | array | List of NDA records for this user |
total_active_ndas | integer | Count of active NDAs |
NDA Object Schema:
| Field | Type | Description |
|---|---|---|
nda_id | string | NDA record ULID |
nda_version | string | NDA document version |
project_id | string | Associated project ULID |
project_name | string | Human-readable project name |
signed_at | datetime | Signature timestamp |
expires_at | datetime | Expiry timestamp (null if no expiry) |
is_active | boolean | Active status |
renewal_needed | boolean | True if < 30 days to expiry |
days_until_expiry | integer | Days remaining (null if no expiry) |
tokens | array | Associated document view tokens |
Token Object Schema:
| Field | Type | Description |
|---|---|---|
token_id | string | Token ULID (NOT the token itself) |
scope | string | Access scope (e.g., "read") |
granted_at | datetime | When token was granted |
expires_at | datetime | Token expiry |
is_active | boolean | Active status |
last_used_at | datetime | Last 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
| Status | Error Code | Description | Retry |
|---|---|---|---|
| 401 | TOKEN_EXPIRED | JWT expired | Yes (refresh) |
| 401 | TOKEN_INVALID | Token signature invalid | No |
| 429 | RATE_EXCEEDED | Too many requests | Yes (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"
}
}
| Field | Type | Required | Validation | Description |
|---|---|---|---|---|
nda_id | string | Yes | ULID format, prefix nda_ | NDA record to validate |
project_id | string | Yes | ULID format, prefix proj_ | Project for token scope |
scope | enum | No | "read" (default) or "read-write" | Token access scope |
ip_whitelist | array | No | Valid CIDR or IP addresses | IP restrictions (empty = no restriction) |
ttl_days | integer | No | 1-365 days | Token lifetime (default: project config) |
metadata | object | No | Max 5KB JSON | Additional 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"
}
| Field | Type | Description |
|---|---|---|
token | string | Signed JWT token for document access |
token_id | string | Token record ULID |
expires_at | datetime | Token expiry timestamp |
scope | string | Access scope |
project_id | string | Project the token grants access to |
ip_whitelist | array | IP restrictions (empty if none) |
granted_at | datetime | When 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
| Status | Error Code | Description | Retry |
|---|---|---|---|
| 400 | INVALID_INPUT | Malformed request | No |
| 401 | TOKEN_EXPIRED | JWT expired | Yes (refresh) |
| 403 | PERMISSION_DENIED | User lacks token:grant permission | No |
| 404 | NDA_NOT_FOUND | NDA ID does not exist | No |
| 404 | PROJECT_NOT_FOUND | Project ID does not exist | No |
| 422 | NDA_INACTIVE | NDA exists but is not active (expired or revoked) | No |
| 422 | NDA_PROJECT_MISMATCH | NDA is not for the specified project | No |
| 429 | RATE_EXCEEDED | Too many requests | Yes (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.