Guides
Receipts
A receipt is a signed statement of what a job processed and when its files were deleted, which you can verify and keep for your records.
What a receipt is
When a file passes through a job, you may need evidence of what happened to it: for an audit, for a customer who asks, or for your own data-processing records. A receipt is that evidence. It states which file went in, which file came out, and when each was deleted from our storage, and it is signed with an Ed25519 key so that nobody can alter it afterwards without the change being detectable.
Receipts exist for jobs. A direct request such as POST /v1/compress stores nothing, so it has no deletion to record and no receipt.
Get a receipt
curl "https://api.smolmac.com/v1/jobs/job_8Hq2LmZx4TnV0cRb7KpW/receipt" \
-H "Authorization: Bearer $SMOL_API_KEY"{
"object": "receipt",
"payload": "{\"version\":1,\"job_id\":\"job_8Hq2LmZx4TnV0cRb7KpW\",\"operation\":\"compress\",\"status\":\"succeeded\", ... }",
"signature": "<base64 Ed25519 signature>",
"algorithm": "Ed25519",
"key_id": "<id of the signing key>"
}| Field | Type | Description |
|---|---|---|
| payload | string | The statement, as a JSON document inside a string. The signature covers this string exactly as it is, so verify it first and parse it second. |
| signature | string or null | The Ed25519 signature of the UTF-8 bytes of payload, base64 encoded. null if the receipt could not be signed. |
| algorithm | string | Always Ed25519. |
| key_id | string or null | Identifies the key that signed the receipt, so you know which public key to verify with. |
- A receipt is available once the job is final: succeeded, failed or canceled. Before that the endpoint answers
409 job_not_finished. - A receipt is produced when you ask for it and describes the job at that moment. Each call returns a new receipt with a new
issued_at. - Receipts can be requested for 30 days after the job finished. After that the job record is gone and the endpoint answers
404 not_found. Store the receipts you need to keep. - Like the job itself, a receipt is only visible to keys of the same account and the same mode.
The payload
Parsed, the payload of the job from Large files and jobs looks like this, requested after its output had expired:
{
"version": 1,
"job_id": "job_8Hq2LmZx4TnV0cRb7KpW",
"operation": "compress",
"status": "succeeded",
"standard": "v1",
"input_sha256": "<SHA-256 of the input file, hex>",
"input_size": 64182301,
"output_sha256": "<SHA-256 of the output file, hex>",
"output_size": 9627345,
"created_at": "2026-10-01T09:14:31.120Z",
"completed_at": "2026-10-01T09:15:12.870Z",
"input_deleted_at": "2026-10-01T09:15:12.871Z",
"output_deleted_at": "2026-10-01T10:15:12.902Z",
"output_delivered_to_customer_storage": false,
"issued_at": "2026-10-01T11:02:44.310Z"
}| Field | Meaning |
|---|---|
version | The version of the receipt format. Currently 1. |
job_id | The job the receipt is about. |
operation | compress, convert or strip_metadata. |
status | The final status of the job. |
standard | The version of the compression standard the job ran under. |
input_sha256, input_size | The SHA-256 digest, in hex, and the size in bytes of the file that was processed. The digest is null if the job did not succeed. |
output_sha256, output_size | The same for the file that was produced. Both are null if the job did not succeed. |
created_at, completed_at | When the job was created and when it reached its final status. |
input_deleted_at | When the input was deleted from our storage. |
output_deleted_at | When the output was deleted from our storage. Null while the output is still stored, and null when it was never stored. |
output_delivered_to_customer_storage | True when the job had an output.url. The output of such a job is never stored by us: if the job succeeded, it was sent to your storage. |
issued_at | When this receipt was produced. |
The public key
Receipts are signed with a private key that only the API holds. Anyone can check a signature with the matching public key, which is not secret.
The public keys are published at GET /v1/receipt_keys, which needs no API key. Match the receipt’s key_id to an entry and use its public_key. A key that has been replaced stays in the list with status: "retired", so older receipts remain checkable.
curl https://api.smolmac.com/v1/receipt_keys{
"object": "list",
"data": [
{
"key_id": "smol-receipt-2026-10",
"algorithm": "Ed25519",
"format": "spki-base64",
"public_key": "MCowBQYDK2VwAyEA…",
"status": "active",
"retired_at": null
}
],
"has_more": false
}The key is an Ed25519 public key in SubjectPublicKeyInfo (SPKI) form, DER encoded, then base64 encoded. Copy it into your own configuration rather than fetching it at verification time: a key you fetch from the same place as the receipt adds nothing to the check.
Verify the signature
Verification needs no Smol code and no network access: the payload string, the signature and the public key are enough. This script fetches a receipt, verifies it with the Node.js standard library, and compares the digests with your own copies of the files.
import crypto from "node:crypto";
import { readFile } from "node:fs/promises";
// The public_key from GET /v1/receipt_keys whose key_id matches the receipt: base64 of the SPKI DER encoding.
const PUBLIC_KEY_BASE64 = process.env.SMOL_RECEIPT_PUBLIC_KEY;
const jobId = process.argv[2];
const response = await fetch(`https://api.smolmac.com/v1/jobs/${jobId}/receipt`, {
headers: { Authorization: `Bearer ${process.env.SMOL_API_KEY}` },
});
if (!response.ok) throw new Error((await response.json()).error.message);
const receipt = await response.json();
if (receipt.algorithm !== "Ed25519" || !receipt.signature) {
throw new Error("The receipt is not signed");
}
// 1. Verify the signature over the payload string, byte for byte.
const publicKey = crypto.createPublicKey({
key: Buffer.from(PUBLIC_KEY_BASE64, "base64"),
format: "der",
type: "spki",
});
const signatureIsValid = crypto.verify(
null, // Ed25519 takes no separate digest algorithm
Buffer.from(receipt.payload, "utf8"),
publicKey,
Buffer.from(receipt.signature, "base64"),
);
if (!signatureIsValid) throw new Error("The receipt signature is not valid");
// 2. Only now parse the payload.
const statement = JSON.parse(receipt.payload);
if (statement.job_id !== jobId) throw new Error("The receipt is for a different job");
// 3. Compare the digests with the files you hold.
const sha256 = async (path) => crypto.createHash("sha256").update(await readFile(path)).digest("hex");
console.log("input matches: ", (await sha256("report.pdf")) === statement.input_sha256);
console.log("output matches:", (await sha256("report.min.pdf")) === statement.output_sha256);
console.log("input deleted: ", statement.input_deleted_at);
console.log("output deleted:", statement.output_deleted_at);SMOL_RECEIPT_PUBLIC_KEY="<public_key from /v1/receipt_keys>" node verify-receipt.mjs job_8Hq2LmZx4TnV0cRb7KpWThree details matter:
- Verify the
payloadstring exactly as it arrived. If you parse it and serialise it again, the key order or the spacing can change, and the signature will no longer match. - When you store a receipt, store the
payloadstring, thesignatureand thekey_idtogether. That is everything needed to verify it again later. - Check that
key_idis the id of the key you verified with.
What a receipt proves
A receipt with a valid signature establishes these things:
- The statement was issued by the holder of the Smol receipt key, and has not been changed since.
- The statement is about specific files. If the SHA-256 of your copy matches
input_sha256oroutput_sha256, the receipt refers to exactly those bytes. - Smol states that the input and the output were deleted from its storage at the times given, or that the output was delivered to your storage and never stored.
It does not establish these things:
- It is not independent proof of deletion. A receipt is our signed statement about our own systems. The signature ties the statement to us and makes any later change to it detectable. It cannot show that no copy exists. How deletion works is described in How files are handled.
- It says nothing about copies outside our storage. The file at your
input.url, the output you downloaded, and anyone you gave the download link to are outside its scope. - It does not describe the content. A receipt holds digests, sizes and times. It does not say what the file showed or whether the result is good.
- It does not cover direct requests. Only jobs have receipts.