API documentation
Every image you send through SignUnder comes back with signed Content Credentials, an invisible watermark bound to them, and a fingerprint on record, so it can be recognised as yours even after a platform strips the metadata. You can call the API directly, or point your fal or Replicate calls at it and change nothing else.
Getting started
Sign in to the dashboard with your work email, name your company, and copy the API key it shows you: it is shown once. Send it as a bearer token: Authorization: Bearer su_test_…. In proxy mode, where Authorization carries your provider’s key, send ours in X-SignUnder-Key instead.
Early access runs on our staging service at https://signunder-staging-api.fly.dev, in Frankfurt. Keys start su_test_. Images are signed with a SignUnder test certificate until your company is listed with the C2PA, so Content Credentials viewers show the signer as unrecognised; the signature itself is valid. The first call after a quiet spell can take up to half a minute while the service wakes.
The service is open source under Apache-2.0: the API, the signer, the watermark worker and the rule packs are on GitHub, with a Docker Compose stack that runs it locally on a development key.
Python SDK
The signunder package wraps the calls below for Python 3.10 and later, with an AsyncSignunder for asyncio. It works out the image type from the file, and every refusal raises SignunderError with the API’s message.
pip install signunder
export SIGNUNDER_API_KEY=su_test_…
from signunder import Signunder
with Signunder() as client:
signed = client.sign("picture.png") # a path, bytes or an open file
signed.save("picture.signed.png")
print(signed.record_id)
result = client.verify("downloaded.png")
print(result.manifest_found, result.signed_by, result.altered)
record = client.get_record(signed.record_id) # None if it is not yours.NET SDK
The Signunder package does the same for .NET 8 and later, and works in trimmed and native AOT apps. Every refusal throws SignunderException with the status and the API’s message.
dotnet add package Signunder
export SIGNUNDER_API_KEY=su_test_…
using Signunder.Sdk;
var client = SignunderClient.FromEnvironment();
var signed = await client.SignAsync("picture.png"); // the type is read from the file
await File.WriteAllBytesAsync("picture.signed.png", signed.Content);
Console.WriteLine(signed.RecordId);
var result = await client.VerifyAsync("downloaded.png");
Console.WriteLine($"{result.ManifestFound} {result.SignedBy} {result.Altered}");
var record = await client.GetRecordAsync(signed.RecordId); // null if it is not yours
// ASP.NET Core: register once, then inject SignunderClient
builder.Services.AddSignunderClient(o => o.ApiKey = builder.Configuration["Signunder:ApiKey"]);Sign an image
POST /v1/sign takes one JPEG, PNG or WebP image as multipart/form-data in a field called file, with its type set, and returns the signed image in the same format. Treat it as AI-generated output: the signature records it as created by a generative model.
curl https://signunder-staging-api.fly.dev/v1/sign \ -H "Authorization: Bearer $SIGNUNDER_KEY" \ -F "file=@picture.png;type=image/png" \ -o picture.signed.png \ -D -
The record id comes back in a header. Keep it if you want to link to one image on your verify page.
HTTP/2 200 content-type: image/png signunder-record-id: 01JC0EXAMPLE00000000000000 signunder-manifest-hash: 3f1c…
Before signing, an invisible watermark is embedded in the pixels and bound into the signature, and the image is fingerprinted. The signed output is checked before it is returned; anything that cannot be signed fails the call rather than coming back unsigned. We do not keep the image, only its record.
Verify an image
POST /v1/verify reads the Content Credentials in any image, signed by you or anyone else, and says whether they still hold.
curl https://signunder-staging-api.fly.dev/v1/verify \ -H "Authorization: Bearer $SIGNUNDER_KEY" \ -F "file=@picture.signed.png;type=image/png"
{
"manifest_found": true,
"validation_state": "valid",
"manifest_label": "urn:c2pa:…",
"signed_by": "SignUnder TEST - NOT CONFORMANT",
"cert_subject": { "CN": "…", "O": "…" },
"trust_status": "untrusted",
"issued_at": "2026-10-16T06:52:31+00:00",
"actions": ["c2pa.created", "c2pa.watermarked.bound"],
"altered": false,
"failures": [],
"report": { … }
}altered is true when the file has changed since it was signed. validation_state is trusted only when the signer is on the C2PA trust list. An image with no Content Credentials answers {"manifest_found": false}; to find your own images after the metadata is gone, use your public verify page, which also checks the watermark and fingerprint.
Look up a record
GET /v1/records/{id} returns everything we hold about one of your outputs: when it was signed, its SHA-256, the manifest hash, the actions, which marks it carries, its watermark number and fingerprint, and the full validation report. Another company’s record answers 404.
curl https://signunder-staging-api.fly.dev/v1/records/01JC0EXAMPLE00000000000000 \ -H "Authorization: Bearer $SIGNUNDER_KEY"
Proxy mode: fal and Replicate
Point your provider calls at SignUnder instead and add your SignUnder key. Your provider key passes through untouched (we never store it), the provider generates and bills as usual, and every image in the finished result is signed before it reaches you. Its URL is replaced with one we serve, at /media/{record id}, for 30 days.
fal: replace https://queue.fal.run/ with https://signunder-staging-api.fly.dev/proxy/fal/queue/. Submit as usual, then poll the status and fetch the result through the URLs in the response, which already point back at SignUnder.
curl https://signunder-staging-api.fly.dev/proxy/fal/queue/fal-ai/flux/schnell \
-H "Authorization: Key $FAL_KEY" \
-H "X-SignUnder-Key: $SIGNUNDER_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "a lighthouse at dawn, watercolour"}'curl https://signunder-staging-api.fly.dev/proxy/fal/queue/fal-ai/flux/requests/$REQUEST_ID \ -H "Authorization: Key $FAL_KEY" \ -H "X-SignUnder-Key: $SIGNUNDER_KEY"
Replicate: replace https://api.replicate.com/ with https://signunder-staging-api.fly.dev/proxy/replicate/. Creating, fetching and cancelling predictions are supported, by model or by version.
curl https://signunder-staging-api.fly.dev/proxy/replicate/v1/models/black-forest-labs/flux-schnell/predictions \
-H "Authorization: Bearer $REPLICATE_API_TOKEN" \
-H "X-SignUnder-Key: $SIGNUNDER_KEY" \
-H "Prefer: wait" \
-H "Content-Type: application/json" \
-d '{"input": {"prompt": "a lighthouse at dawn, watercolour"}}'Each result is signed once; fetching it again returns the same signed output. Outputs that are not images pass through unchanged.
Your public verify page
Anyone can check an image against your outputs at signunder.com/verify?t=your-company, using the company slug from your dashboard. Add &id= and a record id to show one image. The page looks for your Content Credentials, then the watermark, then the fingerprint, so it still recognises an image that has been re-saved, resized or lightly cropped. It answers only whether the image is one of yours, never whether some other image is AI-made. The same check is open to your own systems, with no key:
curl https://signunder-staging-api.fly.dev/public/v1/your-company/verify \ -F "file=@picture.png;type=image/png"
Your name in signatures
Under Your name in signatures in the dashboard, enter your company’s domain and publish the TXT record it shows, then press Check the record. Once the record is found we check your company name, usually within one working day. From then on every image you sign carries a CAWG identity assertion naming you as its publisher, and your verify page and public records say “Published by Acme Ltd (acme.com, verified by SignUnder)”. In the public record the same appears as published_by, with name, domain and verified_by. The signature itself stays SignUnder’s until you sign under your own certificate; the guide explains the difference and what viewers show.
Errors and limits
Errors are problem details with a readable detail:
{
"title": "Too Many Requests",
"status": 429,
"detail": "This month's allowance of 2,000 signed outputs on the free plan is used up. It resets on 1 November 2026 (UTC)."
}401: no key, or a key that is unknown or revoked.403: the key lacks the scope for this call.400and415: the file is empty, unreadable, or not JPEG, PNG or WebP.429: more than 300 requests a minute on one key (wait for theRetry-Afterseconds), or the month’s allowance is used up: 2,000 signed images a calendar month on the free plan, counted in UTC and shown on your dashboard. In proxy mode a new generation is refused before it reaches your provider, so you are not billed for an image we would not sign.502: something failed on our side. Thedetailgives a reference to quote to us at hello@signunder.com.- Uploads are limited to 30 MB; images fetched from a provider in proxy mode to 32 MB.