Docs

API Reference

Base URL: https://api.verimago.io

All requests use JSON. All responses return JSON. CORS is enabled for all origins.

Authentication

Most endpoints require a Bearer token. Obtain one by logging in or using a persistent API key.

Authorization: Bearer <token-or-api-key>

Auth

POST /v1/auth/register

Create a new account.

{

"email": "user@example.com",

"password": "min-8-characters",

"name": "Jane Smith"

}

Response 201:
{

"message": "Verification email sent",

"userId": "usr_abc123"

}

POST /v1/auth/verify-email

Verify email address using the code sent during registration.

{

"email": "user@example.com",

"code": "123456"

}

POST /v1/auth/login

Request body uses username (for normal accounts this is your email address), not email.

{

"username": "user@example.com",

"password": "your-password"

}

Response 200 (typical):
{

"token": "vmgo_...",

"passwordChangeRequired": false

}

When passwordChangeRequired is true, the client should send the user to change password (publisher portal: Settings) before normal browsing; GET /v1/user/me echoes the same flag until POST /v1/user/me/password succeeds.

Use Authorization: Bearer on protected routes. If SMS MFA is enabled, you may get mfaRequired and mfaToken instead of token until you complete POST /v1/auth/mfa/verify. If the account has no certificate yet, you may get needsCert: true with a token for onboarding.

POST /v1/auth/forgot-password

{ "email": "user@example.com" }

POST /v1/auth/reset-password

{

"email": "user@example.com",

"code": "123456",

"newPassword": "new-password"

}


Identities (social links — named Creator certs roadmap)

Auth: Bearer session or API key resolving to a user.

GET /v1/identities

Returns linked social identities (active only). Empty array until the user completes OAuth link flows (Phase 1).

Response 200:
{

"identities": [

{

"id": "uuid",

"provider": "instagram",

"providerUserId": "178414053…",

"username": "@handle",

"displayName": "Display Name",

"profileUrl": "https://…",

"profileImage": "https://…",

"verifiedAt": "2026-04-18T12:00:00.000Z",

"isPrimary": true

}

]

}

Errors: 503 with code: "VMG_MIGRATION_REQUIRED" if the social_identities table has not been created (run POST /v1/admin/migrate or apply packages/api/src/db/migrate-production.sql).

PATCH /v1/identities/{id}/primary

Sets the given identity as primary (clears isPrimary on others). 404 if the id does not belong to the caller.

DELETE /v1/identities/{id}

Soft-revokes the link (revoked_at). Promotes the oldest remaining identity to primary when needed.

First-party Verimago @handle (verimago_handle)

Auth: Bearer session (user-scoped). Not the login username field — this is the public registry @handle (Option B — docs/HANDOFF_OAUTH_NAMED_CERTS.md). Also on: GET /v1/user/me returns verimagoHandle and verimagoHandleSetAt when columns exist.

GET /v1/user/me/handle

200 — current handle, eligibility for claim/change, and troubleshooting pointers (include requestId from the JSON or X-Request-Id header in support tickets).
FieldMeaning
eligibility.canClaimNo handle yet and account passes gates
eligibility.canChangeHandle exists and 90-day cooldown has passed
eligibility.reasonsMachine codes when blocked, e.g. account_type_not_creator, subscription_not_active, change_cooldown_active
eligibility.billingWhen VERIMAGO_HANDLE_REQUIRE_ACTIVE_SUB=1, an ACTIVE cert subscription is required if the user has a cert_id
503 VMG_MIGRATION_REQUIRED — DB columns missing.

PUT /v1/user/me/handle

Body: { "handle": "myname" } — optional leading @; stored lowercase; charset [a-z0-9_]; length 3–30; no reserved names (see implementation blocklist).
CodeHTTPWhen
(success — no code field)200Set or changed — body: ok, verimagoHandle, verimagoHandleSetAt, requestId (CloudWatch audit uses handle.success / detail VMG_HANDLE_SET)
(unchanged)200Same handle as current — unchanged: true (audit VMG_HANDLE_UNCHANGED)
VMG_HANDLE_BODY400Invalid JSON
VMG_HANDLE_FORMAT / VMG_HANDLE_LENGTH / VMG_HANDLE_CHARS / VMG_HANDLE_RESERVED400Validation — response includes hint and rule
VMG_AUTH_REQUIRED / VMG_AUTH_USER_REQUIRED401Missing Bearer or non-user key
VMG_HANDLE_SUBSCRIPTION_REQUIRED402Operator set VERIMAGO_HANDLE_REQUIRE_ACTIVE_SUB=1 and subscription not active
VMG_HANDLE_ACCOUNT_TYPE403account_type is not creator
VMG_HANDLE_TAKEN409Unique conflict (another user; case-insensitive)
VMG_HANDLE_RATE_LIMIT429Too many attempts per hour (Retry-After)
VMG_HANDLE_CHANGE_COOLDOWN429Handle change before 90 days — body includes changeAvailableAt and Retry-After
VMG_MIGRATION_REQUIRED503Columns not migrated
Observability: structured handle.* JSON lines in CloudWatch; audit_log row handle.set with payload including previous handle and requestId. Certificate display: after a successful PUT, the API attempts to refresh certificates.publisher_name for the caller’s Creator cert (cert_type = creator) from linked identities + @handle (see docs/HANDOFF_OAUTH_NAMED_CERTS.md). Check CloudWatch for cert.publisher_name_synced / cert.publisher_name_sync_failed. Env: VERIMAGO_HANDLE_REQUIRE_ACTIVE_SUB — set to 1 in production if handle claim must wait on Stripe-active creator cert subscription; default unset (staging-friendly).

OAuth (Google live; other providers stubbed)

Env (Lambda): GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, OAUTH_CALLBACK_BASE (HTTPS origin of this API, e.g. https://api.verimago.io — used to build redirect_uri/v1/oauth/google/callback), REDIS_URL, optional OAUTH_SUCCESS_REDIRECT / OAUTH_ERROR_REDIRECT (browser callback landing; default success → https://verimago.com/creator/settings/ with ?oauth=success&provider=google). Register the same redirect_uri in Google Cloud Console.

GET /v1/oauth/{provider}/authorize

Auth: Bearer required.
ProviderBehavior
googleIf env is complete: 302 Location → Google authorize URL (opaque state in Redis, 10m TTL). If Accept: application/json, 200 { "redirectUrl": "https://accounts.google.com/…" } for SPAs.
apple, instagram, tiktok, x501 VMG_OAUTH_NOT_CONFIGURED

GET /v1/oauth/{provider}/callback

Auth: none (browser redirect from Google). google: exchanges code, validates state, upserts social_identities (no refresh token stored). 409 VMG_IDENTITY_CONFLICT if that Google account is already linked to another user. On success: 302 to OAUTH_SUCCESS_REDIRECT with ?oauth=success&provider=google, or 200 JSON if Accept: application/json.

Publisher domain validation (DNS TXT)

Auth: Bearer on a publisher API key with cert_type = publisher, VERIFIED certificate, and non-empty certificates.domain matching the request.

POST /v1/domains/validate

Body:
{ "domain": "example.com", "method": "dns_txt" }
201: { "validationId", "domain", "method", "expiresAt", "instructions", "txtName", "txtValue" } — publish txtValue as a TXT record on the zone apex (@). Proof string format: verimago-domain-verification=. Errors: 403 if key has no cert, cert not publisher, or not verified; 400 VMG_DOMAIN_MISMATCH if domain ≠ certificate domain (after normalization).

POST /v1/domains/check

Body: { "validationId": "" } 200: { "status": "pending" | "verified", "validationId", "domain", "checkCount", "expectedTxt" }. On first successful TXT match, sets domain_validations.status = verified and stamps certificates.domain_validated_at. 429 if too many checks (sliding window per user). 501 / stubs: Apple, Instagram, TikTok, X OAuth paths still return VMG_OAUTH_NOT_CONFIGURED until wired.

Full design: docs/HANDOFF_OAUTH_NAMED_CERTS.md, architecture: docs/architecture/OAUTH_NAMED_CERTS_AND_DOMAIN_VALIDATION.md.


Sign (Server-Side)

POST /v1/sign

Auth: Bearer token required

Sign a content hash using the Verimago CA. The video/photo file is never uploaded — only the SHA-384 hash (96 hex).

Entitlement: Caller must have an active subscription on their certificate or a paid, unused freelance purchase for this exact hash. Otherwise 402 with code VERIMAGO_SIGN_REQUIRES_SUBSCRIPTION_OR_FREELANCE. Use POST /v1/stripe/checkout-onetime (body includes required contentHash) and poll GET /v1/stripe/freelance-purchase?contentHash=… until canServerSign is true. Request:
{

"contentHash": "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b",

"headline": "Breaking: City council vote",

"journalist": "Jane Smith",

"location": "Portland, OR",

"recordedAt": "2026-03-28T14:30:00Z",

"contentType": "AUTHENTIC",

"captureMode": "VIDEO",

"tags": ["politics", "local"]

}

FieldRequiredValues
contentHashYes96-char hex SHA-384 (optional sha384: prefix)
headlineYesDescription of the content
journalistNoName of the person who captured/created the content
locationNoWhere the content was captured
recordedAtNoISO 8601 timestamp of capture
contentTypeNo (default AUTHENTIC)AUTHENTIC, AI_ENHANCED, AI_GENERATED
captureModeNoVIDEO (default), PHOTO
tagsNoArray of string tags
Response 201:
{

"contentHash": "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b",

"certId": "550e8400-e29b-41d4-a716-446655440000",

"signature": "MEUCIQ...",

"shieldState": "GREEN",

"signedAt": "2026-03-28T15:00:00.000Z",

"contentType": "AUTHENTIC",

"verifyUrl": "https://registry.verimago.io/v/?h=38b060a751ac"

}

verifyUrl uses the first 12 hex digits of the content hash in h= (easy to type). The registry page and this API also accept full sha384: + 96 hex or 8–95 hex as a prefix (first matching manifest). Errors: Shield state mapping:
contentTypeshieldState
AUTHENTICGREEN
AI_ENHANCEDPURPLE
AI_GENERATEDAMBER

POST /v1/stripe/checkout-onetime

Auth: Bearer token required. Body: { "plan": "freelance_photo_299" | "freelance_video_499", "contentHash": "<96 hex SHA-384>" }contentHash is required and must match the hash you will pass to POST /v1/sign. Response 201: { "url": "", "plan": "…", "contentHash": "<96 hex>" }

GET /v1/stripe/freelance-purchase

Auth: Bearer token required. Query: contentHash (same encoding as signing). Response 200: { "contentHash", "hasPurchase", "consumed", "plan", "createdAt", "hasActiveSubscription", "canServerSign" }

Verify (Public)

GET /v1/verify/{hash}

Auth: None required

Look up a certificate by content hash. Path {hash} may be full sha384: / sha256: + hex, plain 64- or 96-char hex, or an 8–95 character hex prefix (prefix resolves to the first matching manifest).

GET /v1/verify/sha384:38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b

GET /v1/verify/38b060a751ac

Response 200 (found):
{

"verified": true,

"shieldState": "GREEN",

"publisher": "Reuters",

"journalist": "Jane Smith",

"headline": "Breaking: City council vote",

"location": "Portland, OR",

"signedAt": "2026-03-28T15:00:00Z",

"recordedAt": "2026-03-28T14:30:00Z",

"contentType": "AUTHENTIC",

"certStatus": "ACTIVE"

}

Response 404 (not found):
{

"verified": false,

"shieldState": "GREY"

}

POST /v1/verify/url

Auth: None required

Verify content by URL. The server fetches the file, computes the hash, and looks it up.

{

"url": "https://example.com/video.mp4"

}

Rate limited to 10 requests per IP per minute. Maximum file size: 500 MB. Timeout: 25 seconds.


API Keys

GET /v1/keys

Auth: Bearer token

List all active API keys for your account.

Response 200:
{

"items": [

{

"id": "key_abc123",

"name": "CMS Production",

"keyPrefix": "vmgo_xxxxxxx",

"createdAt": "2026-03-28T12:00:00Z",

"lastUsedAt": "2026-03-28T15:00:00Z",

"expiresAt": "2027-03-28T12:00:00Z"

}

],

"total": 1

}

expiresAt is null when the key has no scheduled expiry.

POST /v1/keys

Auth: Bearer token

Generate a new API key. The full key is returned only once.

{ "name": "CMS Production" }

Optional automatic expiry (choose one; if both are sent, the request is rejected):

Omit both for a key that stays valid until revoked (same behavior as before this option existed).

{ "name": "CI — C2PA verify", "ttlDays": 90 }
Response 201 — the secret is in raw (shown once; not stored server-side in the clear):
{

"id": "uuid",

"name": "CMS Production",

"raw": "vmgo_…",

"prefix": "vmgo_xxxxxx",

"expiresAt": "2026-06-26T12:00:00.000Z"

}

When no expiry was requested, expiresAt is null.

Use raw as Authorization: Bearer … and as apiKey in @verimago/sdk. The list endpoint returns only keyPrefix (first 12 characters of the key), not the full secret.

POST /v1/keys/{id}/revoke

Auth: Bearer token

Revoke an API key. Immediate effect — all requests using this key will fail.


Certificates

GET /v1/certs

Auth: Bearer token

List certificates associated with your account.

GET /v1/certs/{id}

Auth: Bearer token

Get certificate details including status, expiry, and public key.

PATCH /v1/certs/{id}/activate

Auth: Bearer token

Activate a pending certificate.

PATCH /v1/certs/{id}/revoke

Auth: Bearer token

Revoke a certificate. All manifests signed with this certificate will show the certificate as revoked during verification, though the signature remains valid.


Applications

POST /v1/apply

Auth: None required

Submit a publisher or creator application.

{

"organizationName": "Portland Herald",

"contactEmail": "editor@portlandherald.com",

"contactName": "Jane Smith",

"domain": "portlandherald.com",

"jurisdiction": "US-OR",

"description": "Daily newspaper covering Portland metro area, 50,000 daily readers"

}

For creator applications, omit domain and organizationName.

Response 201:
{

"id": "app_abc123",

"status": "PENDING",

"message": "Application received. You will be contacted within 48 hours."

}

GET /v1/apply/{id}

Auth: None required

Check application status.


Watermark

POST /v1/watermark/upload-url

Auth: Bearer token

Get a presigned S3 URL to upload a video for watermarking.

{

"filename": "my-video.mp4",

"contentType": "video/mp4"

}

Response 200:
{

"jobId": "wm_abc123",

"uploadUrl": "https://s3.amazonaws.com/...",

"expiresIn": 3600

}

POST /v1/watermark/process

Auth: Bearer token

Start watermark processing for an uploaded video.

{ "jobId": "wm_abc123" }

GET /v1/watermark/status/{jobId}

Auth: None

Check watermark processing status and get download URL when complete.


OCSP

These routes expose JSON for publisher TLS / account certificates in Verimago’s database. They are not RFC 6960 PKIX OCSP for the C2PA CA chain (issuer PEM + CRL for that chain are published at http://ca.verimago.io under /certs/ and /crl/ when operators sync the CA publication bucket).

GET /v1/ocsp/status

JSON health check. Response includes status: "ok" and a short note clarifying the separation from CA-chain OCSP.

GET /v1/ocsp/{certId}

Check publisher certificate revocation / expiry status (JSON fields such as GOOD / REVOKED / UNKNOWN), not DER OCSP.


TSA (timestamp proxy)

Verimago does not operate a Trusted Timestamp Authority. This API proxies RFC 3161 requests to the upstream service configured by TSA_URL. Default upstream in code and CDK is DigiCert’s public RFC 3161 endpoint http://timestamp.digicert.com (override with TSA_URL in GitHub staging / production environment secrets if a different DigiCert contract URL is required).

Operational invariant: First-party iOS and Android cameras call this endpoint with a SHA-384 digest (48 bytes) of the capture bytes (or a rolling SHA-384 over video NALs) before C2PA embed. The Lambda must accept hash lengths 32 (legacy SHA-256) and 48 (SHA-384) and build the correct RFC 3161 MessageImprint for each (packages/api/src/routes/tsa.ts). If production still enforces 32 bytes only, cameras get HTTP 400 and signing fails (TSAError / Android IllegalStateException) even with perfect network connectivity. Deploy the API (deploy-production.yml or your staging equivalent) before or with shipping mobile builds that send SHA-384.

GET /v1/tsa/status

Health check for the timestamp proxy. Response includes the effective upstream TSA base URL (from TSA_URL or the built-in DigiCert default).

POST /v1/tsa

RFC 3161 proxy to the upstream TSA (DigiCert when TSA_URL is set correctly). Used by Verimago Camera (and integrators that mirror the same flow) during capture.

Request — JSON body:
FieldRequiredDescription
hashYesStandard base64 encoding of the raw digest bytes (32 bytes = SHA-256 legacy, 48 bytes = SHA-384 canonical).
Responses (200) — JSON:
FieldDescription
timestampISO 8601 string (may include fractional seconds).
timestampMsUnix epoch milliseconds (Android client uses this).
timestampBytesBase64 — opaque bytes used with attestation / signing payload on the client.
tokenBase64 — full RFC 3161 TimeStampToken for embedding in C2PA.
Errors: 400 if hash is missing or decodes to a length other than 32 or 48. 500 if the upstream TSA rejects the request or parsing fails. Smoke (production): after API deploy, confirm a 48-byte SHA-384 digest is accepted:
H=$(python3 -c "import base64,hashlib; print(base64.b64encode(hashlib.sha384(b'test').digest()).decode())")

curl -sS -w "\nHTTP %{http_code}\n" -X POST "https://api.verimago.io/v1/tsa" \

-H "Content-Type: application/json" -d "{\"hash\":\"$H\"}" | head -c 200

Expect HTTP 200 and JSON with token and timestamp. HTTP 400 whose error text only allows 32-byte SHA-256 means the deployed Lambda is older than packages/api/src/routes/tsa.ts on main — redeploy the API before relying on new camera builds.


Operator admin (internal)

These routes are for operators using the publisher Admin UI or scripts. Authenticate with either:

Certificate admin actions use the same rule: PATCH /v1/certs/{id}/activate and PATCH /v1/certs/{id}/revoke. Application review (PATCH /v1/apply/{id}) and GET /v1/admin/applications likewise.

MethodPathDescription
GET/v1/admin/usersList users (limit, optional search). Response items include disabledAt, accountDisabled.
POST/v1/admin/users/{id}/reset-passwordSet a temporary password; returns tempPassword once.
POST/v1/admin/users/{id}/force-signoutRevoke all API keys / sessions for that user (user-scoped and cert-scoped keys for their linked cert).
POST/v1/admin/users/{id}/disableOptional JSON body { "reason": "..." }. Revokes keys, sets disabled_at, blocks login and Bearer auth until enabled.
POST/v1/admin/users/{id}/enableClears disabled_at / disabled_reason.
DELETE/v1/admin/users/{id}Hard delete (existing behavior).
GET/v1/admin/logsAudit log rows. Query: limit, eventType, targetId, from, to (ISO 8601 timestamps).
GET/v1/admin/logs/exportSame filters as /v1/admin/logs plus limit (default 5000, max 20000); response is CSV (text/csv).

Apply DB migrations so users.disabled_at / users.disabled_reason / users.platform_role exist before relying on disable/enable or superadmin Bearer auth in production (packages/api/src/db/migrate-production.sql and admin migrate bundle). Bootstrap script: scripts/bootstrap-platform-superadmin.ts (see docs/SETUP.md).


Errors

All errors follow this format:

{

"error": "Human-readable error message"

}

StatusMeaning
400Bad request — check your input
401Unauthorized — missing or invalid token
404Not found
429Rate limited
500Server error — contact support

Rate limits

EndpointLimit
POST /v1/verify/url10 req/IP/min
POST /v1/sign100 req/key/min
GET /v1/verify/{hash}1000 req/IP/min
All other endpoints60 req/key/min