Guía de integración en 15 minutos
Esta guía te lleva de cero a tu primer PDF firmado con la API de Firmatia. Vas a enviar un documento a firmar, recibir el aviso en tu servidor cuando termine y descargar el resultado. Los ejemplos están en curl, PHP, Node.js y Python, y todos apuntan al sandbox, el entorno de pruebas. La referencia completa de la API está en https://firmatia.es/docs.
Qué vas a conseguir
En unos 15 minutos tendrás:
- Una petición de firma creada desde tu código: un PDF y un firmante.
- El aviso del resultado (webhook) en tu servidor, con su firma comprobada.
- El PDF firmado descargado y verificado.
Una petición de firma es un sobre con un solo firmante. Para varios firmantes existe el sobre (POST /signature-envelopes), que funciona igual y se describe en la referencia.
Antes de empezar
- Acceso al sandbox. Pídelo desde el formulario de contacto de https://firmatia.es. El sandbox es independiente de producción: sus cuentas, sus tokens y sus datos no sirven en el otro entorno. Los datos son de prueba y se borran pasado un plazo, que publica https://firmatia.es/sandbox.
- Un cliente de API con su token y su secreto de webhook. El token es la credencial con la que tu código se identifica (cabecera
Authorization: Bearer <token>). El secreto de webhook (webhook_secret) sirve para comprobar que los avisos vienen de Firmatia. Te los entregamos al darte acceso. Después, el administrador de tu empresa puede emitir tokens nuevos y rotar el secreto en el panel, en «Clientes de API». Los dos se muestran una sola vez: guárdalos en un lugar seguro. Copia el token entero, con el número y la barra vertical del principio (12|…). - El responsable del tratamiento. Tu empresa debe tenerlo configurado en «Mi marca» (nombre y correo de protección de datos). Si falta, la creación responde 422
branding_incomplete. La alternativa es enviar el objetocontrolleren cada petición. - Un correo tuyo para el firmante. Así recibes el enlace y firmas tú mismo. En el sandbox los avisos al firmante llegan siempre por correo.
La URL base del sandbox es https://sandbox.firmatia.es/api/v1. Todas las rutas de esta guía son relativas a ella. Toda respuesta de la API lleva la cabecera X-Firmatia-Environment, con el valor production o sandbox. Compruébala para saber con qué entorno hablas. El paso a producción se explica en el apartado 7.
En los ejemplos, el token y el secreto se leen de las variables de entorno FIRMATIA_TOKEN y FIRMATIA_WEBHOOK_SECRET. No los escribas en el código.
1. Crear la petición de firma
Envía POST /signature-requests con un cuerpo JSON. Los campos mínimos son:
| Campo | Qué es |
|---|---|
signature_level |
Nivel de firma. Usa ses (firma electrónica simple). |
ses_type |
Cómo manifiesta el firmante su voluntad: checkbox (casilla de aceptación) o drawn (firma dibujada). |
signer_name |
Nombre del firmante. |
signer_email |
Correo del firmante. Ahí recibe su enlace de firma. |
document_base64 |
El PDF, codificado en base64. |
Dos campos opcionales que te interesan desde el principio:
external_reference: tu identificador (un número de contrato, de pedido…). Vuelve en la respuesta y en cada webhook.callback_url: la URL de tu servidor que recibirá los webhooks. Sus requisitos están en el apartado 2.
Este es el cuerpo de ejemplo. Guárdalo como peticion.json:
{
"signature_level": "ses",
"ses_type": "checkbox",
"signer_name": "Juan Pérez",
"signer_email": "juan@example.com",
"external_reference": "CONTRATO-2026-0042",
"callback_url": "https://tu-servidor.example.com/webhooks/firmatia",
"document_base64": "JVBERi0xLjcK"
}
Ojo: JVBERi0xLjcK es solo el principio de un PDF (%PDF-1.7), no un documento completo. Si lo envías tal cual, la API responde 422 document_invalid. Sustitúyelo por tu PDF entero en base64. En la terminal lo obtienes así:
base64 < contrato.pdf | tr -d '\n'
Los ejemplos de PHP, Node.js y Python de más abajo ya leen y codifican el fichero por ti.
También en multipart. Si prefieres no codificar el PDF, envía la petición como multipart/form-data, con el fichero en el campo document en lugar de document_base64 (en curl, -F "document=@contrato.pdf;type=application/pdf" y un -F por cada campo). Envía uno de los dos, nunca ambos.
La cabecera Idempotency-Key
Imagina que envías la petición y la conexión se corta antes de recibir la respuesta. No sabes si el sobre se creó. Si repites la petición sin más, puedes crear dos sobres y enviar dos enlaces al firmante.
La cabecera Idempotency-Key lo evita. Funciona así:
- Es una clave que eliges tú. Admite letras sin tildes ni eñes, números,
.,_,:y-, hasta 255 caracteres. Lo más práctico es derivarla de tu propio identificador:contrato-2026-0042. - Repite la misma clave y el mismo cuerpo. Si el sobre ya existía, la API no crea otro ni consume otro crédito. Responde 201 con el sobre que ya creó, en su estado actual, y la cabecera
Idempotent-Replayed: true. - Vive 24 horas desde que crea el sobre. Pasado ese plazo, la misma clave crea un sobre nuevo.
- Es de tu cliente de API. Otro cliente puede usar el mismo valor sin chocar contigo.
- La misma clave con otro cuerpo (u otro PDF) responde 422
idempotency_conflict. También si la usas en el otro endpoint de creación (POST /signature-envelopes).
Por eso conviene no meter en el cuerpo valores que cambien en cada intento, como una fecha calculada en el momento.
La respuesta
Si todo va bien, la API responde 201 con el sobre creado:
{
"data": {
"uuid": "019966e1-4a5b-7c6d-8e9f-0a1b2c3d4e5f",
"external_reference": "CONTRATO-2026-0042",
"template": null,
"controller": null,
"signature_level": "ses",
"signing_mode": "parallel",
"ses_type": "checkbox",
"status": "draft",
"signer": {
"name": "Juan Pérez",
"email": "juan@example.com",
"phone": null
},
"original_hash": "2b62788ce00d7927023e6fb1aaa41dfda93b56000d2a2bcb127b3563b263c047",
"signed_hash": null,
"worm_retained_at": null,
"callback_url": "https://tu-servidor.example.com/webhooks/firmatia",
"expires_at": "2026-10-20T09:30:00+00:00",
"created_at": "2026-10-06T09:30:00+00:00",
"updated_at": "2026-10-06T09:30:00+00:00"
}
}
Guarda dos datos:
uuid: el identificador del sobre. Lo necesitas para consultarlo y descargarlo.status: nace endraft. En unos segundos el enlace sale hacia el firmante y pasa asent. Si no envíasexpires_at, el sobre caduca a los 14 días.
Si algo falla, la respuesta es JSON con message (texto orientativo) y code (estable: úsalo en tu lógica). Un 422 validation_failed trae además errors, campo a campo. La lista completa de códigos está en https://firmatia.es/docs.
En tu lenguaje
Los cuatro ejemplos hacen lo mismo: envían el PDF con su Idempotency-Key, comprueban el entorno y muestran el uuid. Usan la misma clave, así que si pruebas más de uno, cambia la clave entre ellos: con la misma clave y el mismo PDF recibirás el sobre que ya creaste (Idempotent-Replayed: true), no uno nuevo.
curl (con el peticion.json de arriba):
curl -sS https://sandbox.firmatia.es/api/v1/signature-requests \
-H "Authorization: Bearer $FIRMATIA_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: contrato-2026-0042" \
-D - \
--data @peticion.json
La opción -D - muestra las cabeceras de la respuesta, entre ellas X-Firmatia-Environment.
PHP (Guzzle, composer require guzzlehttp/guzzle):
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
// La barra final de base_uri y la ruta sin barra inicial son necesarias:
// así Guzzle conserva el /api/v1 de la URL base.
$cliente = new Client([
'base_uri' => 'https://sandbox.firmatia.es/api/v1/',
'timeout' => 30,
'headers' => [
'Authorization' => 'Bearer ' . getenv('FIRMATIA_TOKEN'),
'Accept' => 'application/json',
],
]);
$respuesta = $cliente->post('signature-requests', [
'headers' => ['Idempotency-Key' => 'contrato-2026-0042'],
'json' => [
'signature_level' => 'ses',
'ses_type' => 'checkbox',
'signer_name' => 'Juan Pérez',
'signer_email' => 'juan@example.com',
'external_reference' => 'CONTRATO-2026-0042',
'callback_url' => 'https://tu-servidor.example.com/webhooks/firmatia',
'document_base64' => base64_encode(file_get_contents('contrato.pdf')),
],
]);
echo 'Entorno: ' . $respuesta->getHeaderLine('X-Firmatia-Environment') . PHP_EOL;
$sobre = json_decode((string) $respuesta->getBody(), true)['data'];
echo "Creado {$sobre['uuid']} en estado {$sobre['status']}" . PHP_EOL;
Guzzle lanza una excepción ante un 4xx o un 5xx. Con $e->getResponse() lees el cuerpo del error y su code.
Node.js (18 o posterior, fetch nativo, sin dependencias):
const fs = require('node:fs');
async function crearPeticion() {
const respuesta = await fetch('https://sandbox.firmatia.es/api/v1/signature-requests', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FIRMATIA_TOKEN}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': 'contrato-2026-0042',
},
body: JSON.stringify({
signature_level: 'ses',
ses_type: 'checkbox',
signer_name: 'Juan Pérez',
signer_email: 'juan@example.com',
external_reference: 'CONTRATO-2026-0042',
callback_url: 'https://tu-servidor.example.com/webhooks/firmatia',
document_base64: fs.readFileSync('contrato.pdf').toString('base64'),
}),
signal: AbortSignal.timeout(30000),
});
const cuerpo = await respuesta.json();
console.log('Entorno:', respuesta.headers.get('X-Firmatia-Environment'));
if (!respuesta.ok) {
throw new Error(`Error ${respuesta.status}: ${cuerpo.code}`);
}
console.log(`Creado ${cuerpo.data.uuid} en estado ${cuerpo.data.status}`);
}
crearPeticion();
Python (pip install requests):
import base64
import os
import requests
with open("contrato.pdf", "rb") as f:
pdf_base64 = base64.b64encode(f.read()).decode("ascii")
respuesta = requests.post(
"https://sandbox.firmatia.es/api/v1/signature-requests",
headers={
"Authorization": f"Bearer {os.environ['FIRMATIA_TOKEN']}",
"Accept": "application/json",
"Idempotency-Key": "contrato-2026-0042",
},
json={
"signature_level": "ses",
"ses_type": "checkbox",
"signer_name": "Juan Pérez",
"signer_email": "juan@example.com",
"external_reference": "CONTRATO-2026-0042",
"callback_url": "https://tu-servidor.example.com/webhooks/firmatia",
"document_base64": pdf_base64,
},
timeout=30,
)
print("Entorno:", respuesta.headers.get("X-Firmatia-Environment"))
respuesta.raise_for_status()
sobre = respuesta.json()["data"]
print(f"Creado {sobre['uuid']} en estado {sobre['status']}")
Si una de estas llamadas acaba en un error de red o un tiempo de espera agotado, repítela tal cual: misma clave y mismo PDF. No crearás un sobre duplicado.
2. Recibir el webhook
Cuando el sobre termina, Firmatia hace un POST a tu callback_url con el resultado. Es el webhook. Así no tienes que preguntar por el estado una y otra vez.
Requisitos de la callback_url
Firmatia comprueba la URL al crear el sobre (si no cumple, 422 validation_failed con el motivo en errors.callback_url) y otra vez antes de cada envío. La URL debe:
- Usar
https. - No llevar puerto, o llevar el 443. Cualquier otro se rechaza.
- No llevar usuario ni contraseña (
usuario:contraseña@). - Tener un host público: un nombre de dominio con al menos un punto y que resuelva en DNS, o una IP pública. No se admiten
localhost,*.localhostni nombres sin dominio, comointranet. - Resolver solo a direcciones públicas. Se rechazan las IP privadas, de loopback, link-local, multicast y reservadas, en IPv4 y en IPv6.
Además, Firmatia no sigue redirecciones. Si tu servidor responde con un 3xx, el envío cuenta como fallido y se reintenta en la misma URL.
Para probar desde tu equipo, expón tu servidor local con un túnel HTTPS público y usa su URL como callback_url. Una dirección como http://localhost:8000 no es válida.
Qué recibes
El webhook es un POST con un cuerpo JSON y estas cabeceras:
| Cabecera | Contenido |
|---|---|
X-Webhook-Id |
Identificador único del evento (UUID). Es el mismo en todos los reintentos de ese evento. |
X-Webhook-Event |
El nombre del evento, igual que el campo event del cuerpo. |
X-Webhook-Timestamp |
Un entero Unix en segundos, la hora de ese intento de envío. |
X-Webhook-Signature |
sha256= seguido de la firma HMAC-SHA256 en hexadecimal. |
Como el timestamp es el de cada intento, la firma cambia de un reintento a otro. El X-Webhook-Id, no.
Este es el cuerpo del evento signed de un sobre del sandbox:
{
"event": "signed",
"environment": "sandbox",
"signature_request": {
"uuid": "019966e1-4a5b-7c6d-8e9f-0a1b2c3d4e5f",
"external_reference": "CONTRATO-2026-0042",
"signature_level": "ses",
"signing_mode": "parallel",
"status": "signed",
"original_hash": "2b62788ce00d7927023e6fb1aaa41dfda93b56000d2a2bcb127b3563b263c047",
"signed_hash": "3c9d0a8e5f6b7c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d"
},
"signer": null,
"document_timestamp": {
"tsa_url": "https://sellado.example.com/tsr",
"timestamped_at": "2026-10-06T10:15:00+00:00",
"hash": "3c9d0a8e5f6b7c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
"qualified": false
},
"document_url": "https://sandbox.firmatia.es/api/v1/signature-requests/019966e1-4a5b-7c6d-8e9f-0a1b2c3d4e5f/document",
"occurred_at": "2026-10-06T10:15:01+00:00"
}
El campo environment vale sandbox o production, según el entorno que envía el aviso. document_timestamp es el sello de tiempo del PDF firmado: solo llega en signed, y en los demás eventos vale null.
Los eventos
| Evento | Cuándo llega |
|---|---|
signed |
Se ha firmado y el PDF firmado ya está disponible. |
declined |
El firmante ha rechazado el documento, o lo has cancelado tú. Lleva decline_reason: el motivo que dio el firmante, o null. |
expired |
El sobre ha caducado sin firmarse. |
error |
El documento original no ha superado la comprobación de integridad al ir a firmar. El sobre no cambia de estado y no puede completarse: crea uno nuevo. |
signer_signed |
Solo en sobres de varios firmantes: uno ha firmado y el sobre sigue abierto. La firma del último no genera signer_signed: llega signed, y con él das por firmados a todos. |
signer_declined |
Solo en sobres de varios firmantes: uno ha rechazado. Llega antes del declined del sobre. |
Una petición de firma, con un solo firmante, recibe solo los eventos del sobre: signed, declined, expired o error.
Verificar la firma
Cualquiera puede enviar un POST a tu URL. Antes de fiarte del cuerpo, comprueba que lo firmó Firmatia con tu webhook_secret:
- Toma el cuerpo crudo, los bytes exactos que llegan. No lo conviertas a JSON y de vuelta a texto: cambiaría y la firma no cuadraría.
- Calcula HMAC-SHA256, con tu
webhook_secretcomo clave, sobre el textoX-Webhook-Timestamp+.+ cuerpo crudo. - Pásalo a hexadecimal, antepón
sha256=y compáralo conX-Webhook-Signatureen tiempo constante. - Rechaza el aviso si el timestamp se aleja de tu hora actual más de una tolerancia. Te recomendamos 300 segundos, si tu reloj está en hora.
Para comprobar tu código: con el secreto whsec_ejemplo, el timestamp 1789718400 y el cuerpo {"event":"signed"}, la firma es sha256=636dc7029dcca554b697041d34977ca002a2796cbb39793b9d6150925ee30401.
Estas tres funciones hacen la comprobación completa, sin dependencias. Devuelven false si falta una cabecera, si el timestamp no es un entero, si está fuera de la tolerancia o si la firma no coincide.
PHP (8.0 o posterior):
function verificarWebhookFirmatia(string $secreto, ?string $timestamp, ?string $firma, string $cuerpo, ?int $ahora = null, int $tolerancia = 300): bool
{
if ($timestamp === null || $firma === null || $timestamp === '' || $firma === '') {
return false;
}
// ctype_digit: solo cifras ASCII, sin signo, espacios ni salto de línea.
if (! ctype_digit($timestamp)) {
return false;
}
$ahora ??= time();
if (abs($ahora - (int) $timestamp) > $tolerancia) {
return false;
}
if (! str_starts_with($firma, 'sha256=')) {
return false;
}
$esperada = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $cuerpo, $secreto);
if (strlen($firma) !== strlen($esperada)) {
return false;
}
return hash_equals($esperada, $firma);
}
Node.js:
const crypto = require('node:crypto');
function verificarWebhookFirmatia(secreto, timestamp, firma, cuerpo, ahora = Math.floor(Date.now() / 1000), tolerancia = 300) {
try {
if (typeof timestamp !== 'string' || typeof firma !== 'string' || timestamp === '' || firma === '') {
return false;
}
// Solo cifras ASCII.
if (!/^[0-9]+$/.test(timestamp)) {
return false;
}
if (Math.abs(ahora - Number(timestamp)) > tolerancia) {
return false;
}
if (!firma.startsWith('sha256=')) {
return false;
}
// El cuerpo entra tal cual (string o Buffer), sin volver a decodificarlo.
const hmac = crypto.createHmac('sha256', secreto);
hmac.update(timestamp + '.');
hmac.update(cuerpo);
const esperada = Buffer.from('sha256=' + hmac.digest('hex'), 'utf8');
const recibida = Buffer.from(firma, 'utf8');
// timingSafeEqual exige la misma longitud en bytes.
if (recibida.length !== esperada.length) {
return false;
}
return crypto.timingSafeEqual(recibida, esperada);
} catch {
return false;
}
}
Python (solo biblioteca estándar):
import hashlib
import hmac
import time
def verificar_webhook_firmatia(secreto: str, timestamp, firma, cuerpo: bytes, ahora=None, tolerancia=300) -> bool:
try:
if isinstance(timestamp, bytes):
timestamp = timestamp.decode("ascii")
if isinstance(firma, bytes):
firma = firma.decode("ascii")
if not isinstance(timestamp, str) or not isinstance(firma, str) or timestamp == "" or firma == "":
return False
# isascii() además de isdigit(): isdigit() acepta cifras como «²».
if not (timestamp.isascii() and timestamp.isdigit()):
return False
if ahora is None:
ahora = int(time.time())
if abs(ahora - int(timestamp)) > tolerancia:
return False
if not firma.startswith("sha256="):
return False
if isinstance(cuerpo, str):
cuerpo = cuerpo.encode("utf-8")
esperada = ("sha256=" + hmac.new(
secreto.encode("utf-8"),
timestamp.encode("ascii") + b"." + cuerpo,
hashlib.sha256,
).hexdigest()).encode("ascii")
recibida = firma.encode("utf-8")
if len(recibida) != len(esperada):
return False
return hmac.compare_digest(esperada, recibida)
except Exception:
return False
Cómo obtener el cuerpo crudo:
- PHP:
file_get_contents('php://input'). En Laravel,$request->getContent(). - Node.js: el cuerpo como
Buffer, tal como llega, sin pasar por ningún parser de JSON. - Python: los bytes del cuerpo, sin decodificar (por ejemplo,
request.get_data()).
Ojo con el reloj. Si el reloj de tu servidor está desviado, la comprobación de la tolerancia falla aunque el aviso sea auténtico. Tu servidor devolverá un error y Firmatia reintentará hasta agotar los intentos. Mantén el reloj sincronizado. Cuando una entrega agota sus intentos, la ficha del sobre en el panel muestra el evento delivery_failed con el motivo (por ejemplo, http_401). Ese mismo evento aparece en events al consultar GET /signature-requests/{uuid}.
Entrega fiable
Firmatia reintenta cada evento hasta entregarlo, dentro de un límite. A cambio, el mismo evento puede llegarte más de una vez. Por ejemplo, si tu servidor lo procesa y su respuesta se pierde por el camino. Prepárate así:
- Verifica la firma. Si no es válida, responde 401 y no hagas nada más.
- Guarda el evento deduplicando por
X-Webhook-Id. Usa una clave única en tu base de datos sobre esa columna, de forma que dos copias simultáneas no puedan entrar las dos. Si ya existía, es un duplicado: no lo proceses otra vez. Rechaza (400) un aviso sinX-Webhook-Id. Esa cabecera no va firmada: además, haz que el efecto de cada evento no se repita, con los datos del cuerpo, que sí van firmados (por ejemplo, no vuelvas a descargar ni a marcar como firmado un sobre que ya lo está). - Responde 2xx enseguida, después de guardarlo. Firmatia espera como máximo 15 segundos.
- Procesa aparte, en segundo plano: descargar el PDF, actualizar tu sistema, avisar a quien corresponda.
- No dependas del orden de llegada. El cuerpo de cada evento refleja el sobre en el momento del evento, y un aviso antiguo puede llegar tarde (por ejemplo, un
signer_signedreintentado después designed). No hagas retroceder un sobre que ya tengas en un estado final (signed,declined,expired), y si dudas, vuelve a leerlo conGET /signature-requests/{uuid}.
Un receptor mínimo en PHP, con la función de arriba:
<?php
$cuerpo = file_get_contents('php://input');
$valido = verificarWebhookFirmatia(
getenv('FIRMATIA_WEBHOOK_SECRET'),
$_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? null,
$_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null,
$cuerpo,
);
if (! $valido) {
http_response_code(401);
exit;
}
$webhookId = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';
if ($webhookId === '') {
http_response_code(400);
exit;
}
// La clave primaria en webhook_id descarta los duplicados de forma atómica.
$pdo = new PDO(getenv('DATABASE_DSN'));
$sentencia = $pdo->prepare(
'INSERT INTO webhooks_firmatia (webhook_id, cuerpo) VALUES (?, ?) ON CONFLICT (webhook_id) DO NOTHING'
);
$sentencia->execute([$webhookId, $cuerpo]);
// Responde ya; un proceso aparte trata los eventos guardados.
http_response_code(204);
Reintentos
Cualquier respuesta 2xx cuenta como entregada. Un 3xx, un 4xx, un 5xx, un error de red o superar los 15 segundos cuentan como fallo, y Firmatia reintenta.
En total hay hasta 6 intentos. Entre ellos espera 1 minuto, 5 minutos, 15 minutos, 1 hora y 2 horas: unas 3 horas y media desde el primero. Si todos fallan, la entrega se da por fallida y el sobre registra el evento delivery_failed.
Si tu servidor ha estado caído más tiempo, no pierdes el resultado: consúltalo con GET /signature-requests/{uuid}.
3. Descargar el PDF firmado
Cuando recibas el evento signed, descarga el PDF con GET /signature-requests/{uuid}/document. Es la misma URL que llega en el campo document_url del webhook. El webhook no trae credenciales: la descarga necesita el token de tu cliente de API, como cualquier otra llamada. Por eso es más seguro construir la URL con el uuid y tu URL base que seguir document_url a ciegas: así tu token nunca sale hacia otro sitio.
document_url llega en todos los eventos, pero solo hay PDF firmado tras signed. Descárgalo solo entonces.
Comprueba la integridad
La respuesta trae la cabecera X-Document-Sha256, con la huella SHA-256 del PDF. Debe coincidir con signed_hash, que tienes en el webhook (signature_request.signed_hash) y en GET /signature-requests/{uuid}. Para estar seguro, calcula tú también el SHA-256 de los bytes que has descargado y compáralo con signed_hash.
Respuestas que puedes recibir
| Respuesta | Qué significa |
|---|---|
| 200 | El PDF firmado (application/pdf). |
409 document_not_signed |
Todavía no hay PDF firmado. Es la respuesta normal si consultas antes de que llegue el webhook signed, y también en un sobre rechazado o caducado. |
409 integrity_check_failed |
El PDF guardado no supera la comprobación de integridad. Escríbenos a soporte@firmatia.es. |
404 not_found |
El sobre no existe. En el sandbox, también un sobre que ya se ha borrado al cumplir su plazo. |
410 envelope_purged |
En producción: el sobre se purgó al vencer su plazo de conservación. |
En tu lenguaje
curl:
UUID=019966e1-4a5b-7c6d-8e9f-0a1b2c3d4e5f
curl -sS --fail-with-body \
"https://sandbox.firmatia.es/api/v1/signature-requests/$UUID/document" \
-H "Authorization: Bearer $FIRMATIA_TOKEN" \
-D cabeceras.txt \
-o firmado.pdf
grep -i '^x-document-sha256' cabeceras.txt
sha256sum firmado.pdf
Las dos huellas deben coincidir entre sí y con signed_hash. En macOS, usa shasum -a 256 en lugar de sha256sum.
PHP (Guzzle):
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$uuid = '019966e1-4a5b-7c6d-8e9f-0a1b2c3d4e5f';
$signedHash = '...'; // signature_request.signed_hash del webhook
$cliente = new Client([
'base_uri' => 'https://sandbox.firmatia.es/api/v1/',
'timeout' => 60,
'headers' => ['Authorization' => 'Bearer ' . getenv('FIRMATIA_TOKEN')],
]);
$respuesta = $cliente->get("signature-requests/{$uuid}/document");
$pdf = (string) $respuesta->getBody();
if (hash('sha256', $pdf) !== $signedHash || $respuesta->getHeaderLine('X-Document-Sha256') !== $signedHash) {
throw new RuntimeException('El PDF descargado no coincide con signed_hash.');
}
file_put_contents("{$uuid}-firmado.pdf", $pdf);
Node.js:
const crypto = require('node:crypto');
const fs = require('node:fs');
async function descargarFirmado(uuid, signedHash) {
const respuesta = await fetch(`https://sandbox.firmatia.es/api/v1/signature-requests/${uuid}/document`, {
headers: { Authorization: `Bearer ${process.env.FIRMATIA_TOKEN}` },
signal: AbortSignal.timeout(60000),
});
if (!respuesta.ok) {
const error = await respuesta.json();
throw new Error(`Error ${respuesta.status}: ${error.code}`);
}
const pdf = Buffer.from(await respuesta.arrayBuffer());
const huella = crypto.createHash('sha256').update(pdf).digest('hex');
if (huella !== signedHash || respuesta.headers.get('X-Document-Sha256') !== signedHash) {
throw new Error('El PDF descargado no coincide con signed_hash.');
}
fs.writeFileSync(`${uuid}-firmado.pdf`, pdf);
}
Python:
import hashlib
import os
import requests
def descargar_firmado(uuid: str, signed_hash: str) -> None:
respuesta = requests.get(
f"https://sandbox.firmatia.es/api/v1/signature-requests/{uuid}/document",
headers={"Authorization": f"Bearer {os.environ['FIRMATIA_TOKEN']}"},
timeout=60,
)
respuesta.raise_for_status()
pdf = respuesta.content
if hashlib.sha256(pdf).hexdigest() != signed_hash or respuesta.headers.get("X-Document-Sha256") != signed_hash:
raise ValueError("El PDF descargado no coincide con signed_hash.")
with open(f"{uuid}-firmado.pdf", "wb") as f:
f.write(pdf)
Con esto ya tienes el ciclo completo: creas la petición, recibes el aviso firmado y guardas el PDF verificado.
4. Mientras firman
Ya has creado la petición de firma. Mientras el firmante decide, puedes consultar su estado, reenviarle el enlace o cancelarla. Las tres operaciones usan el uuid que te devolvió la creación, y valen igual para una petición de firma que para un sobre.
Consultar el estado
curl https://sandbox.firmatia.es/api/v1/signature-requests/019966e1-4a5b-7c6d-8e9f-0a1b2c3d4e5f \
-H "Authorization: Bearer $FIRMATIA_TOKEN"
La respuesta trae el campo status del sobre y, en signers, el estado de cada firmante. También trae events, la cronología de auditoría, y document_timestamp, el sello de tiempo del documento cuando el sobre se cierra.
status |
Qué significa |
|---|---|
draft |
Recién creado. El enlace de firma aún no ha salido. |
sent |
El enlace de firma ha salido. |
viewed |
El firmante ha abierto la página de firma. |
identity_pending |
El firmante ha pedido su código de verificación y aún no lo ha introducido. Solo en peticiones de un firmante que exigen verificación. |
identity_verified |
El firmante ha superado la verificación. Solo en peticiones de un firmante. |
signed |
Final. Han firmado todos. Ya puedes descargar el PDF firmado. |
declined |
Final. Un firmante ha rechazado el documento, o lo has cancelado tú. |
expired |
Final. Ha vencido la fecha límite (expires_at) sin completarse. |
error |
Final. El sobre no ha podido completarse por un fallo. Escríbenos con el uuid. |
No hay un estado cancelled: un sobre cancelado queda en declined.
No hace falta que consultes el estado en bucle. El webhook del apartado 2 te avisa cuando el sobre termina. Consulta el estado cuando necesites el detalle, o como red de seguridad si un webhook no te llega.
Reenviar el enlace
curl -X POST https://sandbox.firmatia.es/api/v1/signature-requests/019966e1-4a5b-7c6d-8e9f-0a1b2c3d4e5f/resend \
-H "Authorization: Bearer $FIRMATIA_TOKEN"
Envía un enlace nuevo a cada firmante que aún no ha terminado e invalida el anterior. En un sobre secuencial, solo lo recibe el firmante al que le toca. El envío va en segundo plano, así que la respuesta es un 202:
{
"message": "Enlace de firma reenviado."
}
Ten en cuenta:
- Cada firmante admite un número máximo de reenvíos en toda la vida del sobre. El primer envío no cuenta. Si todos los firmantes que esperan el enlace han llegado al tope, recibes un 429
resend_limit_reached. Esperar no lo repone. - Un sobre en estado final (
signed,declined,expiredoerror) no admite reenvíos: responde 409invalid_state.
Cancelar
curl -X POST https://sandbox.firmatia.es/api/v1/signature-requests/019966e1-4a5b-7c6d-8e9f-0a1b2c3d4e5f/cancel \
-H "Authorization: Bearer $FIRMATIA_TOKEN"
El sobre pasa a declined y, si tiene callback_url, recibes el webhook declined. Los enlaces de los firmantes pendientes dejan de funcionar. Puedes cancelar desde draft, sent, viewed, identity_pending o identity_verified. Desde un estado final responde 409 invalid_state.
La respuesta trae el sobre y el campo credit_refunded, que te dice si el crédito ha vuelto a tu bono:
{
"credit_refunded": false,
"data": {
"uuid": "019966e1-4a5b-7c6d-8e9f-0a1b2c3d4e5f",
"external_reference": "CONTRATO-2026-0042",
"template": null,
"controller": null,
"signature_level": "ses",
"signing_mode": "parallel",
"ses_type": "checkbox",
"status": "declined",
"signer": {
"name": "Juan Pérez",
"email": "juan@example.com",
"phone": null
},
"original_hash": "2b62788ce00d7927023e6fb1aaa41dfda93b56000d2a2bcb127b3563b263c047",
"signed_hash": null,
"worm_retained_at": null,
"callback_url": "https://tu-servidor.example.com/webhooks/firmatia",
"expires_at": "2026-10-20T09:30:00+00:00",
"created_at": "2026-10-06T09:30:00+00:00",
"updated_at": "2026-10-06T09:35:00+00:00"
}
}
El crédito solo vuelve si cancelas antes de que salga ningún enlace de firma y sin que nadie haya firmado. Una vez enviado el enlace, no se devuelve aunque nadie llegue a firmar.
Consultar el saldo
En producción, cada sobre consume créditos de un bono prepago. Consulta tu saldo así:
curl https://sandbox.firmatia.es/api/v1/balance \
-H "Authorization: Bearer $FIRMATIA_TOKEN"
En el sandbox no se consumen créditos. Por eso la respuesta llega con enforced: false y sin cifras:
{
"data": {
"enforced": false,
"ses": {
"available": null,
"level": "ok",
"next_expiry": null
},
"aes": {
"available": null,
"level": "ok",
"next_expiry": null
}
}
}
En producción, contra https://firmatia.es/api/v1, la misma respuesta trae tu saldo real:
{
"data": {
"enforced": true,
"ses": {
"available": 412,
"level": "ok",
"next_expiry": {
"at": "2027-03-01T22:59:59+00:00",
"amount": 112
}
},
"aes": {
"available": 0,
"level": "empty",
"next_expiry": null
}
}
}
available: créditos que puedes usar ahora mismo.level: semáforo del saldo:ok,low,criticaloempty.next_expiry: cuándo caduca el primer saldo pendiente y cuántos créditos caducan en ese momento.nullsi no te queda saldo.
Sin saldo: 402 insufficient_credits
Si creas un sobre en producción sin saldo suficiente del tipo que cuesta, la API responde 402 y no crea nada:
{
"message": "No hay saldo suficiente de créditos SES: este envío necesita 1 crédito. Solicita un bono para continuar.",
"code": "insufficient_credits"
}
Reintentar no sirve: hace falta un bono nuevo. Lo pide un administrador de tu empresa desde el panel, en «Bonos y consumo». En el sandbox este error no ocurre nunca.
5. Varios firmantes
Si el documento lo firman varias personas, crea un sobre con POST /signature-envelopes. En lugar de los campos signer_*, envías una lista signers, con el nombre y el correo de cada firmante. Elige el modo con signing_mode. En parallel, el valor por defecto, todos reciben el enlace a la vez y firman en cualquier orden. En sequential firman por turnos: solo el firmante del turno tiene el enlace activo y, al firmar, se avisa al siguiente. El orden lo marca signing_order, sin repetir números; si lo omites, se sigue el orden de la lista. El PDF final se cierra una sola vez, cuando han firmado todos.
{
"signature_level": "ses",
"signing_mode": "sequential",
"ses_type": "drawn",
"external_reference": "CONTRATO-2026-0043",
"callback_url": "https://tu-servidor.example.com/webhooks/firmatia",
"document_base64": "JVBERi0xLjcKJXNvYnJl",
"signers": [
{ "name": "Ana López", "email": "ana@example.com", "signing_order": 1 },
{ "name": "Beto Ruiz", "email": "beto@example.com", "signing_order": 2 }
]
}
El tipo de firma (ses_type) es del sobre, no de cada firmante. Con varios firmantes recibes además los webhooks signer_signed y signer_declined cada vez que uno firma o rechaza. El número máximo de firmantes y las opciones de cada uno están en la referencia: https://firmatia.es/docs.
6. Errores
Todos los errores que genera Firmatia tienen la misma forma: un message en español y un code estable.
{
"message": "Recurso no encontrado.",
"code": "not_found"
}
El message es orientativo y puede cambiar. Programa tu lógica con el code.
401: token ausente, inválido o caducado
Siempre llega con code unauthenticated. Si el token existía pero ha caducado, el message lo dice, con la fecha:
{
"message": "Token caducado el 15/03/2027 09:30 (hora de Madrid).",
"code": "unauthenticated"
}
Qué hacer:
- Comprueba que envías la cabecera
Authorization: Bearer <token>, con el token completo. - Comprueba que usas el token del entorno correcto: los del sandbox no valen en producción, ni al revés.
- Si ha caducado, pide a un administrador de tu empresa que emita un token nuevo desde el panel.
Cada token caduca a los 365 días de emitirse. Te avisamos 30 y 7 días antes. Al renovarlo desde el panel, el token antiguo sigue funcionando hasta siete días, sin pasar de su caducidad original, para que cambies al nuevo sin cortes: renuévalo con margen.
403: sin acceso
forbidden: el sobre es de otro cliente de API. Cada cliente de API solo ve sus propios sobres.client_inactive: tu cliente de API está desactivado, o tu empresa está suspendida o de baja. Habla con el administrador de tu empresa.
404, 409 y 410
not_found(404): eluuidno existe o la ruta está mal escrita. En el sandbox, también un sobre que ya se ha borrado.invalid_state(409): el sobre está en un estado que no admite la operación. Por ejemplo, reenviar o cancelar un sobre ya cerrado.document_not_signed(409): pides el PDF firmado de un sobre que aún no se ha cerrado. Espera al webhooksigned.integrity_check_failed(409): el PDF guardado no supera la comprobación de integridad. Escríbenos con eluuid.envelope_purged(410): el sobre se purgó al vencer su plazo de conservación. Es definitivo.
422: datos no válidos
Si un campo no es válido, el code es validation_failed y errors te dice qué falla en cada campo:
{
"message": "Los datos enviados no son válidos.",
"code": "validation_failed",
"errors": {
"callback_url": [
"La URL de callback_url no es un destino de webhook admitido: el host apunta a una dirección privada o reservada."
]
}
}
En los campos de una lista, la clave lleva la posición: signers.0.email es el correo del primer firmante.
Al crear un sobre hay otros 422, sin errors, porque no son de un campo concreto:
document_invalid: el PDF está dañado, incompleto o cifrado.branding_incomplete: tu empresa no tiene configurados el responsable del tratamiento y su correo de protección de datos. Complétalos en «Mi marca» o envía el objetocontrolleren el sobre.idempotency_conflict: has repetido unaIdempotency-Keycon una petición distinta.sms_not_enabled: has puesto teléfono a un firmante y tu empresa no tiene activados los SMS.phone_prefix_not_allowed: el teléfono de un firmante tiene un prefijo no permitido para tu empresa.aes_unavailable: la firma avanzada aún no está disponible.
429: demasiadas peticiones
rate_limited: has superado el límite de peticiones por minuto de tu cliente de API. Todas las respuestas llevanX-RateLimit-LimityX-RateLimit-Remaining. El 429 añadeRetry-After, con los segundos que debes esperar, yX-RateLimit-Reset.resend_limit_reached: has agotado los reenvíos del enlace. No llevaRetry-After: esperar no lo repone.sandbox_daily_limit,sandbox_storage_quotaysandbox_capacity: límites del sandbox al crear un sobre (ver el apartado 7). LlevanRetry-After.
Resumen
| HTTP | code |
Qué hacer |
|---|---|---|
| 401 | unauthenticated |
Revisa la cabecera y el entorno. Si el token caducó, emite uno nuevo. |
| 402 | insufficient_credits |
Pide un bono. No reintentes. |
| 402 | insufficient_sms_credits |
Quita el teléfono de algún firmante o pide un bono de SMS. |
| 403 | forbidden |
Usa el cliente de API que creó el sobre. |
| 403 | client_inactive |
Habla con el administrador de tu empresa. |
| 404 | not_found |
Revisa el uuid y la ruta. |
| 405 | method_not_allowed |
Usa el método HTTP que indica la referencia para esa ruta. |
| 409 | invalid_state |
Consulta el estado del sobre antes de actuar. |
| 409 | document_not_signed |
Espera a que el sobre esté signed. |
| 409 | integrity_check_failed |
Escríbenos con el uuid. |
| 410 | envelope_purged |
Nada: el sobre ya no existe. |
| 413 | payload_too_large |
Reduce el tamaño del PDF. |
| 422 | validation_failed |
Corrige los campos que indica errors. |
| 422 | document_invalid |
Envía un PDF sin daños y sin cifrar. |
| 422 | branding_incomplete |
Completa «Mi marca» o envía controller. |
| 422 | idempotency_conflict |
Usa una Idempotency-Key nueva para cada sobre distinto. |
| 422 | sms_not_enabled |
Quita el teléfono o pide que activemos los SMS. |
| 422 | phone_prefix_not_allowed |
Usa un teléfono con un prefijo permitido. |
| 422 | aes_unavailable |
Usa ses. |
| 4xx | http_error |
Revisa la petición: es un error 4xx que no encaja en ningún otro código. |
| 429 | rate_limited |
Espera los segundos de Retry-After. |
| 429 | resend_limit_reached |
No reenvíes más. Si hace falta, crea un sobre nuevo. |
| 429 | sandbox_daily_limit, sandbox_storage_quota, sandbox_capacity |
Espera los segundos de Retry-After. |
| 500 | internal_error |
Reintenta más tarde. Si se repite, escríbenos. |
| 503 | service_unavailable |
Reintenta más tarde. |
Algunos errores del servidor, como un 413, un 502 o un 504, pueden llegar en HTML en lugar de JSON. Tu código no debe dar por hecho que todo error es JSON.
7. Sandbox y producción
Firmatia tiene dos entornos separados, cada uno con sus cuentas, sus tokens y sus datos. Lo que creas en uno no se ve en el otro. La API es la misma en los dos: mismas rutas, mismos campos y mismos códigos de error.
| Sandbox | Producción | |
|---|---|---|
| URL base | https://sandbox.firmatia.es/api/v1 |
https://firmatia.es/api/v1 |
| Uso | Solo pruebas | Firmas reales |
| Créditos | No se consumen | Cada sobre consume de tu bono |
Lo que cambia en el sandbox
- Tamaño del PDF. El máximo es más bajo que en producción.
- Límite diario. Cada empresa puede crear un número limitado de sobres al día. Al superarlo, la API responde 429
sandbox_daily_limit. El contador se reinicia a las 00:00, hora de Madrid. - Almacenamiento. Cada empresa tiene una cuota de espacio (429
sandbox_storage_quota), y el entorno entero tiene una capacidad máxima (429sandbox_capacity). - Purga. Los sobres se borran solos pasado un plazo, con sus documentos, sus firmantes y sus evidencias. No se pueden recuperar.
- Sin SMS. Las invitaciones y los códigos de verificación llegan siempre por correo, aunque el firmante tenga teléfono.
- Marcas de prueba. El PDF firmado lleva la marca de agua «SANDBOX» en cada página y el aviso «DOCUMENTO DE PRUEBA: NO VÁLIDO PARA OPERACIONES REALES» en el certificado. Los correos llevan «[PRUEBA]» en el asunto. El sello de tiempo no es cualificado.
Las cifras exactas de cada límite están en https://firmatia.es/sandbox.
Usa en el sandbox solo datos ficticios o tus propios datos. Nunca documentos reales ni datos de terceros.
Cómo saber en qué entorno estás
Cada respuesta de la API lleva la cabecera X-Firmatia-Environment, con el valor sandbox o production. Cada webhook lleva el campo environment con el mismo valor. Compruébalo en tu integración: así detectas a tiempo si apuntas al entorno equivocado.
Pasar a producción
Tu código no cambia. Cambia la configuración:
- Cuenta nueva. Las credenciales del sandbox no sirven en producción. Pide tu cuenta desde el formulario de contacto de https://firmatia.es.
- URL base. Cambia a
https://firmatia.es/api/v1. - Token nuevo. Crea un cliente de API en el panel de producción y emite su token.
- Secreto de webhook nuevo. El del cliente de API de producción. Con el del sandbox, las firmas de los webhooks no cuadrarán.
- Saldo. En producción cada sobre consume créditos. Antes de recibir saldo, tu empresa firma una orden de pedido. Comprueba tu saldo con
GET /balance. - «Mi marca». Completa el responsable del tratamiento y su correo de protección de datos, o envía
controlleren cada sobre. Si no, la creación responde 422branding_incomplete. callback_url. Revisa que apunte a tu servidor de producción, conhttpsy accesible desde internet.- Comprobación. Verifica que tus respuestas llegan con
X-Firmatia-Environment: production.
Guarda la URL base, el token y el secreto de webhook en la configuración de tu aplicación, nunca en el código.
8. Conectores y soporte
Estamos preparando un nodo para n8n. Mientras tanto, puedes usar la API desde cualquier herramienta con un nodo HTTP: basta con añadir la cabecera Authorization: Bearer <token>.
Para probar la API sin escribir código, usa la especificación OpenAPI. Importa el YAML del host que vayas a usar en Postman, Insomnia o Bruno: https://sandbox.firmatia.es/docs/openapi.yaml para el sandbox o https://firmatia.es/docs/openapi.yaml para producción; el cliente apuntará al servidor correcto.
La referencia completa de la API, con todos los campos y errores, está en https://firmatia.es/docs.
¿No has encontrado lo que buscabas? Escríbenos a soporte@firmatia.es.
Actualizado el 6/10/2026