Newsroom sidecar chain
A wire photo passes through three stages: a camera-side daemon, an editor's workstation, and the publishing CMS. Each stage signs and writes a .c2pa sidecar next to the asset on disk. To chain back to the previous stage, the next stage embeds the prior sidecar into a transient in-memory copy and passes that copy as an ingredient to the next sign() call. Only the final publishing stage writes the manifest into the asset itself.
The sidecar is the on-disk format. embed() is the in-memory transport the SDK uses to read the prior chain.
Capture
import { SignerClient, AssetSdk } from "@limboai/verifox-sdk-issuer";
import { readFile, writeFile } from "node:fs/promises";
const client = await SignerClient.connect("https://api.trylimbo.com", {
headers: { authorization: `Bearer ${process.env.LIMBO_API_KEY}` },
});
const sdk = AssetSdk.create(client);
export async function onShutter(jpgPath: string) {
const jpg = await readFile(jpgPath);
const { sidecar } = await sdk.sign(jpg, "capture", {
origin: { sourceType: "digitalCapture" },
identity: {
roles: ["cawg.creator"],
identities: [
{
type: "cawg.social_media",
name: "Author",
username: "author",
uri: "https://example.com/cawg",
verifiedAt: "2026-01-15T00:00:00Z",
provider: { id: "https://example.com", name: "Example" },
},
],
},
}, { storage: { mode: "embedded" } });
await writeFile(`${jpgPath}.c2pa`, sidecar);
}
Edit
The editor reads capture.jpg and capture.jpg.c2pa, exports a retouched JPEG, and signs it with the capture as a parentOf ingredient. The capture's manifest is injected into an in-memory copy so the SDK can read it.
import { SignerClient, AssetSdk, embed } from "@limboai/verifox-sdk-issuer";
import { readFile, writeFile } from "node:fs/promises";
const client = await SignerClient.connect("https://api.trylimbo.com", {
headers: { authorization: `Bearer ${process.env.LIMBO_API_KEY}` },
});
const sdk = AssetSdk.create(client);
export async function onExport(capturePath: string, editedPath: string) {
const capture = await readFile(capturePath);
const captureSidecar = await readFile(`${capturePath}.c2pa`);
const captureSigned = embed(capture, captureSidecar);
const edited = await readFile(editedPath);
const { sidecar } = await sdk.sign(edited, "retouch", {
origin: { parent: { data: captureSigned } },
actions: [{ kind: "edited" }],
identity: {
roles: ["cawg.editor"],
identities: [
{
type: "cawg.social_media",
name: "Editor",
username: "editor",
uri: "https://example.com/cawg",
verifiedAt: "2026-01-15T00:00:00Z",
provider: { id: "https://example.com", name: "Example" },
},
],
},
}, { storage: { mode: "embedded" } });
await writeFile(`${editedPath}.c2pa`, sidecar);
}
captureSigned exists in memory only. The on-disk capture.jpg remains untouched, so the editor's tools never encounter an embedded manifest.
Publish
The CMS receives edited.jpg and edited.jpg.c2pa. The pattern is the same: rebuild the parent in memory, sign with the publication identity, and embed the final manifest into the file that ships.
import { SignerClient, AssetSdk, embed } from "@limboai/verifox-sdk-issuer";
import { readFile, writeFile } from "node:fs/promises";
const client = await SignerClient.connect("https://api.trylimbo.com", {
headers: { authorization: `Bearer ${process.env.LIMBO_API_KEY}` },
});
const sdk = AssetSdk.create(client);
export async function release(editedPath: string, outPath: string) {
const edited = await readFile(editedPath);
const editedSidecar = await readFile(`${editedPath}.c2pa`);
const editedSigned = embed(edited, editedSidecar);
const { sidecar } = await sdk.sign(edited, "release", {
origin: { parent: { data: editedSigned } },
actions: [{ kind: "published" }],
identity: {
roles: ["cawg.publisher"],
identities: [
{
type: "cawg.social_media",
name: "Publisher",
username: "publisher",
uri: "https://example.com/cawg",
verifiedAt: "2026-01-15T00:00:00Z",
provider: { id: "https://example.com", name: "Example" },
},
],
},
}, { storage: { mode: "embedded" } });
await writeFile(outPath, embed(edited, sidecar));
}
This is the only embed() call whose output is written to disk. Every prior stage remained as a sidecar.
Why sidecars
This pattern follows from how C2PA 2.3 §15 defines the claim binding: the signed hash covers a specific byte layout of the asset. Any tool that re-encodes pixels, rewrites EXIF, or re-muxes a video invalidates the manifest, because the hash no longer matches the bytes on disk.
Newsroom editing tools (Photoshop, Premiere, the CMS image pipeline) rewrite the file on save, so embedding the manifest at capture or edit time would break the signature on each downstream save. Leaving the on-disk asset untouched at every intermediate stage avoids that. Only the final publish step writes embedded bytes to disk, since that is the artifact viewers fetch.
The C2PA spec calls the result a provenance chain: each manifest names its parent via the c2pa.ingredient.v3 assertion, the verifier walks the chain backward, and the signed claim at each stage proves that stage's identity. The chain survives format conversions (JPEG to JPEG, raw to JPEG) because the ingredient is referenced by its own signed hash, not by a file path.
embed() supports JPEG, PNG, TIFF, MP4, MOV, PDF, WAV, MP3, and the other formats listed under Supported formats. Proprietary raw formats (some camera CR3 variants, for example) cannot be embedded into directly. Convert to JPEG or DNG at capture time if the chain must reach back to the camera.