Skip to main content

Phase 1 Step 4: License Delivery - Design Document

Date: December 1, 2025 Author: AI Assistant Status: Implementation Ready


Overview

Provide REST API endpoints for customers to access, view, and download their CODITECT licenses generated from Stripe subscriptions.


Goals

  1. License Listing: Allow organizations to view all their licenses
  2. License Details: Provide detailed information about individual licenses
  3. License Download: Generate downloadable signed license files (JSON)
  4. License Validation: Public endpoint to validate license keys
  5. Multi-Tenant Security: Ensure proper organization-scoped access

API Endpoints

1. List Organization Licenses

GET /api/v1/licenses/

Authentication: Required (JWT)
Permission: Organization member

Query Parameters:
- is_active (optional): Filter by active status (true/false)
- tier (optional): Filter by tier (PRO, ENTERPRISE)

Response (200 OK):
{
"licenses": [
{
"id": "uuid-123",
"key_string": "CODITECT-2025-A7B3-X9K2",
"tier": "PRO",
"features": ["marketplace", "analytics", "priority_support"],
"expiry_date": "2026-01-01T00:00:00Z",
"is_active": true,
"is_expired": false,
"is_valid": true,
"created_at": "2025-12-01T10:00:00Z",
"auto_renew": true,
"cancel_at_period_end": false
}
],
"count": 1,
"active_license": {
"id": "uuid-123",
"key_string": "CODITECT-2025-A7B3-X9K2"
}
}

2. Get License Details

GET /api/v1/licenses/{license_id}/

Authentication: Required (JWT)
Permission: Organization member (tenant-scoped)

Response (200 OK):
{
"id": "uuid-123",
"key_string": "CODITECT-2025-A7B3-X9K2",
"tier": "PRO",
"features": ["marketplace", "analytics", "priority_support"],
"expiry_date": "2026-01-01T00:00:00Z",
"is_active": true,
"is_expired": false,
"is_valid": true,
"created_at": "2025-12-01T10:00:00Z",
"updated_at": "2025-12-01T10:00:00Z",
"organization": {
"id": "org-uuid",
"name": "Acme Corp",
"slug": "acme-corp"
},
"subscription": {
"stripe_subscription_id": "sub_xxx",
"stripe_price_id": "price_xxx",
"auto_renew": true,
"cancel_at_period_end": false
},
"usage": {
"seats_purchased": 5,
"seats_used": 3,
"seats_remaining": 2,
"active_sessions_count": 3
}
}

Response (404 Not Found):
{
"error": "License not found or does not belong to your organization"
}

3. Download License File

GET /api/v1/licenses/{license_id}/download/

Authentication: Required (JWT)
Permission: Organization member (tenant-scoped)

Response (200 OK):
Content-Type: application/json
Content-Disposition: attachment; filename="coditect-license-2025-A7B3.json"

{
"license": {
"license_key": "CODITECT-2025-A7B3-X9K2",
"organization": {
"id": "org-uuid",
"name": "Acme Corp"
},
"tier": "PRO",
"features": ["marketplace", "analytics", "priority_support"],
"expiry_date": "2026-01-01T00:00:00Z",
"issued_at": "2025-12-01T10:00:00Z",
"is_active": true
},
"signature": {
"payload": {
"license_id": "uuid-123",
"license_key": "CODITECT-2025-A7B3-X9K2",
"organization_id": "org-uuid",
"organization_name": "Acme Corp",
"tier": "PRO",
"features": ["marketplace", "analytics", "priority_support"],
"expiry_date": "2026-01-01T00:00:00Z",
"issued_at": "2025-12-01T10:00:00Z",
"is_active": true,
"auto_renew": true
},
"signature": "base64-encoded-rsa-4096-signature...",
"algorithm": "RS256",
"key_id": "projects/coditect-cloud-infra/locations/us-central1/keyRings/license-signing-keyring/cryptoKeys/license-signing-key"
},
"metadata": {
"generated_at": "2025-12-01T12:00:00Z",
"format_version": "1.0",
"verification_url": "https://api.coditect.ai/v1/licenses/validate/"
}
}

Response (404 Not Found):
{
"error": "License not found or does not belong to your organization"
}

4. Validate License Key

POST /api/v1/licenses/validate/

Authentication: Optional (public endpoint)
Rate Limit: 60 requests/minute per IP

Request Body:
{
"key_string": "CODITECT-2025-A7B3-X9K2"
}

Response (200 OK - Valid):
{
"valid": true,
"license": {
"key_string": "CODITECT-2025-A7B3-X9K2",
"tier": "PRO",
"features": ["marketplace", "analytics", "priority_support"],
"expiry_date": "2026-01-01T00:00:00Z",
"is_active": true,
"is_expired": false
},
"organization": {
"id": "org-uuid",
"name": "Acme Corp"
}
}

Response (200 OK - Invalid):
{
"valid": false,
"reason": "license_expired", // or "license_not_found", "license_inactive"
"message": "License has expired on 2025-11-30T00:00:00Z"
}

Response (400 Bad Request):
{
"error": "Invalid license key format"
}

Serializers

1. LicenseListSerializer

class LicenseListSerializer(serializers.ModelSerializer):
"""
Serializer for license list view.
Returns basic license information.
"""

class Meta:
model = License
fields = [
'id', 'key_string', 'tier', 'features',
'expiry_date', 'is_active', 'is_expired', 'is_valid',
'created_at', 'auto_renew', 'cancel_at_period_end'
]
read_only_fields = fields

2. LicenseDetailSerializer

class LicenseDetailSerializer(serializers.ModelSerializer):
"""
Serializer for detailed license view.
Includes organization, subscription, and usage information.
"""

organization = OrganizationSerializer(read_only=True)
subscription = serializers.SerializerMethodField()
usage = serializers.SerializerMethodField()

class Meta:
model = License
fields = [
'id', 'key_string', 'tier', 'features',
'expiry_date', 'is_active', 'is_expired', 'is_valid',
'created_at', 'updated_at',
'organization', 'subscription', 'usage'
]
read_only_fields = fields

def get_subscription(self, obj):
return {
'stripe_subscription_id': obj.stripe_subscription_id,
'stripe_price_id': obj.stripe_price_id,
'auto_renew': obj.auto_renew,
'cancel_at_period_end': obj.cancel_at_period_end,
}

def get_usage(self, obj):
org = obj.organization
active_sessions = LicenseSession.objects.filter(
license=obj,
ended_at__isnull=True
).count()

return {
'seats_purchased': org.seats_purchased,
'seats_used': org.seats_used,
'seats_remaining': max(0, org.seats_purchased - org.seats_used),
'active_sessions_count': active_sessions,
}

3. LicenseValidateSerializer

class LicenseValidateSerializer(serializers.Serializer):
"""
Serializer for license validation request.
"""

key_string = serializers.CharField(required=True, max_length=255)

def validate_key_string(self, value):
from licenses.services import LicenseService

if not LicenseService.validate_key_format(value):
raise serializers.ValidationError("Invalid license key format")

return value

Views Implementation

1. LicenseListView

class LicenseListView(APIView):
"""
GET /api/v1/licenses/

List all licenses for the authenticated user's organization.
"""
permission_classes = [IsAuthenticated]

def get(self, request):
organization = request.user.organization

# Get all licenses for organization
licenses = License.objects.filter(organization=organization)

# Apply filters
is_active = request.query_params.get('is_active')
if is_active is not None:
licenses = licenses.filter(is_active=is_active.lower() == 'true')

tier = request.query_params.get('tier')
if tier:
licenses = licenses.filter(tier=tier.upper())

# Serialize
serializer = LicenseListSerializer(licenses, many=True)

return Response({
'licenses': serializer.data,
'count': licenses.count(),
'active_license': {
'id': str(organization.active_license.id),
'key_string': organization.active_license.key_string,
} if organization.active_license else None
}, status=status.HTTP_200_OK)

2. LicenseDetailView

class LicenseDetailView(APIView):
"""
GET /api/v1/licenses/{license_id}/

Get detailed information about a specific license.
"""
permission_classes = [IsAuthenticated]

def get(self, request, license_id):
try:
# Tenant-scoped query (django-multitenant)
license_obj = License.objects.get(
id=license_id,
organization=request.user.organization
)

serializer = LicenseDetailSerializer(license_obj)
return Response(serializer.data, status=status.HTTP_200_OK)

except License.DoesNotExist:
return Response(
{'error': 'License not found or does not belong to your organization'},
status=status.HTTP_404_NOT_FOUND
)

3. LicenseDownloadView

class LicenseDownloadView(APIView):
"""
GET /api/v1/licenses/{license_id}/download/

Download a signed license file.
"""
permission_classes = [IsAuthenticated]

def get(self, request, license_id):
try:
# Tenant-scoped query
license_obj = License.objects.get(
id=license_id,
organization=request.user.organization
)

from licenses.services import LicenseService

# Generate signed license payload
signed_payload = LicenseService.sign_license_payload(license_obj)

# Prepare download response
download_data = {
'license': {
'license_key': license_obj.key_string,
'organization': {
'id': str(license_obj.organization.id),
'name': license_obj.organization.name,
},
'tier': license_obj.tier,
'features': license_obj.features,
'expiry_date': license_obj.expiry_date.isoformat(),
'issued_at': license_obj.created_at.isoformat(),
'is_active': license_obj.is_active,
},
'signature': signed_payload,
'metadata': {
'generated_at': timezone.now().isoformat(),
'format_version': '1.0',
'verification_url': f"{settings.BASE_URL}/api/v1/licenses/validate/",
}
}

# Create audit log
from licenses.models import AuditLog
AuditLog.objects.create(
organization=license_obj.organization,
user=request.user,
action='LICENSE_DOWNLOADED',
resource_type='license',
resource_id=license_obj.id,
metadata={'ip_address': request.META.get('REMOTE_ADDR')}
)

# Return as downloadable file
response = Response(download_data, status=status.HTTP_200_OK)

# Extract short key segment for filename
key_parts = license_obj.key_string.split('-')
short_key = f"{key_parts[1]}-{key_parts[2]}" if len(key_parts) >= 3 else "license"

response['Content-Disposition'] = f'attachment; filename="coditect-license-{short_key}.json"'

return response

except License.DoesNotExist:
return Response(
{'error': 'License not found or does not belong to your organization'},
status=status.HTTP_404_NOT_FOUND
)

4. LicenseValidateView

class LicenseValidateView(APIView):
"""
POST /api/v1/licenses/validate/

Validate a license key (public endpoint).
"""
permission_classes = [AllowAny]
throttle_classes = [AnonRateThrottle] # 60 requests/minute

def post(self, request):
serializer = LicenseValidateSerializer(data=request.data)

if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

key_string = serializer.validated_data['key_string']

try:
license_obj = License.objects.get(key_string=key_string)

# Check if valid
if not license_obj.is_active:
return Response({
'valid': False,
'reason': 'license_inactive',
'message': 'License has been deactivated'
}, status=status.HTTP_200_OK)

if license_obj.is_expired:
return Response({
'valid': False,
'reason': 'license_expired',
'message': f'License expired on {license_obj.expiry_date.isoformat()}'
}, status=status.HTTP_200_OK)

# Valid license
return Response({
'valid': True,
'license': {
'key_string': license_obj.key_string,
'tier': license_obj.tier,
'features': license_obj.features,
'expiry_date': license_obj.expiry_date.isoformat(),
'is_active': license_obj.is_active,
'is_expired': license_obj.is_expired,
},
'organization': {
'id': str(license_obj.organization.id),
'name': license_obj.organization.name,
}
}, status=status.HTTP_200_OK)

except License.DoesNotExist:
return Response({
'valid': False,
'reason': 'license_not_found',
'message': 'License key not found in database'
}, status=status.HTTP_200_OK)

URL Patterns

urlpatterns = [
# ... existing patterns ...

# Phase 1 Step 4: License Delivery
path('licenses/', LicenseListView.as_view(), name='license-list'),
path('licenses/<uuid:license_id>/', LicenseDetailView.as_view(), name='license-detail'),
path('licenses/<uuid:license_id>/download/', LicenseDownloadView.as_view(), name='license-download'),
path('licenses/validate/', LicenseValidateView.as_view(), name='license-validate'),
]

Security Considerations

  1. Multi-Tenant Isolation:

    • All license queries filtered by organization
    • Django-multitenant automatic tenant filtering
    • Explicit organization checks in views
  2. Rate Limiting:

    • License validation endpoint: 60 requests/minute per IP
    • Prevents brute-force license key guessing
    • Uses DRF's AnonRateThrottle
  3. Audit Logging:

    • License downloads logged with user and IP
    • Validation attempts not logged (high frequency)
    • All access events traceable for compliance
  4. License Key Security:

    • Keys are cryptographically random (not sequential)
    • Validation requires database lookup
    • Cannot be reverse-engineered from organization info

Implementation Checklist

  • Create serializers: LicenseListSerializer, LicenseDetailSerializer, LicenseValidateSerializer
  • Create views: LicenseListView, LicenseDetailView, LicenseDownloadView, LicenseValidateView
  • Update URLs: Add 4 license endpoints
  • Configure rate limiting: Set up AnonRateThrottle for validation endpoint
  • Update serializers/init.py: Export new serializers
  • Create tests: Unit tests for each endpoint
  • Documentation: Update API documentation

Success Criteria

  1. List Licenses: Organizations can view all their licenses
  2. View Details: Detailed license information accessible
  3. Download Files: Signed license files downloadable as JSON
  4. Validate Keys: Public validation endpoint working with rate limiting
  5. Multi-Tenant Security: All endpoints properly tenant-scoped
  6. Audit Logging: Downloads logged for compliance

Timeline Estimate

  • Serializers: 1 hour
  • Views: 2 hours
  • URL Configuration: 15 minutes
  • Rate Limiting Setup: 30 minutes
  • Testing: 1 hour
  • Documentation: 30 minutes

Total: 5.5 hours


Status: Ready for Implementation Dependencies: Phase 1 Step 3 (License Generation) - ✅ Complete