Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.beltic.com/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Credentials carry an evidence_refs[] array — opaque audit-trail breadcrumbs that point at the source data the credential is based on. For supporting documents (passport scans, driver’s-license photos, residence-permit PDFs, utility bills), Beltic offers a first-class evidence service: upload the bytes once, get back an ev_<uuid>, then reference it from any number of credentials. When you issue a credential whose evidence_refs[] contains an evidence:<id> entry, Beltic hydrates the record and emits a W3C VCDM 2.0 evidence[] block inside the signed JWT-VC. That block carries a digestSRI: "sha256-..." — the Subresource Integrity digest of the document. Any verifier who later obtains the original bytes (out-of-band, e.g. via the upcoming admin reveal flow) can recompute the hash and confirm document integrity without round-tripping Beltic.

Prerequisites

  • A Beltic API key with credentials:evidence:upload permission. This is a distinct scope from credentials:write so KYC partners who only collect documents can be issued evidence-only keys without the ability to issue or revoke credentials themselves.
  • A supporting document in PDF, JPEG, PNG, WEBP, TIFF, or HEIC/HEIF format. Max 10 MB.

Step 1: Upload the Document

curl -X POST https://api.beltic.com/v1/evidence \
  -H "X-Api-Key: $BELTIC_API_KEY" \
  -F "file=@passport.pdf;type=application/pdf" \
  -F "document_type=passport" \
  -F "filename=alivia-passport.pdf"
file is the only required form field. document_type and filename are both optional but recommended — they surface in the admin reveal flow and in the W3C evidence[] claim emitted at issue time. A successful upload returns 201 with the evidence resource:
{
  "id": "ev_01HQABCDEFGH",
  "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "content_type": "application/pdf",
  "size_bytes": 134217,
  "filename": "alivia-passport.pdf",
  "document_type": "passport",
  "created_at": "2026-05-24T19:42:11.000Z"
}

Idempotency

Uploads are content-addressed by SHA-256. Re-uploading bytes whose hash already exists for your org returns the existing resource with HTTP 200 (instead of 201) and skips the S3 round-trip — useful for resumable uploads or when the same passport scan backs multiple credentials.

Step 2: Reference the Evidence in a Credential

Pass the returned ID with the evidence: prefix in the issue request’s evidence_refs[]:
curl -X POST https://api.beltic.com/v1/credentials \
  -H "X-Api-Key: $BELTIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "credential_type": "user",
    "subject": { "id": "usr_alivia", "type": "person" },
    "claims": {
      "kyc_status": "approved",
      "trust_level": "idv_verified",
      "nationality": "US"
    },
    "evidence_refs": ["evidence:ev_01HQABCDEFGH"]
  }'
You can mix evidence:<id> entries with other opaque ref formats — only the evidence: ones trigger hydration. Anything else passes through to the credential row verbatim.

What Lands in the JWT-VC

When the credential is issued, the inner vc block gains an evidence array per W3C VCDM 2.0:
{
  "vc": {
    "@context": ["https://www.w3.org/ns/credentials/v2"],
    "type": ["VerifiableCredential", "BelticUserCredential"],
    "credentialSubject": { "id": "usr_alivia", "kyc_status": "approved" },
    "evidence": [
      {
        "type": ["DocumentEvidence"],
        "id": "evidence:ev_01HQABCDEFGH",
        "documentType": "passport",
        "filename": "alivia-passport.pdf",
        "digestSRI": "sha256-n4bQgYhMfWWaL-qgxVrQFaO_TxsrCwTSjFTRyo2cFsM"
      }
    ]
  }
}
The digestSRI field follows the W3C Subresource Integrity spec — sha256- prefix followed by the base64url-encoded binary SHA-256 (no padding). A verifier who later receives the document bytes can recompute the digest and compare without needing to fetch anything from Beltic.

Failure Modes

StatusCodeWhat happened
400malformed_requestMultipart body could not be parsed
400missing_required_fieldNo file part in the multipart body
400validation_failedFile is empty or exceeds the 10 MB limit
422unprocessable_entityContent type not in the accepted list
401unauthorizedAPI key invalid or missing
403forbiddenAPI key lacks credentials:evidence:upload
If you reference an evidence:<id> from an issue request and the ID doesn’t exist for your org, the issue call fails with 400 evidence_not_found and the details.missing_ids array lists the unresolved IDs. Cross-org IDs look identical to stale ones — the lookup is org-scoped, by design.

Reading Evidence Back

Once uploaded, evidence can be fetched two ways. Both require an API key with the dedicated credentials:evidence:read permission — granted independently of credentials:read so compliance reviewers can be given read-only evidence keys without seeing credential rows.

Metadata

curl https://api.beltic.com/v1/evidence/ev_01HQABCDEFGH \
  -H "X-Api-Key: $BELTIC_API_KEY"
Returns the same EvidenceResource shape as the upload response. The bytes are NOT included.

Bytes (presigned download URL)

curl https://api.beltic.com/v1/evidence/ev_01HQABCDEFGH/download \
  -H "X-Api-Key: $BELTIC_API_KEY"
Returns a short-lived presigned S3 URL the caller can GET directly without proxying through Beltic:
{
  "url": "https://beltic-prod-credentials-evidence.s3.amazonaws.com/org_abc/ev_01HQABCDEFGH.pdf?X-Amz-Algorithm=...",
  "expires_at": "2026-05-25T19:51:00.000Z",
  "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "content_type": "application/pdf",
  "size_bytes": 134217
}
The URL is the bearer token for the bytes the moment it’s issued — anyone who obtains it (logs, screenshots, browser history) can pull the document for the remainder of the TTL. Default TTL: 60 seconds. Long enough for a click → fetch cycle; short enough that screenshotted URLs are dead by the time anyone could exploit them. Pass expires_in_seconds (between 60 and 300) to extend up to the 5-minute hard cap when downloading over slow networks:
curl "https://api.beltic.com/v1/evidence/ev_01HQABCDEFGH/download?expires_in_seconds=300" \
  -H "X-Api-Key: $BELTIC_API_KEY"
Every URL generation is audit-logged with the requesting API key id, so a leak post-mortem can attribute every reveal to a specific consumer.
Why so short? Browsers don’t redact URLs from history; logs frequently capture them; chat apps render them as click-through links. A 60-second TTL means a URL pasted to Slack is essentially dead before anyone could click it. Callers who need bytes for longer can simply re-request — the second call is also audit-logged.
After fetching the bytes the caller can recompute SHA-256 and compare against the sha256 field (and against the digestSRI on any credential that references this evidence) to confirm the document hasn’t been tampered with in transit.

Retention

Evidence bytes are retained for 7 years from upload (US FinCEN BSA + EU AML5D minimum for KYC document retention). The S3 lifecycle transitions objects to Intelligent-Tiering after 30 days so cold evidence costs near-zero. Storage retention is independent of credential lifecycle — revoking a credential does not delete its evidence. For GDPR Article 17 erasure requests, an admin path (not yet exposed publicly) can hard-delete evidence rows + their S3 objects on demand.

Self-Service Reveal for Credential Subjects

The credential subject (the person the credential is about) often isn’t a Beltic-org user with their own API key. To let them view their credential plus its evidence without minting a per-user key, the org can email them a one-time access link:
curl -X POST https://api.beltic.com/v1/credentials/cred_abc123/reveal-link \
  -H "X-Api-Key: $BELTIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "alivia@example.com",
    "first_name": "Alivia"
  }'
Beltic sends the email from its own domain via SES (noreply@beltic.com by default). The recipient clicks the link, which lands on a Beltic-hosted page at portal.beltic.com/r?token=<raw>&credential_id=<id>. The page hands the token to POST /v1/credentials/_reveal/authenticate, which:
  1. Hashes the raw token server-side and looks up the row.
  2. Atomically marks it redeemed (one-time use — second clicks return 410 Gone).
  3. Mints a Beltic-issued credential-scoped JWT.
The raw token is never stored on the Beltic side — only its SHA-256 hash lives in the reveal_tokens Postgres table. A database leak yields hashes the attacker can’t pre-image back to working URLs. The session JWT is scoped to a single credential — server-side. The Beltic-issued token (HS256, signed with REVEAL_TOKEN_SECRET) carries the credential_id it’s bound to in its claims. Every handler that reads credential or evidence data refuses requests where the requested resource doesn’t match the bound scope. This is the security difference from any unscoped session token. If a token-holder tries to fetch a sibling credential or unrelated evidence:
{ "error": { "code": "forbidden", "message": "Reveal token is scoped to a different credential…" } }
Audit: every reveal-link mint fires a credential.reveal_link_sent event keyed to the org’s API key. Redemptions and per-byte downloads emit their own events so the org sees the full lifecycle. TTL: the email link expires 10 minutes after reveal-link is called (one-time use). The Beltic-issued reveal JWT lives for 15 minutes after redemption (hard cap 60 minutes). If the recipient misses either window, request a new link. Email customization: Beltic renders the email body in code (packages/credentials-core/src/services/reveal-link-service.ts). The current template carries Beltic branding; per-org template customization (logo / from-name / copy) is roadmap once the first customer asks. From-address is configurable per-environment via the REVEAL_EMAIL_FROM env var (must be a verified SES identity).

See Also