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, not null. Branching on null never fires — discovery_status always carries a value.
  • no_certs_found is common in practice, not an edge case. On the current production data the distribution is success 40, no_certs_found 31, pending 26, js_only_no_links 7, bot_blocked 4, error 1.

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_locked becomes true and certificates is omitted — but certificate_count remains, so the answer degrades rather than disappearing.
  • certificate_number and document_url are 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_frequency field 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