The License Uploader provides a RESTful API for uploading license documents, extracting terms using AI, and submitting to Ex Libris Alma. The API is designed for web browser consumption with session-based authentication and CSRF protection.
Architecture:
- Backend: Flask Python web framework
- Authentication: Session-based with secure cookies
- Security: CSRF tokens, rate limiting, HTTPS enforcement
- Data Format: JSON (application/json)
Development:
http://localhost:5000
Production:
https://license-uploader.example.edu
Replace license-uploader.example.edu with your actual deployment domain.
The API uses session-based authentication with secure HTTP-only cookies.
Session Cookie Name: session
Session Properties:
HttpOnly: Yes (prevents JavaScript access)Secure: Yes (HTTPS only in production)SameSite: Lax (CSRF protection)Max-Age: 3600 seconds (1 hour)
Session Lifetime:
- 1 hour of inactivity
- Permanent sessions disabled
- Sessions cleared after license submission
The API does not require API keys for client requests. Backend API keys (Alma, LiteLLM) are configured server-side in the .env file.
All POST/PUT/DELETE requests must include:
Content-Type: application/json
X-CSRFToken: <csrf-token>CSRF Token:
- Required for all state-changing operations
- Obtain from
/api/csrf-tokenendpoint - Include in
X-CSRFTokenheader or form field
Standard response headers:
Content-Type: application/json
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'; ...All errors return JSON with the following structure:
{
"error": "Human-readable error message"
}| Code | Meaning | Description |
|---|---|---|
200 |
OK | Request successful |
400 |
Bad Request | Invalid input, missing required fields |
413 |
Payload Too Large | File exceeds 16 MB limit |
429 |
Too Many Requests | Rate limit exceeded |
500 |
Internal Server Error | Server-side error |
Upload Errors:
"No file uploaded"- File missing from request"No file selected"- Empty filename"Invalid file type"- Unsupported file format"File too large. Maximum size is 16MB."- File exceeds limit"Uploaded file is empty. Please upload a file with content."- Zero-byte file"No text content could be extracted from the file."- PDF is scanned image or corrupted"Invalid file type detected: {mime}. Only PDF, DOCX, and TXT files are allowed."- MIME type validation failure
Session Errors:
"No document in session"- Session expired or upload not completed"Session expired or invalid. Please upload your document again."- Session timeout"Session file not found"- Temporary file deleted or corrupted
Validation Errors:
"License code is required"- Missing required field"License name is required"- Missing required field"End date must be after start date"- Invalid date range"Invalid date format. Please use YYYY-MM-DD format."- Malformed date
API Errors:
"Error extracting terms with LLM: {error}"- LLM service failure"AI service took too long to respond (timeout after 180 seconds)."- LLM timeout"Error creating license: {error}"- Alma API failure"Error fetching vendors from Alma: {error}"- Alma API connection failure
The API implements rate limiting to prevent abuse and ensure fair usage.
Default limits:
- 200 requests per day per IP
- 50 requests per hour per IP
| Endpoint | Limit | Reason |
|---|---|---|
POST /upload |
10 per hour | Heavy processing (file + LLM) |
POST /refine-term |
30 per hour | LLM API call |
POST /submit-license |
20 per hour | Alma API write operation |
POST /api/prompt |
10 per hour | Administrative operation |
DELETE /api/prompt |
10 per hour | Administrative operation |
Response headers when rate limited:
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1706745600
Retry-After: 3600HTTP 429 Too Many Requests:
{
"error": "Rate limit exceeded. Please slow down and try again later.",
"retry_after": "3600 seconds"
}Home page - returns HTML upload interface.
GET / HTTP/1.1
Host: license-uploader.example.eduHTTP 200 OK
<!DOCTYPE html>
<html>
<head><title>License Uploader</title></head>
<body>...</body>
</html>- Load the upload interface in a web browser
- Entry point for users
Get CSRF token for AJAX requests. Required for all POST/PUT/DELETE operations.
GET /api/csrf-token HTTP/1.1
Host: license-uploader.example.eduHTTP 200 OK
{
"csrf_token": "ImFjYzQ4NzQ3ZGU5ZjRhNmY4YjI3MmE4ZjE2OTY0NTIwYjNhMGY0YmEi.Z6Y8gw.xGqxTZ8lH5hPzF3VUcR8e8T0Kz4"
}JavaScript:
// Get CSRF token
const response = await fetch('/api/csrf-token');
const data = await response.json();
const csrfToken = data.csrf_token;
// Use in subsequent requests
fetch('/upload', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
},
body: formData
});Save API configuration in server-side session. Stores API keys securely without exposing to client.
POST /api/config HTTP/1.1
Host: license-uploader.example.edu
Content-Type: application/json
X-CSRFToken: <csrf-token>
{
"litellm_api_key": "sk-1234567890abcdef",
"alma_api_key": "l7xx1234567890abcdef",
"llm_model": "gpt-5"
}Request Body Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
litellm_api_key |
string | No | LiteLLM API key (overrides server default) |
alma_api_key |
string | No | Alma API key (overrides server default) |
llm_model |
string | No | LLM model to use (e.g., gpt-4, gpt-5) |
HTTP 200 OK
{
"success": true
}HTTP 400 Bad Request
{
"error": "No configuration provided"
}- Configuration is stored in server-side session
- Session expires after 1 hour of inactivity
- Use this for per-user API key overrides
- Not required if using server-configured keys
Retrieve API configuration from server-side session.
GET /api/config HTTP/1.1
Host: license-uploader.example.eduHTTP 200 OK
{
"litellm_api_key": "sk-1234567890abcdef",
"alma_api_key": "l7xx1234567890abcdef",
"llm_model": "gpt-5"
}Response Fields:
| Field | Type | Description |
|---|---|---|
litellm_api_key |
string | LiteLLM API key (empty string if not set) |
alma_api_key |
string | Alma API key (empty string if not set) |
llm_model |
string | LLM model name (empty string if not set) |
- Returns empty strings for unset values
- Used to restore configuration in UI
Upload and process a license document. Extracts text and uses AI to extract license terms.
10 requests per hour (strict limit due to heavy processing)
POST /upload HTTP/1.1
Host: license-uploader.example.edu
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
X-CSRFToken: <csrf-token>
------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="license.pdf"
Content-Type: application/pdf
<binary-file-data>
------WebKitFormBoundary--Request Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
file |
file | Yes | License document (PDF, DOCX, or TXT) |
File Requirements:
- Formats: PDF, DOCX, TXT
- Max size: 16 MB
- Content: Must contain extractable text (not scanned images)
HTTP 200 OK (Success)
{
"success": true,
"message": "Document processed successfully",
"redirect": "/review"
}HTTP 200 OK (Success with truncation warning)
{
"success": true,
"message": "Document processed successfully",
"redirect": "/review",
"warning": "Document was truncated from 25000 to 15000 characters. Some terms in later sections may have been missed."
}Response Fields:
| Field | Type | Description |
|---|---|---|
success |
boolean | Always true for successful processing |
message |
string | Success message |
redirect |
string | URL to redirect to (review page) |
warning |
string | Optional truncation warning message |
HTTP 400 Bad Request
{
"error": "Invalid file type"
}HTTP 413 Payload Too Large
{
"error": "File too large. Maximum size is 16MB."
}HTTP 500 Internal Server Error
{
"error": "Error extracting terms with LLM: Connection timeout"
}- Validate file extension (.pdf, .docx, .txt)
- Save file securely with sanitized filename
- Validate file content using MIME type detection
- Extract text from document
- Send text to LLM for term extraction
- Store results in server-side session
- Clean up uploaded file
- Return success with redirect URL
JavaScript (Fetch API):
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const response = await fetch('/upload', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
},
body: formData
});
const result = await response.json();
if (result.success) {
if (result.warning) {
console.warn(result.warning);
}
window.location.href = result.redirect;
} else {
console.error(result.error);
}cURL:
curl -X POST https://license-uploader.example.edu/upload \
-H "X-CSRFToken: <token>" \
-F "file=@license.pdf"Review and edit extracted license terms. Returns HTML page with form interface.
GET /review HTTP/1.1
Host: license-uploader.example.edu
Cookie: session=<session-id>HTTP 200 OK
<!DOCTYPE html>
<html>
<head><title>Review License Terms</title></head>
<body>
<!-- Review form with extracted terms -->
</body>
</html>HTTP 302 Redirect (No session)
Location: /
- Requires active session with uploaded document
- Redirects to home page if no session
- Returns HTML form, not JSON
Refine a specific license term using AI. Re-analyzes the document focusing on one term.
30 requests per hour (moderate limit due to LLM API call)
POST /refine-term HTTP/1.1
Host: license-uploader.example.edu
Content-Type: application/json
X-CSRFToken: <csrf-token>
Cookie: session=<session-id>
{
"term_code": "ARCHIVE",
"current_value": "Permitted (Explicit)"
}Request Body Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
term_code |
string | Yes | License term code (e.g., ARCHIVE, FAIRUSE, GOVJUR) |
current_value |
string | No | Current extracted value (for context) |
HTTP 200 OK
{
"success": true,
"value": "Permitted (Explicit)"
}Response Fields:
| Field | Type | Description |
|---|---|---|
success |
boolean | Always true for successful refinement |
value |
string or null | Refined value or null if not found |
HTTP 400 Bad Request
{
"error": "Term code required"
}HTTP 400 Bad Request (Session expired)
{
"error": "Session invalid. Please upload your document again."
}HTTP 500 Internal Server Error
{
"error": "AI service took too long to respond (timeout after 180 seconds)."
}JavaScript:
const response = await fetch('/refine-term', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
term_code: 'ARCHIVE',
current_value: 'Silent'
})
});
const result = await response.json();
console.log('Refined value:', result.value);- Requires active session with uploaded document
- Uses same LLM model as initial extraction
- Focuses analysis on specific term
- May return same value if document is ambiguous
Submit license with all terms to Ex Libris Alma. Creates a new license record.
20 requests per hour (moderate limit for API write operations)
POST /submit-license HTTP/1.1
Host: license-uploader.example.edu
Content-Type: application/json
X-CSRFToken: <csrf-token>
Cookie: session=<session-id>
{
"basic_info": {
"code": "LIC-2026-SPRINGER-001",
"name": "SpringerLink Electronic Journals 2026",
"type": "LICENSE",
"status": "ACTIVE",
"review_status": "INREVIEW",
"vendor_code": "SPRINGER",
"start_date": "2026-01-01",
"end_date": "2026-12-31"
},
"terms": {
"ARCHIVE": {
"value": "Permitted (Explicit)",
"type": "LicenseTermsPermittedProhibited",
"name": "Archiving Right",
"description": "..."
},
"FAIRUSE": {
"value": "Yes",
"type": "LicenseTermsYesNo",
"name": "Fair Use Clause",
"description": "..."
}
}
}Request Body Parameters:
basic_info (object, required):
| Field | Type | Required | Description |
|---|---|---|---|
code |
string | Yes | Unique license code (max 50 chars) |
name |
string | Yes | License name (max 255 chars) |
type |
string | No | LICENSE, AMENDMENT, or ADDENDUM (default: LICENSE) |
status |
string | No | ACTIVE, DRAFT, EXPIRED, or RETIRED (default: ACTIVE) |
review_status |
string | No | INREVIEW, APPROVED, or PENDING (default: INREVIEW) |
vendor_code |
string | No | Vendor code from Alma (max 50 chars) |
start_date |
string | No | Start date in YYYY-MM-DD format |
end_date |
string | No | End date in YYYY-MM-DD format |
terms (object, optional):
- Key: term code (e.g., "ARCHIVE")
- Value: term object with
value,type,name,description
HTTP 200 OK
{
"success": true,
"message": "License created successfully",
"license_code": "LIC-2026-SPRINGER-001"
}Response Fields:
| Field | Type | Description |
|---|---|---|
success |
boolean | Always true for successful submission |
message |
string | Success message |
license_code |
string | Created license code (echoed from request) |
HTTP 400 Bad Request (Validation error)
{
"error": "License code is required"
}HTTP 400 Bad Request (Invalid dates)
{
"error": "End date must be after start date"
}HTTP 500 Internal Server Error (Alma API error)
{
"error": "Error creating license: API key invalid"
}JavaScript:
const licenseData = {
basic_info: {
code: 'LIC-2026-001',
name: 'Test License Agreement',
type: 'LICENSE',
status: 'ACTIVE',
start_date: '2026-01-01',
end_date: '2026-12-31'
},
terms: extractedTerms // From review page
};
const response = await fetch('/submit-license', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(licenseData)
});
const result = await response.json();
if (result.success) {
console.log('License created:', result.license_code);
}- Requires active session with uploaded document
- Session and temporary files are cleared after submission
- Date validation: end_date must be after start_date
- Input sanitization applied to all text fields
- License code must be unique in Alma
Test connection to Alma API. Verifies API key and network connectivity.
GET /test-connection HTTP/1.1
Host: license-uploader.example.edu
Cookie: session=<session-id>HTTP 200 OK (Connection successful)
{
"success": true,
"message": "Connection successful"
}HTTP 500 Internal Server Error (Connection failed)
{
"success": false,
"message": "Connection failed"
}JavaScript:
const response = await fetch('/test-connection');
const result = await response.json();
if (result.success) {
console.log('Alma API connection OK');
} else {
console.error('Alma API connection failed');
}- Tests Alma API connectivity by fetching 1 vendor record
- Uses API key from session config or server .env
- Useful for troubleshooting API configuration
Retrieve list of active vendors from Alma. Used for vendor selection in review form.
GET /get-vendors HTTP/1.1
Host: license-uploader.example.edu
Cookie: session=<session-id>HTTP 200 OK
{
"success": true,
"vendors": [
{
"code": "SPRINGER",
"name": "Springer Nature",
"status": "active"
},
{
"code": "ELSEVIER",
"name": "Elsevier B.V.",
"status": "active"
}
]
}Response Fields:
| Field | Type | Description |
|---|---|---|
success |
boolean | Always true for successful retrieval |
vendors |
array | Array of vendor objects |
Vendor Object:
| Field | Type | Description |
|---|---|---|
code |
string | Vendor code (used for linking) |
name |
string | Vendor display name |
status |
string | Vendor status (typically "active") |
HTTP 500 Internal Server Error
{
"success": false,
"error": "Error fetching vendors from Alma: Unauthorized"
}JavaScript:
const response = await fetch('/get-vendors');
const result = await response.json();
if (result.success) {
const vendorSelect = document.getElementById('vendor-select');
result.vendors.forEach(vendor => {
const option = document.createElement('option');
option.value = vendor.code;
option.textContent = vendor.name;
vendorSelect.appendChild(option);
});
}- Returns up to 100 active vendors (default limit)
- Sorted alphabetically by name
- Requires valid Alma API key
Get the current AI extraction prompt template. Shows custom prompt if set, otherwise default.
GET /api/prompt HTTP/1.1
Host: license-uploader.example.eduHTTP 200 OK (Custom prompt)
{
"success": true,
"prompt": "Analyze the following license agreement...",
"is_custom": true
}HTTP 200 OK (Default prompt)
{
"success": true,
"prompt": "Analyze the following license agreement...",
"is_custom": false
}Response Fields:
| Field | Type | Description |
|---|---|---|
success |
boolean | Always true for successful retrieval |
prompt |
string | Prompt template text |
is_custom |
boolean | True if custom prompt is active, false for default |
JavaScript:
const response = await fetch('/api/prompt');
const result = await response.json();
document.getElementById('prompt-textarea').value = result.prompt;
document.getElementById('custom-indicator').textContent =
result.is_custom ? 'Custom' : 'Default';Update the AI extraction prompt template. Saves custom prompt for all future extractions.
10 requests per hour (administrative operation)
POST /api/prompt HTTP/1.1
Host: license-uploader.example.edu
Content-Type: application/json
X-CSRFToken: <csrf-token>
{
"prompt": "Analyze the following license agreement...\n\n{terms_description}\n\n{document_text}"
}Request Body Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
prompt |
string | Yes | New prompt template (must include placeholders) |
Required placeholders:
{terms_description}- Replaced with license term definitions{document_text}- Replaced with uploaded document text
HTTP 200 OK
{
"success": true,
"message": "Prompt updated successfully"
}HTTP 400 Bad Request (Validation error)
{
"success": false,
"error": "Prompt must contain placeholders: {terms_description}, {document_text}"
}JavaScript:
const newPrompt = document.getElementById('prompt-textarea').value;
const response = await fetch('/api/prompt', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ prompt: newPrompt })
});
const result = await response.json();
if (result.success) {
alert('Prompt saved successfully');
}- Custom prompt applies to all users of the instance
- Changes take effect immediately for new uploads
- Prompt is saved to
custom_prompt.txtfile
Reset prompt template to default by deleting custom prompt file.
10 requests per hour (administrative operation)
DELETE /api/prompt HTTP/1.1
Host: license-uploader.example.edu
X-CSRFToken: <csrf-token>HTTP 200 OK
{
"success": true,
"message": "Prompt reset to default"
}JavaScript:
const response = await fetch('/api/prompt', {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken
}
});
const result = await response.json();
if (result.success) {
alert('Prompt reset to default');
// Reload prompt
location.reload();
}- Deletes
custom_prompt.txtfile if it exists - Subsequent extractions use default prompt
- Idempotent operation (safe to call multiple times)
1. Get CSRF Token
const csrfResponse = await fetch('/api/csrf-token');
const { csrf_token } = await csrfResponse.json();2. Upload Document
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const uploadResponse = await fetch('/upload', {
method: 'POST',
headers: { 'X-CSRFToken': csrf_token },
body: formData
});
const uploadResult = await uploadResponse.json();
// uploadResult.redirect = "/review"3. Navigate to Review Page
window.location.href = uploadResult.redirect;
// User reviews and edits terms in browser4. Refine Specific Term (Optional)
const refineResponse = await fetch('/refine-term', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrf_token
},
body: JSON.stringify({
term_code: 'FAIRUSE',
current_value: null
})
});
const { value } = await refineResponse.json();
// Update UI with refined value5. Submit to Alma
const submitResponse = await fetch('/submit-license', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrf_token
},
body: JSON.stringify({
basic_info: {
code: 'LIC-2026-001',
name: 'Test License',
type: 'LICENSE',
status: 'ACTIVE'
},
terms: extractedTerms
})
});
const submitResult = await submitResponse.json();
// submitResult.license_code = "LIC-2026-001"{
"value": "Permitted (Explicit)",
"type": "LicenseTermsPermittedProhibited",
"name": "Archiving Right",
"description": "The right to permanently retain an electronic copy of the licensed materials."
}LicenseTermsYesNo:
"Yes""No"
LicenseTermsPermittedProhibited:
"Permitted (Explicit)""Permitted (Interpreted)""Prohibited (Explicit)""Prohibited (Interpreted)""Silent""Uninterpreted""Not Applicable"
LicenseTermsRenewalType:
"Explicit""Automatic"
LicenseTermsUOM:
"Week""Calendar Day""Month""Business Day"
FREE-TEXT:
- Any string (max 255 characters for most fields)
All state-changing requests require CSRF token:
- POST /upload
- POST /refine-term
- POST /submit-license
- POST /api/config
- POST /api/prompt
- DELETE /api/prompt
Obtain token:
const response = await fetch('/api/csrf-token');
const { csrf_token } = await response.json();Include in requests:
headers: {
'X-CSRFToken': csrf_token
}- HttpOnly cookies: Prevents JavaScript access
- Secure flag: HTTPS only (production)
- SameSite=Lax: CSRF protection
- 1 hour timeout: Sessions expire after inactivity
All inputs are validated:
- File type and size
- Date formats
- Required fields
- Text length limits
- Dangerous characters removed
Prevents abuse and ensures fair usage:
- Global: 200/day, 50/hour per IP
- Upload: 10/hour
- Refine: 30/hour
- Submit: 20/hour
Last Updated: 2026-01-30 Version: 1.0 Document Owner: Technical Writer Agent