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.

Base URL 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).

Request
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.

Terminal
# 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.

StateWhat it means
queuedReceived. Waiting for its batch to close: on the free account, at the next fixed UTC time, every 4 hours (anchoring.batch_closes_after).
batchedIn a Merkle batch, with its path fixed. Waiting for the Polygon anchor.
anchoredAnchored: the transaction is in a Polygon block. proof.json is available.
confirmedThe 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.

CodeHTTPWhat happened
invalid_request400A field is missing or not in the expected format. The message says which.
unauthorized401No key, a malformed key or a revoked one.
payment_required402No qualified seals left. Carries seals_remaining and buy_url.
key_without_account403The key is not attached to an account (old keys).
not_found404The proof does not exist or belongs to another account.
conflict409Another account registered these bytes first, or the original is not stored yet.
hash_mismatch409The file you upload does not have the proof’s fingerprint.
file_too_large413The file exceeds your plan’s per-file limit (max_file_bytes).
rate_limited429Too many requests per minute. Honour Retry-After.
quota_exceeded429Daily proof limit reached. Retry-After runs to 00:00 UTC.
download_quota_exceeded429Daily limit of original downloads reached.
integrity_failed500The stored original no longer matches its fingerprint, so it is not served.
unavailable502 · 503An internal service is not answering. Retry later.
seal_unavailable503The timestamping authority is not available right now.
custody_unavailable503Custody is not available right now.
custody_quota_exceeded507No custody space left on your account.
Response
{
  "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 accountWith Custody
API proofs per day1,0001,000
Polygon and Bitcoin anchoringEvery 4 hours, at fixed UTC timesEvery 4 hours, at fixed UTC times
Active keys55
Original in custody, per file10 MB50 MB
Total custody1 GB20 GB
Original downloads per day1050
Response
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.

CallPriceWhat you get
POST /proofs0.01 USDCA proof in the fast batch (minutes), with certificate, proof.json and evidence ZIP
POST /proofs/{id}/qualified0.90 USDCThe eIDAS qualified timestamp on a proof of your wallet, in seconds
POST /wallet/key0.01 USDCA 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).

Full example (TypeScript)
// 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.

Terminal
# 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

hash string · body required
The file’s SHA-256 fingerprint: 64 hexadecimal characters.
name string · body optional
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.
metadata object · body optional
A JSON object of yours, up to 4 KB. Stored with the proof, never returned or published.
size integer · body optional
File size in bytes. Informational.
mime_type string · body optional
The file’s type, used when serving the original if you keep it in custody. Defaults to application/octet-stream.
qualified boolean · body optional
true also issues the qualified (eIDAS) seal.
Idempotency-Key string · header optional
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) or rate_limited (per minute).
503
seal_unavailable or unavailable. If the proof was created, the error carries its proof_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"].

Request
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" }
  }'
Response
{
  "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

id string · path required
The proof’s id.

Responses

200
The proof.
401
unauthorized.
404
not_found: it does not exist or belongs to another account.
Request
curl https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e \
  -H "Authorization: Bearer $SELLAT_API_TOKEN"
Response
{
  "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

limit integer · query optional
Proofs per page, 1 to 100. Defaults to 25.
cursor string · query optional
The next_cursor of the previous page.

Responses

200
data and next_cursor.
400
invalid_request: invalid limit or cursor.
401
unauthorized.
Request
curl "https://sellat.app/api/v2/proofs?limit=25" \
  -H "Authorization: Bearer $SELLAT_API_TOKEN"
Response
{
  "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

id string · path required
The proof’s id.

Responses

200
Seal issued, or it already had one ("created": false, nothing spent). Carries seals_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. Carries seals_remaining and buy_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.

Request
curl -X POST https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/qualified \
  -H "Authorization: Bearer $SELLAT_API_TOKEN"
Response
{
  "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

id string · path required
The proof’s id.

Responses

200
proof_id and qualified.
401
unauthorized.
404
not_found.
Request
curl https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/qualified \
  -H "Authorization: Bearer $SELLAT_API_TOKEN"
Response
{
  "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

id string · path required
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.
Request
curl https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/qualified.tsr \
  -H "Authorization: Bearer $SELLAT_API_TOKEN" \
  -o seal.tsr
Response (file)
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

id string · path required
The proof’s id.
lang string · query optional
Certificate language: es, en, de or fr. Any other value gives en.

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.
Request
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
Response (file)
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

id string · path required
The proof’s id.
lang string · query optional
Language of the package’s texts: es, en, de or fr.

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.

Request
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
Response (file)
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

id string · path required
The proof’s id.
file binary · body required
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) or conflict (another account’s bytes).
413
file_too_large: over your plan’s per-file limit. Carries max_file_bytes.
502
unavailable: storage did not answer.
503
custody_unavailable.
507
custody_quota_exceeded: no space left on your account. Carries used_bytes and max_account_bytes.

Custody is optional: the proof is just as valid without the original. Keeping it adds preservation; it does not condition the proof.

Request
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
Response
{
  "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

id string · path required
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.
Request
curl https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/original \
  -H "Authorization: Bearer $SELLAT_API_TOKEN" \
  -o contract.pdf
Response (file)
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

id string · path required
The proof’s id.

Responses

200
"original": null and "proof_unaffected": true. "purged": false means physical deletion is queued.
401
unauthorized.
404
No original in custody.
Request
curl -X DELETE https://sellat.app/api/v2/proofs/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e/original \
  -H "Authorization: Bearer $SELLAT_API_TOKEN"
Response
{
  "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.
Request
curl https://sellat.app/api/v2/account \
  -H "Authorization: Bearer $SELLAT_API_TOKEN"
Response
{
  "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

id string · path required
The proof’s id.

Responses

200
The proof.json.
202
Not anchored yet: {"ready": false, "state": "..."} with Retry-After: 60.
400
The id is not a UUID.
404
It does not exist.
Request
curl https://sellat.app/api/v2/proof/6f2c9b1e-0d1a-4b9f-8e7c-2a4d5b6c7d8e.json -o proof.json
Response
{
  "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.