Create an API key
Create a key in the dashboard. Signing requires the key. Public verification does not.
Content provenance for production systems
Start with eleven core operations. Open the management, advanced, or program reference only when your integration needs it.
Start here
Create a key in the dashboard. Signing requires the key. Public verification does not.
Use POST /sign for text, POST /sign/media for one file or a composition with many ingredients, and POST /sign/rich for a mixed article.
Sign completed text over SSE, mark incremental segments, or call the OpenAI-compatible signing proxy.
Quickstart
Sign plain text, then pass the returned signed_text to the unauthenticated verifier.
export ENCYPHER_API_KEY="<your-api-key>"
SIGNED=$(curl -sS https://api.encypher.com/api/v1/sign \
-H "Authorization: Bearer $ENCYPHER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"This document carries portable provenance.","document_title":"First signed document"}')
curl -sS https://api.encypher.com/api/v1/public/verify \
-H "Content-Type: application/json" \
-d "$(printf '%s' "$SIGNED" | jq '{text:.data.document.signed_text}')"
import os
import requests
base_url = "https://api.encypher.com/api/v1"
headers = {"Authorization": f"Bearer {os.environ['ENCYPHER_API_KEY']}"}
sign = requests.post(
f"{base_url}/sign",
headers=headers,
json={
"text": "This document carries portable provenance.",
"document_title": "First signed document",
},
timeout=30,
)
sign.raise_for_status()
signed_text = sign.json()["data"]["document"]["signed_text"]
verify = requests.post(
f"{base_url}/public/verify",
json={"text": signed_text},
timeout=30,
)
verify.raise_for_status()
print(verify.json())
const baseUrl = "https://api.encypher.com/api/v1";
const signResponse = await fetch(`${baseUrl}/sign`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ENCYPHER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
text: "This document carries portable provenance.",
document_title: "First signed document",
}),
});
if (!signResponse.ok) throw new Error(await signResponse.text());
const signed = await signResponse.json();
const verifyResponse = await fetch(`${baseUrl}/public/verify`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({text: signed.data.document.signed_text}),
});
if (!verifyResponse.ok) throw new Error(await verifyResponse.text());
console.log(await verifyResponse.json());
Store signed_text exactly as returned. Normalizing or retyping it can remove the invisible provenance payload.
Multi-ingredient media
Send the finished composition once as file. Repeat the multipart ingredients field for every source clip, image, or audio file. Each source becomes a separate c2pa.ingredient.v3 assertion in the final Content Credential.
curl -X POST https://api.encypher.com/api/v1/sign/media -H "Authorization: Bearer $ENCYPHER_API_KEY" -H "Idempotency-Key: campaign-cut-001" -F "[email protected];type=video/mp4" -F "title=Campaign cut" -F "[email protected];type=video/mp4" -F "[email protected];type=video/mp4" -F "[email protected];type=image/png"
import mimetypes
import os
from contextlib import ExitStack
from pathlib import Path
import requests
sources = [Path(f"clip-{n:02}.mp4") for n in range(1, 20)]
sources.append(Path("nanobanana-background.png"))
with ExitStack() as stack:
final = Path("final.mp4")
files = [
("file", (final.name, stack.enter_context(final.open("rb")), "video/mp4")),
]
for source in sources:
mime = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
files.append(
("ingredients", (source.name, stack.enter_context(source.open("rb")), mime))
)
response = requests.post(
"https://api.encypher.com/api/v1/sign/media",
headers={
"Authorization": f"Bearer {os.environ['ENCYPHER_API_KEY']}",
"Idempotency-Key": "campaign-cut-001",
},
data={"title": "Campaign cut"},
files=files,
timeout=300,
)
response.raise_for_status()
result = response.json()
assert result["data"]["ingredient_count"] == len(sources)
const form = new FormData();
form.append("file", finalMp4File, finalMp4File.name);
form.append("title", "Campaign cut");
for (const source of sourceFiles) {
form.append("ingredients", source, source.name);
}
const response = await fetch("https://api.encypher.com/api/v1/sign/media", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Idempotency-Key": "campaign-cut-001",
},
body: form,
});
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
if (result.data.ingredient_count !== sourceFiles.length) {
throw new Error("Not every source was recorded");
}
ingredients means componentsRepeat this field. Order is preserved. Every file gets relationship componentOf, and its filename becomes the ingredient title. Do not send an array, comma-separated list, or ZIP archive.
ingredient means one parentThe singular field is only for one edited predecessor with relationship parentOf. It is not the way to send composition sources.
A signed source keeps its existing C2PA provenance chain. An unsigned source is still recorded, with unknown prior provenance rather than invented history.
The composition contract accepts up to 50 effective ingredients, and a parent counts toward the 50. The public limit is the whole multipart request body -- target, every ingredient, text fields, and multipart framing together -- at or below 100,000,000 bytes (100 MB). Cloudflare measures a 100 MiB edge ceiling in front of the API, and this application cap sits deliberately below it so an over-limit request earns a JSON 413 rather than an edge rejection. Check data.ingredient_count in the response. A mismatch means the final credential does not describe the composition you intended.
For a supported asset or composition at or above 100 MB, call GET /sign/media/hash/capabilities first and use the POST /sign/media/prepare and POST /sign/media/finalize local-hash workflow only when the response reports enabled: true for your organization. The capabilities response, not a static size claim on this page, is authoritative. Prepared mode keeps media payload bytes local and accepts ordered parent and component ingredient descriptors, AI-output ancestry, preserve_source_rights, and use_rights_profile. Ingredient descriptors contain only bounded container structure, size, MIME type, relationship, title, and a SHA-256 digest.
A request rejected above Cloudflare's 100 MiB edge ceiling is answered by the edge, not this API: that response may be HTML and lack Encypher's error.code and correlation_id fields. Branch on the HTTP status and Content-Type before parsing JSON, and treat any 413 as a size failure even when the JSON envelope is absent.
Authentication
Send your Encypher API key on authenticated operations:
Authorization: Bearer <your-api-key>
POST /public/verify and POST /public/verify/media require no key. The OpenAI proxy also requires X-OpenAI-API-Key. Encypher forwards that key for the request and does not persist it.
Errors and limits
{
"success": false,
"error": {
"code": "E_RATE_SIGN",
"message": "Signing rate limit exceeded"
},
"correlation_id": "req-abc123"
}
Branch on error.code. Log correlation_id and the X-Request-ID response header. On 429, honor Retry-After. Rich signing accepts up to 20 images at 10 MB each, 10 audio files at 50 MB each, and 5 video files at 100 MB each.
State and retries
document_idrun_idsession_idstart, segment, finalize, and status calls.correlation_idAPI reference
Eleven operations for signing, verification, streaming, and OpenAI-compatible generation. Use the reference filter to search this surface.