CertWatch — API Reference
Base URL: https://<your-domain>/api/
All endpoints require authentication via Authorization: Token <key> header unless marked as public.
Authentication
CertWatch uses token-based authentication. Each token (TenantToken) carries both user identity and tenant context. All data returned is scoped to the tenant associated with the token.
Authorization: Token 9a8b7c6d5e4f3a2b1c0d...
Rate Limiting
Auth endpoints (register, login, invite accept) are throttled to 5 requests per minute per IP.
Pagination
All list endpoints use page-based pagination:
| Parameter | Default | Max | Description |
|---|---|---|---|
page |
1 | — | Page number |
page_size |
20 | 500 | Results per page |
Response format:
{
"count": 142,
"next": "https://api.example.com/api/certificates/?page=2",
"previous": null,
"results": [...]
}
Authentication & Tenants — /api/auth/
Register
POST /api/auth/register/
Public — no authentication required.
Creates a new tenant, user (with is_active=False), and admin membership. Sends a verification email. The user must verify their email before they can log in.
Request:
{
"email": "user@example.com",
"password": "securePassword123",
"organization_name": "My Company"
}
Response (201):
{
"detail": "Verification email sent.",
"email": "user@example.com"
}
Note: No authentication token is returned at registration. The user must verify their email first, then log in to obtain a token.
Login
POST /api/auth/login/
Public — no authentication required.
Request:
{
"email": "user@example.com",
"password": "securePassword123",
"tenant_id": "uuid" // optional — omit to use most recent membership
}
Response (200):
{
"token": "9a8b7c6d...",
"email": "user@example.com",
"tenant_id": "uuid",
"tenant_name": "My Company",
"role": "admin"
}
Create Invitation
POST /api/auth/invitations/
Admin only. Creates an invitation for a new user to join the tenant.
Request:
{
"email": "newuser@example.com",
"role": "member" // "admin" or "member"
}
Response (201):
{
"invitation_id": "uuid",
"email": "newuser@example.com",
"role": "member",
"status": "pending",
"expires_at": "2025-01-22T10:00:00Z"
}
Accept Invitation
POST /api/auth/invite/accept/
Public. Accepts an invitation, creates a user account and returns a token.
Request:
{
"token": "invitation-token-string",
"password": "securePassword123"
}
Response (201):
{
"user_id": 2,
"tenant_id": "uuid",
"auth_token": "abc123..."
}
Verify Email
GET /api/auth/verify-email/?token=<token>
Public. Verifies a user's email address using the token from the verification email. Sets the user's account to active.
Response (200):
{
"detail": "Email verified successfully."
}
Returns 400 if the token is invalid or expired (tokens expire after 24 hours).
Resend Verification Email
POST /api/auth/resend-verification/
Public. Sends a new verification email for an unverified account.
Request:
{
"email": "user@example.com"
}
Response (200):
{
"detail": "Verification email sent."
}
Returns a success response regardless of whether the email exists (to prevent email enumeration).
List Members
GET /api/auth/members/
Admin only. Returns all members of the current tenant.
Member Detail
GET /api/auth/members/{id}/
PATCH /api/auth/members/{id}/ — update role
DELETE /api/auth/members/{id}/ — remove member (cannot remove last admin)
Admin only.
List / Create API Tokens
GET /api/auth/tokens/ — list tokens for current user + tenant
POST /api/auth/tokens/ — create a new API key token
Request (POST):
{
"name": "CI Pipeline Token",
"expires_at": "2026-01-01T00:00:00Z" // optional — null = never expires
}
Response (201):
{
"key": "abc123...",
"name": "CI Pipeline Token",
"created_at": "2025-01-15T10:00:00Z",
"expires_at": "2026-01-01T00:00:00Z"
}
Refresh Token
POST /api/auth/tokens/refresh/
Replaces the current token with a new one (fresh 30-day expiry). The old token is deleted.
Revoke Token
DELETE /api/auth/tokens/{key}/
Permanently deletes the specified token.
Organizations — /api/organizations/
List Organizations
GET /api/organizations/
Returns organizations with annotated certificate counts.
Query Parameters:
| Parameter | Description |
|---|---|
country |
Filter by ISO country code (e.g. ?country=DE) |
external_id |
Exact match on external_id (e.g. ?external_id=ORG-001). A miss returns 200 with an empty results array, never a 404. |
search |
Search by name or external_id (e.g. ?search=Acme) |
ordering |
Sort by name, external_id, country, created_at |
Response includes: id, external_id, name, website, country, active_certificates, expiring_certificates, expired_certificates, monitored_endpoint_count
Portfolio Statistics
GET /api/organizations/stats/
Tenant-wide totals, aggregated in the database. Use this rather than counting list responses: those are paginated, so a tenant above the page limit would get figures for one page instead of its whole portfolio.
Response:
{
"organizations": 50,
"certificates": 62,
"active_certificates": 55,
"expiring_certificates": 3,
"expired_certificates": 7,
"monitored_endpoints": 749
}
expiring_certificates counts active certificates expiring within 30 days.
monitored_endpoints counts active endpoints only.
Validate Organization Link
POST /api/organizations/validate-link/
Resolves an external_id to the organization it currently points at. Intended
for a consuming system that stores a CertWatch Organization.id and needs to
confirm it still refers to the expected record — for example SCRM Guard's
certwatch_org_id on a vendor profile.
Read-only: nothing is created or modified. That matters because the caller may be a copy running against a restored database dump, and a lookup with side effects would cause exactly the id corruption this endpoint guards against.
Request:
{
"external_id": "ORG-001"
}
Response — link resolves:
{
"status": "found",
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Acme Corp",
"external_id": "ORG-001",
"certificates_count": 5
}
Response — link no longer resolves:
{
"status": "not_found",
"external_id": "ORG-001"
}
Both cases return 200: "this link does not resolve" answers the question
that was asked and is not a failed request. Only a missing or blank
external_id returns 400.
external_id is unique per tenant, and the lookup runs against the
authenticated tenant's data only, so it matches at most one organization and
can never resolve another tenant's identifier.
certificates_count is the total number of certificates on the organization,
across all statuses.
Create Organization
POST /api/organizations/
Request:
{
"external_id": "ORG-001",
"name": "Acme Corp",
"website": "https://acme.com",
"lei": "5493001KJTIIGC8Y1R12",
"vat": "DE123456789",
"country": "DE"
}
On creation, certificate discovery is automatically triggered in the background.
Organization Detail
GET /api/organizations/{id}/
PUT /api/organizations/{id}/
PATCH /api/organizations/{id}/
DELETE /api/organizations/{id}/
Detail response includes nested sites array and the discovery fields below.
Discovery Status
Three read-only fields explain the outcome of the most recent auto-discovery run — i.e. why a supplier has no certificate pages. Writes are ignored: only the discovery task and admins set them.
| Field | Type | Notes |
|---|---|---|
discovery_status |
string | Always present, never null. Defaults to pending. |
discovery_status_detail |
string | Human-readable diagnostic. Empty string, not null, when unset. |
discovery_status_updated_at |
timestamp or null | Null until discovery has run at least once. |
discovery_status values, lowercase snake_case:
| Value | Meaning |
|---|---|
pending |
Discovery has not run yet |
success |
Ran, endpoints were created |
bot_blocked |
Site rejects automated requests (Cloudflare/Akamai) |
js_only_no_links |
JavaScript app without crawlable navigation |
no_certs_found |
Reachable, but no certificate pages found |
error |
Discovery aborted with an error |
manual_override |
Maintained manually by the user |
Two things to watch when consuming this:
- The "not yet run" case is
pending, notnull. Branching on null never fires —discovery_statusalways carries a value. no_certs_foundis common in practice, not an edge case. On the current production data the distribution issuccess40,no_certs_found31,pending26,js_only_no_links7,bot_blocked4,error1.
discovery_status is also present on the list endpoint, for per-row badges.
Sites
GET /api/organizations/sites/
POST /api/organizations/sites/
GET /api/organizations/sites/{id}/
PUT /api/organizations/sites/{id}/
PATCH /api/organizations/sites/{id}/
DELETE /api/organizations/sites/{id}/
Request (POST):
{
"organization": "uuid",
"name": "Main Factory",
"address": "123 Industrial Way, Munich, Germany"
}
Certificates — /api/certificates/
List Certificates
GET /api/certificates/
Query Parameters:
| Parameter | Description |
|---|---|
organization |
Filter by organization UUID |
certificate_type |
Filter by certificate type UUID |
type_code |
Filter by type code (e.g. ?type_code=ISO_9001) |
status |
Filter by status: ACTIVE, EXPIRED, REVOKED, PENDING |
expiry_date |
Filter by exact expiry date |
expiry_date_before |
Certificates expiring on or before date |
expiry_date_after |
Certificates expiring on or after date |
search |
Search by certificate_number, issuing_body, scope |
ordering |
Sort by expiry_date, issue_date, status, created_at |
Create Certificate
POST /api/certificates/
Request:
{
"organization": "uuid",
"certificate_type_id": "uuid",
"certificate_number": "ISO-9001-2024-12345",
"issuing_body": "TÜV SÜD",
"scope": "Design and manufacturing of electronic components",
"issue_date": "2024-01-15",
"expiry_date": "2027-01-14",
"status": "ACTIVE",
"document_url": "https://example.com/cert.pdf"
}
Certificate Detail
GET /api/certificates/{id}/
PUT /api/certificates/{id}/
PATCH /api/certificates/{id}/
DELETE /api/certificates/{id}/
Response:
{
"id": "uuid",
"certificate_number": "ISO-9001-2024-12345",
"certificate_type": {
"code": "ISO_9001",
"name": "ISO 9001:2015"
},
"organization": {
"id": "uuid",
"external_id": "ORG-001",
"name": "Acme Corp"
},
"issuing_body": "TÜV SÜD",
"scope": "Design and manufacturing of electronic components",
"issue_date": "2024-01-15",
"expiry_date": "2027-01-14",
"status": "ACTIVE",
"document_url": "https://example.com/cert.pdf",
"source_url": null,
"document_hash": "a1b2c3...",
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-15T10:00:00Z"
}
Expiring Certificates
GET /api/certificates/expiring/?days=30
Returns active certificates expiring within the specified number of days (default: 30, max: 365).
Public Search Preview
GET /api/public/search/?q=<name-or-domain>
Public — no authentication. Matches on organization name and, when the query contains a dot, on website/domain.
Response:
{
"results": [
{
"organization_name": "Agosi AG",
"certificate_count": 5,
"last_updated": "2026-06-04T12:04:15Z",
"certificates": [
{
"certificate_type": "ISO_14001",
"certificate_type_name": "ISO 14001",
"issuing_body": "KPMG Cert GmbH",
"expiry_date": "2028-12-10"
}
]
}
],
"details_locked": false,
"free_lookups_remaining": 2
}
Counts are scoped to the single tenant with the most active certificates for a given organization name, never summed across tenants — the total would let an outsider infer how many customers track a given supplier. Only ACTIVE certificates are counted.
Free detail lookups
certificates is present only while the visitor has free lookups left:
3 per client address per 24 hours, counted from the first lookup. Signed-in
callers are never charged.
- A search that returns no results does not consume a lookup.
- Once spent,
details_lockedbecomestrueandcertificatesis omitted — butcertificate_countremains, so the answer degrades rather than disappearing. certificate_numberanddocument_urlare never exposed here, with or without quota. Those require an account.
The quota keys on X-Forwarded-For, which is client-controlled. It is a speed
bump against casually scraping the collection, not a security control, and it
only ever decides whether to show a registration prompt. The endpoint is
additionally throttled at 20 requests/minute.
Public Search
GET /api/certificates/search/?q=Acme
Public — no authentication required. Searches organizations by name/website and returns their certificates.
Certificate Types
GET /api/certificates/types/
POST /api/certificates/types/
GET /api/certificates/types/{id}/
PUT /api/certificates/types/{id}/
PATCH /api/certificates/types/{id}/
DELETE /api/certificates/types/{id}/
Monitoring — /api/monitoring/
Monitored Endpoints
GET /api/monitoring/endpoints/
POST /api/monitoring/endpoints/
GET /api/monitoring/endpoints/{id}/
PUT /api/monitoring/endpoints/{id}/
PATCH /api/monitoring/endpoints/{id}/
DELETE /api/monitoring/endpoints/{id}/
Query Parameters:
| Parameter | Description |
|---|---|
organization |
Filter by organization UUID |
is_active |
Filter by active status |
search |
Search by URL or organization name |
ordering |
Sort by url, last_checked, created_at, is_active |
Request (POST):
{
"organization_id": "uuid",
"url": "https://supplier.com/certificates",
"check_frequency": "12:00:00",
"is_active": true
}
Note: The
check_frequencyfield may be automatically adjusted by the system based on certificate expiry proximity. See Adaptive Scheduling below.
Detail response includes: nested organization object and recent_scrape_logs (last 5).
Trigger Manual Scrape
POST /api/monitoring/endpoints/{id}/trigger_scrape/
Queues an async scrape task. Returns 202 Accepted. Fails with 400 if endpoint is inactive.
Scrape Logs
GET /api/monitoring/logs/
GET /api/monitoring/logs/{id}/
Read-only audit trail of scrape attempts.
Certificate Discovery
POST /api/monitoring/discover/
POST /api/monitoring/discover/{organization_id}/
Triggers website crawling to discover certificate-related pages for an organization (or all organizations). Creates MonitoredEndpoint records for discovered URLs.
Monitoring Health
GET /api/monitoring/health/
Returns monitoring subsystem health status.
Adaptive Scheduling
MonitoredEndpoint check frequencies are automatically adjusted based on the expiry proximity of certificates linked to the endpoint's organization. After each scrape, the system evaluates the nearest certificate expiry date and updates check_frequency accordingly:
| Condition | Frequency | Rationale |
|---|---|---|
| Any certificate expiring within 7 days | Every 4 hours | Critical window — maximize freshness |
| Any certificate expiring within 30 days | Every 12 hours | Approaching expiry — increase vigilance |
| No certificates expiring within 30 days | Every 24 hours | Default baseline frequency |
The most urgent tier wins — if an organization has certificates in both the 7-day and 30-day windows, the 4-hour frequency is applied. This adjustment happens automatically after each scrape task completes; no manual API call is needed.
Webhooks — /api/webhooks/
See Webhooks Documentation for the full webhook system reference.
Subscribers
GET /api/webhooks/subscribers/
POST /api/webhooks/subscribers/
GET /api/webhooks/subscribers/{id}/
PUT /api/webhooks/subscribers/{id}/
PATCH /api/webhooks/subscribers/{id}/
DELETE /api/webhooks/subscribers/{id}/
Subscriptions
GET /api/webhooks/subscriptions/
POST /api/webhooks/subscriptions/
GET /api/webhooks/subscriptions/{id}/
PUT /api/webhooks/subscriptions/{id}/
PATCH /api/webhooks/subscriptions/{id}/
DELETE /api/webhooks/subscriptions/{id}/
Webhook Deliveries
GET /api/webhooks/deliveries/
GET /api/webhooks/deliveries/{id}/
Read-only delivery audit log.
Retry Failed Delivery
POST /api/webhooks/deliveries/{id}/retry/
Re-attempts delivery of a failed webhook. Only deliveries with status FAILED can be retried. Resets the delivery to PENDING and enqueues a new delivery task.
Response (200):
{
"status": "retry_queued",
"delivery_id": "delivery-uuid"
}
Returns 400 if the delivery is not in FAILED status.
Rotate Subscriber API Key
POST /api/webhooks/subscribers/{id}/rotate-key/
Generates a new api_key for the subscriber. The previous key is immediately invalidated. Subscriptions and delivery history are preserved.
Response (200):
{
"api_key": "new-auto-generated-api-key"
}
Rotate Subscriber Signing Secret
POST /api/webhooks/subscribers/{id}/rotate-signing-secret/
Generates a new signing_secret for HMAC signature verification. The previous secret is immediately invalidated.
Response (200):
{
"signing_secret": "new-auto-generated-signing-secret"
}
Imports — /api/imports/
Upload File
POST /api/imports/upload/
Multipart file upload. Accepts .xlsx, .xls, .csv.
Request: Content-Type: multipart/form-data with a file field.
Response (200 / 207):
{
"organizations_created": 5,
"organizations_updated": 2,
"certificate_types_created": 1,
"certificate_types_updated": 0,
"discovery_triggered": 5,
"errors": []
}
HTTP 207 indicates partial success (some rows imported, some failed).
Bulk Upload — /api/bulk-upload/
Upload Files
POST /api/bulk-upload/
Multipart upload. Send files under the files key. Accepted extensions: .pdf, .png, .jpg, .jpeg, .tiff, .webp. Max 20 MB per file.
Response (201):
{
"id": "batch-uuid",
"status": "pending",
"created_at": "2025-01-15T10:00:00Z",
"total_items": 3,
"file_errors": ["File 'doc.txt' has unsupported extension '.txt'..."]
}
Processing starts automatically in the background.
List Batches
GET /api/bulk-upload/
Returns all batches ordered by creation date (newest first).
Batch Detail
GET /api/bulk-upload/{id}/
Response:
{
"id": "uuid",
"status": "completed",
"created_at": "2025-01-15T10:00:00Z",
"total_items": 10,
"processed_count": 10,
"matched_count": 7,
"needs_review_count": 2,
"failed_count": 1,
"pending_count": 0,
"auto_match_rate": 0.7,
"failure_rate": 0.1
}
List Batch Items
GET /api/bulk-upload/{batch_id}/items/
Returns items with their status, extracted data, matched organization, and linked certificate.
Confirm Review
POST /api/bulk-upload/items/{item_id}/confirm/
Confirms a needs_review item. Creates a certificate and sets status to matched.
Request:
{
"organization_id": "uuid",
"certificate_type_code": "ISO_9001",
"certificate_number": "CERT-2024-001",
"issuing_body": "TÜV SÜD",
"scope": "Manufacturing",
"issue_date": "2024-01-15",
"expiry_date": "2027-01-14"
}
Skip Review
POST /api/bulk-upload/items/{item_id}/skip/
Skips a needs_review item. Sets status to skipped.
Serve File
GET /api/bulk-upload/files/{item_id}/
Returns the uploaded file with correct Content-Type header for preview.
Quality Metrics
GET /api/bulk-upload/quality-metrics/
Aggregate metrics across all batches: total processed, auto-match rate, failure rate, most-corrected fields.
Health Check
GET /api/health/
Public. Returns service health status.
Error Responses
All error responses follow a consistent format:
{
"error": "Description of what went wrong"
}
Validation errors return field-level details:
{
"email": ["A user with this email already exists."],
"password": ["This password is too common."]
}
HTTP Status Codes
| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Created |
| 202 | Accepted (async task queued) |
| 207 | Multi-Status (partial success) |
| 400 | Bad Request (validation error) |
| 401 | Unauthorized (missing or invalid token) |
| 403 | Forbidden (insufficient permissions) |
| 404 | Not Found |
| 429 | Too Many Requests (rate limited) |
| 500 | Internal Server Error |