If your firm already works in a practice-management tool, you don't need to move: these six steps are enough to seal a document and send it as a verifiable link from where you already are.
It's in Settings → API. Send it in the X-API-Key header of every call. Don't put it in code that reaches the browser.
curl -X POST https://www.aim-codex.ai/api/v1/documents/write \
-H "X-API-Key: TU_CLAVE" -H "Content-Type: application/json" \
-d '{"title":"Presupuesto reforma","markdown":"Total: 6.000 EUR"}'# /documents/write ya lo deja sellado (status sealed): este paso es solo para plantillas
curl -X POST https://www.aim-codex.ai/api/v1/documents \
-H "X-API-Key: TU_CLAVE" -H "Content-Type: application/json" \
-d '{"template_id":"presupuesto","data":{"emisor":"Reformas Paco","cliente":"Ana Ruiz"}}'
curl -X POST https://www.aim-codex.ai/api/v1/documents/DOC_ID/seal \
-H "X-API-Key: TU_CLAVE" -H "Content-Type: application/json" -d '{}'curl -X POST https://www.aim-codex.ai/api/v1/documents/DOC_ID/share \
-H "X-API-Key: TU_CLAVE" -H "Content-Type: application/json" \
-d '{"label":"Construcciones Lopez","allow_accept":true}'curl https://www.aim-codex.ai/api/v1/documents/DOC_ID/opens -H "X-API-Key: TU_CLAVE" curl https://www.aim-codex.ai/api/v1/acceptances -H "X-API-Key: TU_CLAVE"
curl https://www.aim-codex.ai/api/v1/documents/DOC_ID/dossier \ -H "X-API-Key: TU_CLAVE" -o expediente.pdf
curl https://www.aim-codex.ai/api/v1/verify/HUELLA_SHA256
That's it. The link returned in url opens without an account, in your client's browser language, and can be checked against Bitcoin without going through us.
If a call is cut off and you don't know whether it arrived, repeat it with the same Idempotency-Key header (any unique text of yours, up to 200 characters): for 24 hours we return the first response and nothing is sent twice. The document list accepts ?limit= (up to 500) and &offset=, newest first. Errors come back as JSON, with error and code.
In Settings → API you set an address of yours and CODEX calls it when something happens. No polling: your practice tool finds out the moment the client signs.
POST https://tu-gestor.example/codex
{
"event": "document.accepted",
"doc_id": "doc-XXXXXXXX",
"content_hash": "6ab026dc...",
"name": "Antonio Lopez",
"at": "2026-09-04T09:52:00Z"
}Events sent:
document.first_opendocument.accepteddocument.signatures_completedocument.rejecteddocument.counterofferdocument.revokeddocument.unrevokedcredential.revokedcredential.unrevokeddocument.anchoreddocument.receivedEach call is signed with your secret, so you know it came from us. And since it's a plain HTTP request, it works just as well with Zapier, Make or anything that can receive a webhook.
Each call carries X-Codex-Event, X-Codex-Event-Id (the same on retries: use it to avoid processing twice), X-Codex-Timestamp and the signature X-Codex-Signature-256: t=…,v1=…: HMAC-SHA256 with your secret over t + "." + body. Reject anything older than five minutes. (X-Codex-Signature signs the body only and is kept for compatibility.) If your server answers 5xx or doesn't answer, it is retried twice more; redirects are not followed. The secret is in Settings → API, where you can also rotate it.
import hmac, hashlib, time
def es_de_codex(cuerpo: bytes, cabecera: str, secreto: str) -> bool:
partes = dict(p.split('=', 1) for p in cabecera.split(','))
if abs(time.time() - int(partes['t'])) > 300:
return False
esperada = hmac.new(secreto.encode(), partes['t'].encode() + b'.' + cuerpo,
hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada, partes['v1'])Go over a limit and you get 429 with Retry-After: wait that many seconds and retry. Public verification allows 120 lookups per minute per IP; the MCP server, another 120. Error responses carry error (text) and code (stable, for your program: see the error table); send X-Lang: en for English text.
Every 4xx error comes back as JSON with error (text for a person) and code (stable, for your program). Always compare code, never the text. Types and lengths are checked before creating or sealing: what is sealed cannot be corrected, so malformed data is rejected with 400 and nothing is sealed.
| 400 | bad_json | the body is not valid JSON (truncated or malformed), or not an object |
| 400 | bad_types | a field of the wrong type (a list where text goes); comes with field |
| 400 | too_long | a field exceeds its maximum; comes with field and max_length (title: 200 characters) |
| 400 | no_title · no_content · no_template | the title, the content or the template is missing |
| 400 | invalid_data · no_signers · no_recipients | invalid template data, no signers, no recipients |
| 400 | bad_limit · bad_offset · webhook_not_public | pagination out of range; webhook pointing to an internal address |
| 401 | unauthorized | missing or invalid key |
| 402 | plan | the feature belongs to a higher plan (min_plan says which) |
| 403 | forbidden · needs_login | your role does not allow it; or a person is needed (the owner's password) |
| 404 | not_found | does not exist, or is not your account's |
| 409 | conflict | the state does not allow it (e.g. asking for a receipt of a draft) |
| 413 | too_large | the request exceeds the maximum size |
| 429 | rate | too many requests: wait as long as Retry-After says |
GET /api/v1/verify/FINGERPRINT returns three different things. authentic: the bytes are the ones that were sealed (still true for a revoked or expired document). state: valid, revoked, expired, superseded, hash_only, received, flagged or unknown. valid: true only if state is valid. If your program reads a single field, make it valid. File verification (/verify-file/FILE_SHA256) can also return state: conflict when different accounts sealed the same file; there valid is false. The MCP server applies the same rule in its vigente field.
{"authentic": true, "revoked": true, "state": "revoked", "valid": false, ...}POST /api/v1/documents {template_id, data} -> borrador / draft
POST /api/v1/documents/DOC_ID/seal {} -> sellado / sealed (idempotente)
POST /api/v1/seal-hash {hash, label} -> solo la huella / fingerprint only
POST /api/v1/documents/DOC_ID/signature {signers:[{name,email,tipo}]} -> firma / signature flow
tipo: signer | approver | cc (sin tipo: signer / default: signer)
POST /d/SHARE_ID/delegate {email,to_name,to_email,reason[,code]} -> delegar / delegate
POST /api/v1/documents/DOC_ID/bulk-share {recipients:[{name,email}]}
POST /api/v1/acuses {doc_id, recipients:[{name,email}], deadline}
GET /api/v1/documents?limit=1..500&offset=0 -> documents, count, total, limit, offset, next_offsetEach item in signers accepts tipo (also type or kind): signer (default: signs in their turn), approver (approves, or declines with a reason, in their turn, without signing; their receipt reads “Approved by”) or cc (read-only copy: gets the link when the flow is created and the final copy; does not sign or hold up the flow). role is still the free-text role (“Landlord”); if it is approver or cc it is taken as the type. An unknown type returns 400 bad_role; a flow made only of copies, 400 no_signers. A signer or an approver can pass their turn from their link (POST /d/SHARE_ID/delegate, with their email and, when the server sends mail, the code they receive): the delegate gets their own link and their own code. GET /api/v1/documents/DOC_ID/signatures returns tipo, delegated_to and delegated_from (who, when and why); the new states are approved and delegated. Webhook: document.signature_delegated. Copies and whoever delegated do not count in signed/total.
{"mode": "sequential", "signers": [
{"name": "Gestor", "email": "gestor@empresa.es", "tipo": "cc"},
{"name": "Socia", "email": "socia@empresa.es", "tipo": "approver"},
{"name": "Cliente", "email": "cliente@empresa.es", "role": "Arrendatario"}]}POST /api/v1/tenant/settings with {"webhook_url": "https://…"}. Where alerts go is decided by a person: with only the key, add "password" (the owner's) or you get 403 needs_login. The secret is shown once, in the response that creates or rotates it ("rotate_webhook_secret": true, also with password); afterwards it reads ••••••. Store it then. An internal address (localhost, private network, cloud metadata) is rejected with 400 webhook_not_public.
JSON-RPC 2.0 over HTTP (Streamable HTTP) at https://www.aim-codex.ai/mcp. Tools: verificar_documento, search and fetch. A malformed argument returns a result with isError: true; too many requests, HTTP 429 with a JSON-RPC -32000 error and Retry-After.
curl -X POST https://www.aim-codex.ai/mcp -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"HUELLA_SHA256"}}}'We don't need to build a connector for each one. Put a Zapier or Make address in the webhook field and route it to whatever you use: Holded, spreadsheets, your CRM or the team inbox. Ten minutes, no code.
We put it this way because it's what works today, not what we'd like to announce. If your tool isn't on either, the API above works just as well.
If you issue credentials, each one can also be read as a W3C Verifiable Credential: the standard's data format. Public and keyless, like all our verification. It is not yet signed with the issuer's key, which is what wallets require: its proof is the Bitcoin anchor and its status is checked here.
How the seal is computed, step by step →
curl https://www.aim-codex.ai/v/HUELLA_SHA256/w3c.json
Connect https://www.aim-codex.ai/mcp as an MCP server in Claude or ChatGPT and ask it to check a document. No account, no cost.