API reference
Sellat API v2
Everything the web app does, from your own system: register a file’s SHA-256 fingerprint, anchor it on Polygon and Bitcoin, request the qualified (eIDAS, RFC 3161) timestamp, download the certificate and the evidence package, and keep the original in custody. REST and JSON, with an API key.
https://sellat.app/api/v2 Introduction
The API works with the file’s fingerprint, not the file: you compute the SHA-256 on your side and send only those 64 characters. The original never leaves your machine unless you choose to put it in custody.
Each proof goes into a Merkle batch anchored on Polygon, and the same batch is attested on Bitcoin. On the free account, batches close every 4 hours at fixed times (00:00, 04:00, 08:00, 12:00, 16:00 and 20:00 UTC): each batch gathers the proofs of those hours and is anchored in a single transaction. If you ask for it, the fingerprint also gets the qualified timestamp of a qualified trust service provider on the EU trusted list, at the moment you ask.
Every route hangs off the base URL and speaks JSON. Proofs you create through the API and on the web belong to the same account and show up in the same dashboard.
It is meant to be called from a server: it sends no CORS headers, so a browser cannot call it from another site, and your key must never be in code that reaches a browser.
Authentication
Every request carries your key in the Authorization: Bearer sellat_... header. Keys are created and revoked from your dashboard; an account can hold up to 5 active keys, one per integration.
Without a key, or with a revoked one, the API answers 401 unauthorized with a WWW-Authenticate: Bearer header. The only public route is the portable proof (/proof/{id}.json).
curl https://sellat.app/api/v2/account \
-H "Authorization: Bearer sellat_3f9c..." Quick start
Compute the fingerprint, create the proof with the qualified seal and keep the id you get back. When its batch closes and is anchored it becomes anchored and all its URLs work; the response’s anchoring field says when the batch closes (on the free account, at the next fixed UTC time, at most 4 hours later). The qualified seal does not wait for the batch: it carries the time it is issued.
Without "qualified": true the proof is just as verifiable (Polygon and Bitcoin) and spends no seal. You can request the seal later with POST /proofs/{id}/qualified.
# Your key, from the dashboard
export SELLAT_API_TOKEN=sellat_...
# The fingerprint: the file stays on your machine
HASH=$(sha256sum contract.pdf | cut -d' ' -f1)
# The proof, with the qualified seal
curl https://sellat.app/api/v2/proofs \
-H "Authorization: Bearer $SELLAT_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"hash\":\"$HASH\",\"name\":\"contract.pdf\",\"qualified\":true}" Proof states
The state field says what Sellat knows at each moment: it never reports a transaction as confirmed before it has checked it.
The Bitcoin attestation (bitcoin) runs separately: pending until OpenTimestamps includes it in a block, then confirmed with its height. The qualified seal (qualified) does not depend on the state: it is issued straight away.
| State | What it means |
|---|---|
queued | Received. Waiting for its batch to close: on the free account, at the next fixed UTC time, every 4 hours (anchoring.batch_closes_after). |
batched | In a Merkle batch, with its path fixed. Waiting for the Polygon anchor. |
anchored | Anchored: the transaction is in a Polygon block. proof.json is available. |
confirmed | The block has 12 confirmations. |
Errors
An error is always JSON with error.code (stable, for your code) and error.message (for people). Some add fields, such as seals_remaining and buy_url on a 402. The portable proof, being public, answers {"error": "..."}.
If the gateway cuts a request before it reaches the API (too many requests from one IP, or a body over 25 MB), that 429 or 413 does not use this format.
| Code | HTTP | What happened |
|---|---|---|
invalid_request | 400 | A field is missing or not in the expected format. The message says which. |
unauthorized | 401 | No key, a malformed key or a revoked one. |
payment_required | 402 | No qualified seals left. Carries seals_remaining and buy_url. |
key_without_account | 403 | The key is not attached to an account (old keys). |
not_found | 404 | The proof does not exist or belongs to another account. |
conflict | 409 | Another account registered these bytes first, or the original is not stored yet. |
hash_mismatch | 409 | The file you upload does not have the proof’s fingerprint. |
file_too_large | 413 | The file exceeds your plan’s per-file limit (max_file_bytes). |
rate_limited | 429 | Too many requests per minute. Honour Retry-After. |
quota_exceeded | 429 | Daily proof limit reached. Retry-After runs to 00:00 UTC. |
download_quota_exceeded | 429 | Daily limit of original downloads reached. |
integrity_failed | 500 | The stored original no longer matches its fingerprint, so it is not served. |
unavailable | 502 · 503 | An internal service is not answering. Retry later. |
seal_unavailable | 503 | The timestamping authority is not available right now. |
custody_unavailable | 503 | Custody is not available right now. |
custody_quota_exceeded | 507 | No custody space left on your account. |
{
"error": {
"code": "payment_required",
"message": "No qualified seals left on this account.",
"seals_remaining": 0,
"buy_url": "https://sellat.app/precios"
}
} Limits
Each account can create a number of API proofs per day, across all its keys. Proofs made on the web do not count, and neither does sending bytes you already own again. The day resets at 00:00 UTC.
At the limit, POST /proofs answers 429 quota_exceeded with a quota object and Retry-After. Each route also has a per-minute brake (for example 60 creations and 10 seals a minute) that answers 429 rate_limited, and the gateway accepts up to 120 requests a minute per IP. A request body can be 25 MB at most.
Qualified seals have no daily limit: each one spends a seal from your balance. Check what you have left at any time with GET /account, and if you need more volume, write to hola@sellat.app.
| Free account | With Custody | |
|---|---|---|
| API proofs per day | 1,000 | 1,000 |
| Polygon and Bitcoin anchoring | Every 4 hours, at fixed UTC times | Every 4 hours, at fixed UTC times |
| Active keys | 5 | 5 |
| Original in custody, per file | 10 MB | 50 MB |
| Total custody | 1 GB | 20 GB |
| Original downloads per day | 10 | 50 |
HTTP/1.1 429 Too Many Requests
Retry-After: 41231
{
"error": {
"code": "quota_exceeded",
"message": "Daily limit reached: 1000 proofs per account per UTC day across all its keys. Write to hola@sellat.app for more.",
"quota": {
"limit": 1000,
"used": 1000,
"resets_in_seconds": 41231
}
}
} Pay per call (x402, AI agents)
An AI agent with a wallet can use the API with no account and no key: it pays each call in USDC on the Base network with the x402 protocol (v2, exact scheme). Paying is the sign-up: the wallet that pays is the account.
Call without a key and without a payment: the answer is 402 with the PAYMENT-REQUIRED header (base64 JSON) stating the exact amount, token, network and receiving address. Sign an EIP-3009 transferWithAuthorization for that amount and repeat the request with the PAYMENT-SIGNATURE header. The payment is verified before the work and settled after it: if the call fails, nothing is charged. The answer carries PAYMENT-RESPONSE with the transaction, and a payment object in the body.
The first proof returns api_key once, a read-only key: it reads the wallet’s proofs and downloads the certificate and the evidence package. It does not create proofs, because nothing is free in this channel. The same bytes again from the same wallet are not charged. The price list is at GET /x402.
Paying accepts the terms of service: every 402 offer links them and states their version (in the error sentence of PAYMENT-REQUIRED and in the terms object of the body), and Sellat records that version with the payment.
| Call | Price | What you get |
|---|---|---|
POST /proofs | 0.01 USDC | A proof in the fast batch (minutes), with certificate, proof.json and evidence ZIP |
POST /proofs/{id}/qualified | 0.90 USDC | The eIDAS qualified timestamp on a proof of your wallet, in seconds |
POST /wallet/key | 0.01 USDC | A new read-only key for the wallet’s account |
Set the per-payment maximum to 1 USDC. Many x402 clients cap the amount of each payment: @x402/fetch and @x402/axios (v2) default to 1 USDC, others use whatever you set. The qualified timestamp costs 0.90 USDC: with a lower maximum, your client refuses the payment before signing it. The version 1 packages (x402-fetch, x402-axios, 0.10 USDC by default) do not work: Sellat speaks x402 v2.
Telling failures apart. A 402 with payment_required is sent by Sellat when the request arrives without PAYMENT-SIGNATURE: it is the offer, not an error. payment_invalid means a signature arrived but is not valid (amount, network, balance or an authorization already used; the message says which), and payment_failed that the work was done but the payment did not settle. If your client refuses the amount because of its cap, the paid request never reaches Sellat: there is no answer from us, only an exception in your client ("rejected by spendControls.maxAmountPerPayment" in @x402/fetch v2, "Payment amount exceeds maximum allowed" in thirdweb).
// npm install @x402/fetch @x402/evm viem
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";
const client = new x402Client();
// Per-payment cap. The qualified timestamp costs 0.90 USD: a cap below
// that makes the client refuse to sign, and Sellat never sees the request.
// "$1" is the v2 default; set it explicitly so nobody lowers it by accident.
client.setSpendControls({ maxAmountPerPayment: "$1" });
client.register("eip155:8453", new ExactEvmScheme(privateKeyToAccount(process.env.AGENT_KEY as `0x${string}`)));
const pay = wrapFetchWithPayment(fetch, client);
// 1. Hash locally: only the SHA-256 leaves your machine.
const hash = createHash("sha256").update(readFileSync("contract.pdf")).digest("hex");
// 2. The proof (0.01 USD).
const proofRes = await pay("https://sellat.app/api/v2/proofs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hash, name: "contract.pdf" }),
});
if (!proofRes.ok) throw new Error(await proofRes.text());
const proof = await proofRes.json();
// proof.id, proof.state ("queued", then "anchored"), proof.urls.proof_json,
// proof.payment.transaction; proof.api_key comes once: keep it (read-only).
// 3. The qualified eIDAS timestamp on that proof (0.90 USD).
const sealRes = await pay(`https://sellat.app/api/v2/proofs/${proof.id}/qualified`, { method: "POST" });
if (!sealRes.ok) throw new Error(await sealRes.text());
const seal = await sealRes.json();
// seal.qualified.state === "issued", seal.qualified.time, seal.qualified.tsr_url
// thirdweb sets no cap unless asked: wrapFetchWithPayment(fetch, client, wallet, { maxValue: 1_000_000n }) from "thirdweb/x402" (1 USDC in base units). Idempotency
Send an Idempotency-Key header (up to 200 characters) with POST /proofs and you can retry safely: if you already used it with the same API key, you get the original proof back with 200 and "created": false, whatever the new body says, and no seal is issued.
Without the header, sending bytes your account already owns also returns the existing proof (200), without changing its name or metadata. But if another account registered those bytes first, every request creates a new proof with limitations: if you might retry, always use Idempotency-Key.
Seals are never duplicated either: a proof has one seal, and asking again returns the same one without spending another.
Verify without Sellat
A Sellat proof does not need Sellat to be checked. With the file and its proof.json, the open-source verifier recomputes the fingerprint and the Merkle path and reads the anchor on the public blockchain.
The qualified seal is checked with OpenSSL, using the file, the .tsr and the authority’s certificate, which comes inside the evidence package. -partial_chain is needed because the trusted list publishes the authority’s own certificate, not a CA’s.
# 1. The proof: file + proof.json + the public blockchain
curl https://sellat.app/api/v2/proof/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e.json -o proof.json
npx sellat-verify contract.pdf proof.json
# 2. The qualified seal: file + .tsr + the authority's certificate
# (autoridad-sellado.pem comes inside evidence.zip)
openssl ts -verify -in seal.tsr -data contract.pdf \
-CAfile autoridad-sellado.pem -partial_chain Proofs
Create a proof
POST /proofs
Registers a file’s SHA-256 fingerprint and returns the proof. With "qualified": true it also issues the qualified (eIDAS) seal in the same call, spending one seal from the account.
Parameters
-
hashrequired - The file’s SHA-256 fingerprint: 64 hexadecimal characters.
-
nameoptional - What the dashboard and the certificate call it. Only the file name is kept, not the path. Defaults to the first 12 characters of the hash.
-
metadataoptional - A JSON object of yours, up to 4 KB. Stored with the proof, never returned or published.
-
sizeoptional - File size in bytes. Informational.
-
mime_typeoptional - The file’s type, used when serving the original if you keep it in custody. Defaults to
application/octet-stream. -
qualifiedoptional -
truealso issues the qualified (eIDAS) seal. -
Idempotency-Keyoptional - Retry without duplicating (see Idempotency).
Responses
- 201
- Proof created.
- 200
- It already existed: these bytes are already your account’s, or you repeated an
Idempotency-Key. Carries"created": false. - 400
invalid_request: a field is missing or invalid.- 401
unauthorized.- 402
payment_required: you asked for the seal and have none left. Nothing is written.- 409
conflict: you asked for the seal on bytes another account registered first.- 429
quota_exceeded(daily limit) orrate_limited(per minute).- 503
seal_unavailableorunavailable. If the proof was created, the error carries itsproof_id.
If the authority is slow to answer, the proof is still created and qualified comes back as {"state": "pending"}: ask again with POST /proofs/{id}/qualified, at no cost.
If another account registered those bytes first, the proof is created and anchored but has no certificate of its own: the certificate URLs are null and the response carries "limitations": ["hash_registered_by_another_account"].
curl https://sellat.app/api/v2/proofs \
-H "Authorization: Bearer $SELLAT_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: contract-2026-0142" \
-d '{
"hash": "9f3c2a5e0b7d1c4f8a6e2d9b3c7f1a5e8d2c6b0f4a9e3d7c1b5f8a2e6d0c4b9f",
"name": "contract.pdf",
"qualified": true,
"metadata": { "ref": "case-2026-0142" }
}' {
"id": "6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e",
"created": true,
"hash": "9f3c2a5e0b7d1c4f8a6e2d9b3c7f1a5e8d2c6b0f4a9e3d7c1b5f8a2e6d0c4b9f",
"algorithm": "SHA-256",
"name": "contract.pdf",
"state": "queued",
"received_at": "2026-09-30T09:12:03.418Z",
"anchoring": {
"policy": "scheduled",
"batch_closes_after": "2026-09-30T12:00:00.000Z"
},
"anchors": [],
"bitcoin": {
"state": "pending",
"block_height": null
},
"qualified": {
"state": "issued",
"authority": "FNMT-RCM",
"time": "2026-09-30T09:12:05.000Z",
"serial_number": "175755C77B4239226AA0428FE1C107C1",
"policy_oid": "0.4.0.2023.1.1",
"tsr_url": "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/qualified.tsr"
},
"original": null,
"urls": {
"self": "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e",
"proof_json": "https://sellat.app/api/v2/proof/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e.json",
"certificate": "https://sellat.app/certificate/3b1d7e42-9c5a-4f0e-b8d6-1a2c3e4f5a6b",
"certificate_pdf": "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/certificate.pdf",
"evidence_zip": "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/evidence.zip"
}
} Retrieve a proof
GET /proofs/{id}
Returns the proof with its state, its anchors, the Bitcoin attestation, the seal if it has one, the original if it is in custody, and all its URLs.
Parameters
-
idrequired - The proof’s
id.
Responses
- 200
- The proof.
- 401
unauthorized.- 404
not_found: it does not exist or belongs to another account.
curl https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e \
-H "Authorization: Bearer $SELLAT_API_TOKEN" {
"id": "6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e",
"hash": "9f3c2a5e0b7d1c4f8a6e2d9b3c7f1a5e8d2c6b0f4a9e3d7c1b5f8a2e6d0c4b9f",
"algorithm": "SHA-256",
"name": "contract.pdf",
"state": "anchored",
"received_at": "2026-09-30T09:12:03.418Z",
"anchoring": {
"policy": "scheduled",
"batch_closes_after": "2026-09-30T12:00:00.000Z"
},
"anchors": [
{
"network": "Polygon Mainnet",
"chain_id": 137,
"state": "anchored",
"tx_hash": "0xa4195d4ea808610dee92a1caa221d35f289e61674f451c82f7428e29813f9d6a",
"block_number": 94653624,
"block_timestamp": "2026-09-30T12:01:44.000Z",
"explorer_url": "https://polygonscan.com/tx/0xa4195d4ea808610dee92a1caa221d35f289e61674f451c82f7428e29813f9d6a"
}
],
"bitcoin": {
"state": "pending",
"block_height": null
},
"qualified": {
"state": "issued",
"authority": "FNMT-RCM",
"time": "2026-09-30T09:12:05.000Z",
"serial_number": "175755C77B4239226AA0428FE1C107C1",
"policy_oid": "0.4.0.2023.1.1",
"tsr_url": "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/qualified.tsr"
},
"original": null,
"urls": {
"self": "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e",
"proof_json": "https://sellat.app/api/v2/proof/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e.json",
"certificate": "https://sellat.app/certificate/3b1d7e42-9c5a-4f0e-b8d6-1a2c3e4f5a6b",
"certificate_pdf": "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/certificate.pdf",
"evidence_zip": "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/evidence.zip"
}
} List proofs
GET /proofs
Your account’s proofs, newest first: those created through the API and those created on the web. Each item in data is a full proof, like GET /proofs/{id}.
For the next page, pass the next_cursor the previous one returned. When it is null, there are no more.
Parameters
-
limitoptional - Proofs per page, 1 to 100. Defaults to 25.
-
cursoroptional - The
next_cursorof the previous page.
Responses
- 200
dataandnext_cursor.- 400
invalid_request: invalidlimitorcursor.- 401
unauthorized.
curl "https://sellat.app/api/v2/proofs?limit=25" \
-H "Authorization: Bearer $SELLAT_API_TOKEN" {
"data": [
{
"id": "6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e",
"hash": "9f3c2a5e0b7d1c4f8a6e2d9b3c7f1a5e8d2c6b0f4a9e3d7c1b5f8a2e6d0c4b9f",
"name": "contract.pdf",
"state": "confirmed",
"received_at": "2026-09-30T09:12:03.418Z"
}
],
"next_cursor": "1843"
} Qualified seal (eIDAS)
Request the qualified seal
POST /proofs/{id}/qualified
Issues a qualified RFC 3161 timestamp over the file’s fingerprint, signed by a qualified trust service provider on the EU trusted list. Under Article 41(2) of the eIDAS Regulation, a qualified electronic timestamp enjoys the presumption of the accuracy of the date and time it indicates and of the integrity of the data.
It spends one seal from the account: the welcome seal or one from your packs. The API never charges a card or opens a payment; packs are bought on the pricing page.
Parameters
-
idrequired - The proof’s
id.
Responses
- 200
- Seal issued, or it already had one (
"created": false, nothing spent). Carriesseals_remaining. - 202
- The seal is paid for but the authority did not answer. Call again (
Retry-After: 30): it completes at no cost. - 401
unauthorized.- 402
payment_required: no seals left. Carriesseals_remainingandbuy_url.- 404
not_found.- 409
conflict: another account registered these bytes first.- 503
seal_unavailable.
A proof has one seal, forever. If issuance fails for good, the seal goes back to your balance.
curl -X POST https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/qualified \
-H "Authorization: Bearer $SELLAT_API_TOKEN" {
"proof_id": "6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e",
"created": true,
"qualified": {
"state": "issued",
"authority": "FNMT-RCM",
"time": "2026-09-30T09:12:05.000Z",
"serial_number": "175755C77B4239226AA0428FE1C107C1",
"policy_oid": "0.4.0.2023.1.1",
"tsr_url": "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/qualified.tsr"
},
"seals_remaining": 4
} Retrieve the seal
GET /proofs/{id}/qualified
Returns only the proof’s seal, or null if it has none yet. It issues nothing and spends nothing.
Parameters
-
idrequired - The proof’s
id.
Responses
- 200
proof_idandqualified.- 401
unauthorized.- 404
not_found.
curl https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/qualified \
-H "Authorization: Bearer $SELLAT_API_TOKEN" {
"proof_id": "6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e",
"qualified": {
"state": "issued",
"authority": "FNMT-RCM",
"time": "2026-09-30T09:12:05.000Z",
"serial_number": "175755C77B4239226AA0428FE1C107C1",
"policy_oid": "0.4.0.2023.1.1",
"tsr_url": "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/qualified.tsr"
}
} Download the token (.tsr)
GET /proofs/{id}/qualified.tsr
The seal exactly as the authority returned it: an RFC 3161 TimeStampResp in DER. It is the file an expert or a court can validate without Sellat.
Parameters
-
idrequired - The proof’s
id.
Responses
- 200
- The
.tsr(application/timestamp-reply). - 401
unauthorized.- 404
not_found: the proof does not exist or has no seal yet.
curl https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/qualified.tsr \
-H "Authorization: Bearer $SELLAT_API_TOKEN" \
-o seal.tsr HTTP/1.1 200 OK
Content-Type: application/timestamp-reply
Content-Disposition: attachment; filename="sellat-6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e-qualified-timestamp.tsr"
<RFC 3161 TimeStampResp, DER> Certificate and evidence
PDF certificate
GET /proofs/{id}/certificate.pdf
The proof’s certificate, for people: the fingerprint, the date, the anchors and the qualified seal if there is one, with instructions to verify it.
Parameters
-
idrequired - The proof’s
id. -
langoptional - Certificate language:
es,en,deorfr. Any other value givesen.
Responses
- 200
- The PDF.
- 401
unauthorized.- 404
not_found.- 409
conflict: another account registered these bytes first and the proof has no certificate of its own.
curl "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/certificate.pdf?lang=es" \
-H "Authorization: Bearer $SELLAT_API_TOKEN" \
-o certificate.pdf HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="certificado-3b1d7e42-9c5a-4f0e-b8d6-1a2c3e4f5a6b.pdf"
<PDF> Evidence package
GET /proofs/{id}/evidence.zip
A ZIP with what it takes to defend the proof without Sellat: the certificate, proof.json, the seal and the authority’s certificate if there is one, the instructions and, if it is in custody, the original.
Parameters
-
idrequired - The proof’s
id. -
langoptional - Language of the package’s texts:
es,en,deorfr.
Responses
- 200
- The ZIP.
- 401
unauthorized.- 404
not_found.- 409
conflict: another account registered these bytes first.
Including the original spends one of the day’s downloads. If none are left, the package comes without it and the README says so.
curl "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/evidence.zip?lang=es" \
-H "Authorization: Bearer $SELLAT_API_TOKEN" \
-o evidence.zip HTTP/1.1 200 OK
Content-Type: application/zip
Content-Disposition: attachment; filename="sellat-evidencia-3b1d7e42-9c5a-4f0e-b8d6-1a2c3e4f5a6b.zip"
<ZIP> Custody of the original
Deposit the original
PUT /proofs/{id}/original
Stores the file next to its proof, on servers in the European Union. Sellat recomputes the fingerprint of what it receives and refuses it if it does not match the proof.
Send the raw bytes with the file’s Content-Type, or multipart/form-data under the file field. POST works the same as PUT.
Parameters
-
idrequired - The proof’s
id. -
filerequired - The file. Up to 10 MB on the free account and 50 MB with Custody, and 25 MB per request at most.
Responses
- 201
- Stored. Returns the proof with
original. - 200
- It was already stored.
- 400
invalid_request: the body is empty or unreadable.- 401
unauthorized.- 404
not_found.- 409
hash_mismatch(not the proof’s file) orconflict(another account’s bytes).- 413
file_too_large: over your plan’s per-file limit. Carriesmax_file_bytes.- 502
unavailable: storage did not answer.- 503
custody_unavailable.- 507
custody_quota_exceeded: no space left on your account. Carriesused_bytesandmax_account_bytes.
Custody is optional: the proof is just as valid without the original. Keeping it adds preservation; it does not condition the proof.
curl -X PUT https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/original \
-H "Authorization: Bearer $SELLAT_API_TOKEN" \
-H "Content-Type: application/pdf" \
--data-binary @contract.pdf {
"id": "6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e",
"state": "anchored",
"original": {
"stored": true,
"file_name": "contract.pdf",
"size_bytes": 184320,
"mime_type": "application/pdf",
"stored_at": "2026-09-30T09:15:10.000Z",
"url": "https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/original"
}
} Download the original
GET /proofs/{id}/original
Returns the file in custody. Sellat recomputes its fingerprint on the way out and refuses to serve it if it no longer matches. Each download counts towards your plan’s daily limit; the X-Sellat-Custody-Downloads-Remaining header says how many are left.
Parameters
-
idrequired - The proof’s
id.
Responses
- 200
- The file, with its type and name.
- 401
unauthorized.- 404
- No original in custody.
- 409
conflict: the original is not stored yet.- 429
download_quota_exceeded: daily download limit. Resets at 00:00 UTC.- 500
integrity_failed: what is stored no longer matches the fingerprint.- 502
unavailable.
curl https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/original \
-H "Authorization: Bearer $SELLAT_API_TOKEN" \
-o contract.pdf HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="contract.pdf"
X-Sellat-Custody-Downloads-Remaining: 9
<the original bytes> End custody
DELETE /proofs/{id}/original
Deletes the original in custody. The proof does not change: it stays anchored, sealed and verifiable.
Parameters
-
idrequired - The proof’s
id.
Responses
- 200
"original": nulland"proof_unaffected": true."purged": falsemeans physical deletion is queued.- 401
unauthorized.- 404
- No original in custody.
curl -X DELETE https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/original \
-H "Authorization: Bearer $SELLAT_API_TOKEN" {
"proof_id": "6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e",
"original": null,
"purged": true,
"proof_unaffected": true
} Account
Your account
GET /account
What the calling key can do: the plan, the seals you have left (welcome and packs), custody space and downloads, today’s proofs and their limit, and the active keys. Check it before a large batch.
The connector block holds the limits for connected shops (WooCommerce).
Responses
- 200
- The account.
- 401
unauthorized.
curl https://sellat.app/api/v2/account \
-H "Authorization: Bearer $SELLAT_API_TOKEN" {
"plan": "free",
"plan_name": "Free",
"seals": {
"available": true,
"remaining": 1,
"welcome_remaining": 1,
"pack_remaining": 0,
"price_eur": 6,
"buy_url": "https://sellat.app/precios"
},
"custody": {
"available": true,
"used_bytes": 0,
"max_account_bytes": 52428800,
"max_file_bytes": 10485760,
"downloads_used_today": 0,
"max_downloads_per_day": 10
},
"proofs": {
"daily_limit": 1000,
"used_today": 3,
"resets_in_seconds": 41231
},
"connector": {
"mode_available": "daily",
"eidas_available": false,
"custody_available": false,
"operations": {
"daily_limit": 1000,
"resets_in_seconds": 41231
}
},
"keys": {
"active": 1
},
"urls": {
"dashboard": "https://sellat.app/dashboard",
"plans": "https://sellat.app/precios#pricing-custody",
"seals": "https://sellat.app/precios#pricing-seal-title",
"docs": "https://sellat.app/developers/docs",
"connect": "https://sellat.app/connect/woocommerce"
}
} Portable proof
Portable proof (proof.json)
GET /proof/{id}.json No key
The document that lets anyone verify the proof without Sellat, in the public sellat-proof/2 format: the fingerprint, the leaf, the Merkle path, the root, the Polygon anchor and the attestations (Bitcoin and the qualified seal). It is public on purpose: whoever holds it can check the proof without an account.
Parameters
-
idrequired - The proof’s
id.
Responses
- 200
- The
proof.json. - 202
- Not anchored yet:
{"ready": false, "state": "..."}withRetry-After: 60. - 400
- The
idis not a UUID. - 404
- It does not exist.
curl https://sellat.app/api/v2/proof/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e.json -o proof.json {
"schema": "sellat-proof/2",
"proof_id": "6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e",
"content": {
"algorithm": "SHA-256",
"hash": "9f3c2a5e0b7d1c4f8a6e2d9b3c7f1a5e8d2c6b0f4a9e3d7c1b5f8a2e6d0c4b9f"
},
"leaf": {
"formula": "SHA-256('sellat-leaf:v2:' + proof_id + ':' + content.hash)",
"value": "31668b4d846680cd920a1cb5be200ab0562909e8778223087c1738746a1cbb26"
},
"merkle": {
"index": 1,
"path": [
{
"position": "left",
"hash": "9e246a6d6dda91ee3916e149d834cd9a140e3470fbf5b6ec67824705dbbc82d4"
}
],
"root": "88fa1c4c3587d86d6f713e438c7ae745a91a6e03a5dd1b8282f17f35f30bfa14"
},
"anchors": [
{
"chain_id": 137,
"network": "Polygon Mainnet",
"tx_hash": "0xa4195d4ea808610dee92a1caa221d35f289e61674f451c82f7428e29813f9d6a",
"block_number": 94653624,
"payload": "sellat:v2:88fa1c4c3587d86d6f713e438c7ae745a91a6e03a5dd1b8282f17f35f30bfa14"
}
],
"attestations": [
{
"type": "opentimestamps",
"state": "submitted"
},
{
"type": "rfc3161-qualified-timestamp",
"state": "issued"
}
]
} Missing something, or need more volume? Write to hola@sellat.app: you will talk to the people who wrote the code.