Skip to content
AIM CODEXESEN
How it worksPricingTry itCheck a documentVerify with AITrustSign inESEN
Sign inStart free

Seal it from your own software

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.

Your key

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.

1. Create the document

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"}'

2. From a template: create the draft and seal it

# /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 '{}'

3. Create the client link

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}'

4. Check opens and acceptances

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"

5. Download the evidence dossier

curl https://www.aim-codex.ai/api/v1/documents/DOC_ID/dossier \
  -H "X-API-Key: TU_CLAVE" -o expediente.pdf

6. Check a document with no key

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.

Let it tell you

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:

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

Checking the call is ours

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'])

Limits

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.

Errors

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.

400bad_jsonthe body is not valid JSON (truncated or malformed), or not an object
400bad_typesa field of the wrong type (a list where text goes); comes with field
400too_longa field exceeds its maximum; comes with field and max_length (title: 200 characters)
400no_title · no_content · no_templatethe title, the content or the template is missing
400invalid_data · no_signers · no_recipientsinvalid template data, no signers, no recipients
400bad_limit · bad_offset · webhook_not_publicpagination out of range; webhook pointing to an internal address
401unauthorizedmissing or invalid key
402planthe feature belongs to a higher plan (min_plan says which)
403forbidden · needs_loginyour role does not allow it; or a person is needed (the owner's password)
404not_founddoes not exist, or is not your account's
409conflictthe state does not allow it (e.g. asking for a receipt of a draft)
413too_largethe request exceeds the maximum size
429ratetoo many requests: wait as long as Retry-After says

Reading verification: valid, not authentic

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, ...}

More routes

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_offset

Participant roles in a signing flow

Each 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"}]}

The webhook from your program

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.

The MCP, under the hood

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"}}}'

What about your practice tool?

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.

Credentials in the open standard

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

Rather have your assistant do it?

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.

BIOMAG S.L.U. · NIF B-01037241 · Vitoria-Gasteiz