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).
Response200:
{
"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).
| Field | Meaning |
|---|---|
eligibility.canClaim | No handle yet and account passes gates |
eligibility.canChange | Handle exists and 90-day cooldown has passed |
eligibility.reasons | Machine codes when blocked, e.g. account_type_not_creator, subscription_not_active, change_cooldown_active |
eligibility.billing | When VERIMAGO_HANDLE_REQUIRE_ACTIVE_SUB=1, an ACTIVE cert subscription is required if the user has a cert_id |
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).
| Code | HTTP | When |
|---|---|---|
(success — no code field) | 200 | Set or changed — body: ok, verimagoHandle, verimagoHandleSetAt, requestId (CloudWatch audit uses handle.success / detail VMG_HANDLE_SET) |
| (unchanged) | 200 | Same handle as current — unchanged: true (audit VMG_HANDLE_UNCHANGED) |
VMG_HANDLE_BODY | 400 | Invalid JSON |
VMG_HANDLE_FORMAT / VMG_HANDLE_LENGTH / VMG_HANDLE_CHARS / VMG_HANDLE_RESERVED | 400 | Validation — response includes hint and rule |
VMG_AUTH_REQUIRED / VMG_AUTH_USER_REQUIRED | 401 | Missing Bearer or non-user key |
VMG_HANDLE_SUBSCRIPTION_REQUIRED | 402 | Operator set VERIMAGO_HANDLE_REQUIRE_ACTIVE_SUB=1 and subscription not active |
VMG_HANDLE_ACCOUNT_TYPE | 403 | account_type is not creator |
VMG_HANDLE_TAKEN | 409 | Unique conflict (another user; case-insensitive) |
VMG_HANDLE_RATE_LIMIT | 429 | Too many attempts per hour (Retry-After) |
VMG_HANDLE_CHANGE_COOLDOWN | 429 | Handle change before 90 days — body includes changeAvailableAt and Retry-After |
VMG_MIGRATION_REQUIRED | 503 | Columns not migrated |
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.
| Provider | Behavior |
|---|---|
If 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, x | 501 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 withcert_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 requiredSign 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. Otherwise402 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"]
}
| Field | Required | Values |
|---|---|---|
contentHash | Yes | 96-char hex SHA-384 (optional sha384: prefix) |
headline | Yes | Description of the content |
journalist | No | Name of the person who captured/created the content |
location | No | Where the content was captured |
recordedAt | No | ISO 8601 timestamp of capture |
contentType | No (default AUTHENTIC) | AUTHENTIC, AI_ENHANCED, AI_GENERATED |
captureMode | No | VIDEO (default), PHOTO |
tags | No | Array of string tags |
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:
402— No subscription / no unused freelance credit for this hash.409— Manifest already exists; body may includeexistingwith priorverifyUrland fields.
| contentType | shieldState |
|---|---|
| AUTHENTIC | GREEN |
| AI_ENHANCED | PURPLE |
| AI_GENERATED | AMBER |
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 requiredLook 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 requiredVerify 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 tokenList all active API keys for your account.
Response200:
{
"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 tokenGenerate 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):
ttlDays— integer from 1 to 365. The key stops working after that many days from creation (server wall clock).expiresAt— ISO 8601 instant in the future, at most 365 days after the request.
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 tokenRevoke an API key. Immediate effect — all requests using this key will fail.
Certificates
GET /v1/certs
Auth: Bearer tokenList certificates associated with your account.
GET /v1/certs/{id}
Auth: Bearer tokenGet certificate details including status, expiry, and public key.
PATCH /v1/certs/{id}/activate
Auth: Bearer tokenActivate a pending certificate.
PATCH /v1/certs/{id}/revoke
Auth: Bearer tokenRevoke 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 requiredSubmit 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.
201:
{
"id": "app_abc123",
"status": "PENDING",
"message": "Application received. You will be contacted within 48 hours."
}
GET /v1/apply/{id}
Auth: None requiredCheck application status.
Watermark
POST /v1/watermark/upload-url
Auth: Bearer tokenGet 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 tokenStart watermark processing for an uploaded video.
{ "jobId": "wm_abc123" }
GET /v1/watermark/status/{jobId}
Auth: NoneCheck 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).
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.
| Field | Required | Description |
|---|---|---|
hash | Yes | Standard base64 encoding of the raw digest bytes (32 bytes = SHA-256 legacy, 48 bytes = SHA-384 canonical). |
| Field | Description |
|---|---|
timestamp | ISO 8601 string (may include fractional seconds). |
timestampMs | Unix epoch milliseconds (Android client uses this). |
timestampBytes | Base64 — opaque bytes used with attestation / signing payload on the client. |
token | Base64 — full RFC 3161 TimeStampToken for embedding in C2PA. |
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:
x-admin-secret:(same value as theADMIN_SECRETenvironment variable on the API), orAuthorization: Bearerfor a user whoseusers.platform_roleissuperadmin(same capabilities as the shared secret for these operator routes).
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.
| Method | Path | Description |
|---|---|---|
GET | /v1/admin/users | List users (limit, optional search). Response items include disabledAt, accountDisabled. |
POST | /v1/admin/users/{id}/reset-password | Set a temporary password; returns tempPassword once. |
POST | /v1/admin/users/{id}/force-signout | Revoke all API keys / sessions for that user (user-scoped and cert-scoped keys for their linked cert). |
POST | /v1/admin/users/{id}/disable | Optional JSON body { "reason": "..." }. Revokes keys, sets disabled_at, blocks login and Bearer auth until enabled. |
POST | /v1/admin/users/{id}/enable | Clears disabled_at / disabled_reason. |
DELETE | /v1/admin/users/{id} | Hard delete (existing behavior). |
GET | /v1/admin/logs | Audit log rows. Query: limit, eventType, targetId, from, to (ISO 8601 timestamps). |
GET | /v1/admin/logs/export | Same 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"
}
| Status | Meaning |
|---|---|
| 400 | Bad request — check your input |
| 401 | Unauthorized — missing or invalid token |
| 404 | Not found |
| 429 | Rate limited |
| 500 | Server error — contact support |
Rate limits
| Endpoint | Limit |
|---|---|
| POST /v1/verify/url | 10 req/IP/min |
| POST /v1/sign | 100 req/key/min |
| GET /v1/verify/{hash} | 1000 req/IP/min |
| All other endpoints | 60 req/key/min |