La API de EasyForms.
Todo lo que haces en el dashboard también está disponible vía REST. URL base: https://easyfrm.dev.
34 requests, 7 carpetas, scripts que auto-guardan tu apiKey, formId y publicId entre llamadas.
Introducción
EasyForms expone tres espacios de URL:
| Prefijo | Auth | Para qué |
|---|---|---|
| /f/:publicId | ninguna | Endpoint público de envío — embeber en el HTML del formulario. |
| /api/v1/* | Bearer key o sesión | API de gestión. |
| /auth/* | varía | Signup, login, OAuth, reset de contraseña. |
Content types
- La API de gestión consume y devuelve
application/json. - El endpoint de envío acepta
application/json,application/x-www-form-urlencodedymultipart/form-data(con ficheros en plan Pro).
Autenticación
Crea una API key desde /app/apikeys o vía POST /api/v1/apikeys. Las claves empiezan por ef_ y se muestran una sola vez al crearlas.
# Recomendado Authorization: Bearer ef_RkgetyqI2HRMYMTi6NFzmqZNE75XKsxSKUZi5zyB # También aceptado X-API-Key: ef_RkgetyqI2HRMYMTi6NFzmqZNE75XKsxSKUZi5zyB
Scope: cuenta vs formulario
| Scope | Qué puede hacer |
|---|---|
| account | Acceso total: crear/editar/borrar cualquier formulario, gestionar claves, ver envíos de todos los formularios, leer uso y facturación. |
| form | Acceso a un único formulario. Puede leer su config, listar sus envíos, hacer previews. No puede crear claves, ver otros formularios ni tocar facturación. |
Sesión del navegador
El dashboard usa una cookie firmada HttpOnly (ef_session) emitida por POST /auth/login. Las peticiones desde el mismo origen se autentican automáticamente.
Rate limiting
- Endpoint de envío: 30 peticiones/min por (IP, formulario). Excedido →
429 rate_limited. - API de gestión: sin límite estricto por defecto. El abuso se mitiga por
plan.monthlySubmissionsy porplan.maxApikeys.
Códigos de error
Toda respuesta de error es JSON con la forma {"error": "<slug>", …}.
| HTTP | error | Causa |
|---|---|---|
| 400 | invalid_input | El body no pasó la validación zod. Incluye issues. |
| 400 | invalid_body | Body del envío no parseable. |
| 400 | captcha_required / captcha_failed | Captcha mal o ausente. |
| 401 | unauthorized | Sin sesión ni Bearer. |
| 401 | invalid_apikey | Bearer revocada o inexistente. |
| 401 | invalid_credentials | Login con email/password incorrecto. |
| 402 | apikey_limit_reached | Límite plan.maxApikeys. |
| 402 | monthly_quota_exceeded | Límite plan.monthlySubmissions. |
| 402 | uploads_not_on_plan | El plan no permite ficheros. |
| 402 | csv_export_not_on_plan | Export CSV no disponible en Free. |
| 403 | requires_account_scope | Una key de scope form intentó algo global. |
| 403 | apikey_not_authorized_for_form | Key de form distinto. |
| 404 | form_not_found / submission_not_found / apikey_not_found | — |
| 409 | email_taken | Signup con email existente. |
| 413 | upload_too_large | Sobre plan.maxUploadBytes. Incluye maxHuman. |
| 422 | validation_failed | El envío no cumple fieldSchema. Incluye issues. |
| 422 | recipients_limit_exceeded | Sobre plan.maxRecipients. |
| 429 | rate_limited | 30 req/min/IP/form. |
| 500 | internal_error | Bug — abre issue. |
| 503 | storage_not_configured | S3 sin configurar. |
| 503 | provider_not_configured | OAuth/Stripe sin claves. |
Envío público
El núcleo de EasyForms. Cada formulario tiene un publicId corto (10 caracteres) y una URL pública del tipo /f/:publicId. Sin autenticación: pensada para embeber.
POST/f/:publicIdpúblico
Recibe un envío. Acepta JSON, urlencoded o multipart.
Cuerpo de la petición
Cualquier campo. Si el formulario tiene fieldSchema, los campos declarados son validados y type-coerced.
Ejemplo · HTML
<form action="https://easyfrm.dev/f/Crp6zkfAJV" method="POST"> <input name="email" type="email" required> <input name="message" required> <!-- honeypot — los bots lo rellenan, lo descartamos --> <input name="_url" tabindex="-1" autocomplete="off" style="display:none"> <button>Enviar</button> </form>
Ejemplo · JSON (fetch)
fetch('https://easyfrm.dev/f/Crp6zkfAJV', {
method: 'POST',
headers: {
'content-type': 'application/json',
'accept': 'application/json',
},
body: JSON.stringify({
email: 'alice@example.com',
message: 'Hola!',
}),
}).then(r => r.json());{ "ok": true, "id": "sub_xyz123" }Si el Accept no es application/json y el formulario tiene config.redirectOkUrl, la respuesta es 302 a esa URL.
Códigos de respuesta
| Código | Cuándo |
|---|---|
| 200 | Envío aceptado. |
| 302 | Redirige a config.redirectOkUrl cuando el cliente no pide JSON. |
| 400 | invalid_body / captcha_required / captcha_failed. |
| 402 | monthly_quota_exceeded. |
| 404 | form_not_found — publicId inexistente o archivado. |
| 413 | upload_too_large en algún campo file. |
| 422 | validation_failed con issues. |
| 429 | rate_limited. |
OPTIONS/f/:publicIdpúblico
Preflight CORS. Devuelve los headers Access-Control-*.
Access-Control-Allow-Origin: * (o el Origin si está en allowedOrigins) Access-Control-Allow-Methods: POST, OPTIONS Access-Control-Allow-Headers: content-type, accept Access-Control-Max-Age: 86400
POST/f/:publicId/upload-urlpúblico · plan Pro
Genera una URL de subida directa a S3 (presigned PUT) para ficheros más grandes que un multipart inline.
Cuerpo de la petición
| Campo | Tipo | Descripción |
|---|---|---|
| filename | string | requerido · Nombre original del fichero. |
| contentType | string | opcional · MIME type, p.ej. image/png. |
| size | integer | opcional · Tamaño en bytes (se valida contra el plan antes de firmar). |
Ejemplo
POST /f/Crp6zkfAJV/upload-url
{ "filename": "screenshot.png", "contentType": "image/png", "size": 184320 }{
"uploadUrl": "https://s3.example.com/...&X-Amz-Signature=...",
"key": "forms/frm_…/2026/abcd1234-screenshot.png",
"expiresIn": 600,
"maxBytes": 26214400
}Sube el binario con PUT a uploadUrl dentro de 10 minutos. Luego envía la key como un campo normal en tu envío principal.
Códigos de respuesta
| Código | Cuándo |
|---|---|
| 200 | OK. Devuelve uploadUrl, key, maxBytes. |
| 400 | invalid_input. |
| 402 | uploads_not_on_plan. |
| 413 | upload_too_large. La respuesta incluye maxBytes y maxHuman. |
| 503 | storage_not_configured. |
Honeypot & captcha
- Honeypot: declara
config.honeypotField: "_url". Cualquier envío con ese campo no vacío devuelve200silencioso y no se cuenta. - hCaptcha (Pro): añade
config.captchacon sitekey + secret. Envíah-captcha-responsejunto al resto de campos.
CORS & orígenes
config.allowedOrigins vacío → devolvemos Access-Control-Allow-Origin: *. Con orígenes en la lista → solo respondemos con ese origen si el Origin coincide.
Subida de ficheros
Dos opciones:
- Multipart inline: cualquier
<input type="file">en tu HTML. El tamaño se valida contraplan.maxUploadBytes. En el envío almacenado los ficheros se sustituyen por un stub:{ "_file": true, "name": "factura.pdf", "size": 524288, "type": "application/pdf" } - Presigned S3: ver
POST /f/:publicId/upload-url.
Cuenta
POST/auth/signuppúblico
Cuerpo de la petición
| Campo | Tipo | Descripción |
|---|---|---|
| string | requerido · Email único. | |
| password | string | requerido · Mínimo 8 caracteres. |
| name | string | opcional · Nombre del usuario. |
Ejemplo
{ "email": "tu@ejemplo.com", "password": "secreto123", "name": "Alice" }{ "ok": true, "userId": "usr_…", "emailVerificationSent": true }Códigos de respuesta
| Código | Significado |
|---|---|
| 200 | Usuario creado. Email de verificación enviado. |
| 400 | invalid_input. |
| 409 | email_taken. |
GET/auth/verify?token=…público
Consume el token de verificación de email (un solo uso, TTL 24h).
POST/auth/loginpúblico
Cuerpo
| Campo | Tipo | Descripción |
|---|---|---|
| string | requerido. | |
| password | string | requerido. |
Ejemplo
{ "email": "tu@ejemplo.com", "password": "secreto123" }{
"ok": true,
"user": { "id": "usr_…", "email": "tu@ejemplo.com", "planSlug": "free", "emailVerified": true }
}Establece la cookie ef_session. Devuelve 401 invalid_credentials si no.
POST/auth/logoutsesión
Borra la sesión y la cookie.
GET/auth/me
Devuelve el usuario actual o {"user": null} si no hay sesión.
POST/auth/forgotpúblico
Solicita un email con enlace de reset. Siempre responde {"ok": true} (no filtramos si el email existe).
POST/auth/resetpúblico
Cuerpo
| Campo | Tipo | Descripción |
|---|---|---|
| token | string | requerido · Recibido por email. |
| password | string | requerido · Nueva contraseña (mín. 8). |
GET/auth/oauth/:providerpúblico
Inicia el flujo OAuth. :provider = google | github. Devuelve 503 provider_not_configured si faltan client id/secret en el servidor.
Formularios
Cada formulario tiene un id interno (frm_…) y un public id (Crp6zkfAJV) para la URL pública. Una key de scope form solo accede a su formulario.
GET/api/v1/formsaccount/form
Query params
| Param | Tipo | Descripción |
|---|---|---|
| tag | string | opcional · Filtrar por tag exacto. |
| q | string | opcional · LIKE sobre name y publicId. |
Ejemplo
curl -H "Authorization: Bearer ef_…" \ "https://easyfrm.dev/api/v1/forms?tag=leads"
{
"forms": [
{
"id": "frm_yau0t67tuMoO2TBSdnt-",
"publicId": "Crp6zkfAJV",
"name": "Contacto",
"tags": ["leads"],
"config": { /* ver Referencia · Form config */ },
"fieldSchema":{ /* ver Referencia · Field schema */ },
"archivedAt": null,
"createdAt": "2026-05-26T18:47:41.000Z",
"submitUrl": "/f/Crp6zkfAJV"
}
]
}POST/api/v1/formsaccount
Cuerpo
| Campo | Tipo | Descripción |
|---|---|---|
| name | string | requerido · Hasta 120 chars. |
| tags | string[] | opcional · Hasta 20 tags. |
| config | object | opcional · Ver Form config. |
| fieldSchema | object | opcional · Ver Field schema. |
Ejemplo
{
"name": "Contacto",
"tags": ["leads"],
"config": {
"recipients": ["me@example.com"],
"honeypotField": "_url",
"redirectOkUrl": "https://example.com/gracias",
"subjectTemplate":"Nuevo lead de {{data.email}}",
"replyToField": "email"
},
"fieldSchema": {
"fields": [
{ "name": "email", "type": "email", "required": true },
{ "name": "message", "type": "string", "required": true, "min": 3 }
]
}
}{
"id": "frm_…",
"publicId": "Crp6zkfAJV",
"name": "Contacto",
"tags": ["leads"],
"config": { … },
"fieldSchema":{ … },
"submitUrl": "/f/Crp6zkfAJV"
}Códigos de respuesta
| Código | Significado |
|---|---|
| 201 | Creado. |
| 400 | invalid_input. |
| 402 | form_limit_reached. |
| 422 | recipients_limit_exceeded / captcha_not_available_on_plan. |
GET/api/v1/forms/:idaccount/form
Devuelve el formulario completo. Una key de scope form solo puede pedir su propio formulario.
PATCH/api/v1/forms/:idaccount
Update parcial. Envía solo los campos a cambiar. Pasa {"archived": true} para archivar.
Ejemplo
{ "config": { "recipients": ["nuevo@example.com"] } }DELETE/api/v1/forms/:idaccount
Borrado duro. Elimina el formulario, todos sus envíos y deliveries pendientes.
POST/api/v1/forms/:id/test-renderaccount/form
Renderiza el email que produciría el formulario (vía EasyRender si está configurado, si no la plantilla por defecto) sin almacenar nada.
Cuerpo
{ "sample_data": { "email": "preview@example.com", "message": "Test" } }Ejemplo
{
"source": "easyrender" | "default",
"subject": "Nuevo lead de preview@example.com",
"replyTo": "preview@example.com",
"html": "<!doctype html>…",
"derived": { /* derivados calculados */ },
"conditional": { "tags": [], "skipEmail": false, "skipWebhook": false }
}POST/api/v1/forms/:id/test-webhookaccount
Envía un POST real al webhook configurado con datos de prueba. Útil para validar firmas y conectividad.
Ejemplo
{
"status": 200,
"ok": true,
"responseBodyExcerpt": "ok",
"payloadSent": "{\"submissionId\":\"test_submission\",…"
}Envíos
Cada envío se guarda según el retentionDays del plan. Un job diario borra los caducados.
GET/api/v1/forms/:id/submissionsaccount/form
Query params
| Param | Tipo | Descripción |
|---|---|---|
| from | ISO date | opcional · Mínimo createdAt. |
| to | ISO date | opcional · Máximo createdAt. |
| status | enum | opcional · received | spam | invalid. |
| limit | integer | opcional · Default 50, máx 200. |
Ejemplo
{
"submissions": [
{
"id": "sub_…",
"formId": "frm_…",
"data": { "email": "alice@x.com", "message": "Hola" },
"derived": {},
"meta": { "ip": "1.2.3.4", "userAgent": "…", "receivedAt": 1779821427853 },
"tags": ["spain"],
"status": "received",
"expiresAt": "2026-06-25T18:50:27.853Z",
"createdAt": "2026-05-26T18:50:27.000Z"
}
]
}GET/api/v1/forms/:id/submissions.csvaccount/form
Stream del CSV. Columnas: id, createdAt, tags, status, <campos declarados>, <extras>. Solo planes con features.csvExport activado.
GET/api/v1/submissions/:idaccount/form
Envío único.
DELETE/api/v1/submissions/:idaccount
POST/api/v1/submissions/:id/resend-emailaccount
Re-encola un email usando la config actual del formulario. Útil tras cambiar recipients o plantilla.
POST/api/v1/submissions/:id/resend-webhookaccount
Re-encola un webhook. Devuelve 400 webhook_not_configured si no hay URL configurada.
Webhooks
Si config.webhook.url está definido, cada envío aceptado lanza un POST en background. Reintentos en respuestas no-2xx con backoff 1m → 5m → 30m → 2h → 12h. Tras 5 intentos queda como giving_up.
Payload por defecto
POST <webhook.url>
content-type: application/json
user-agent: EasyForms-Webhook/1.0
X-EasyForms-Event: submission.created
X-EasyForms-Submission-Id: sub_…
X-EasyForms-Delivery-Attempt: 1
X-EasyForms-Signature: sha256=… ← solo si config.webhook.secret está definido
{
"submissionId": "sub_…",
"formId": "frm_…",
"formPublicId": "Crp6zkfAJV",
"data": { "email": "alice@x.com", "message": "Hola" },
"derived": {},
"meta": { "ip": "1.2.3.4", "userAgent": "…", "receivedAt": 1779821427853 },
"tags": [],
"createdAt": "2026-05-26T18:50:27.000Z"
}Plantilla custom
Define config.webhook.payloadTemplate como una plantilla Handlebars — su output sustituye al payload por defecto. Útil para Slack, Discord, Teams.
{
"text": "Nuevo lead {{data.email}} — {{data.message}}",
"channel": "#leads"
}La firma se calcula sobre el cuerpo final renderizado.
Firma HMAC
Si defines config.webhook.secret, cada petición incluye X-EasyForms-Signature: sha256=<hex>. Ver Verificar firma para el código.
GET/api/v1/forms/:id/webhook-deliveriesaccount/form
Últimos 100 intentos de entrega (más recientes primero).
Ejemplo
{
"deliveries": [
{
"id": "whd_…",
"submissionId": "sub_…",
"attempt": 1,
"status": "success" | "pending" | "failed" | "giving_up",
"responseCode": 200,
"responseBodyExcerpt": "ok",
"scheduledAt": "…",
"executedAt": "…",
"error": null
}
]
}Emails
Si el formulario tiene al menos un destinatario, cada envío dispara un email vía EasyEmails. El cuerpo lo renderiza EasyRender cuando config.easyrender.enabled = true y hay templateId — si no, una plantilla por defecto.
Subject & Reply-To: precedencia
- Lo que devuelva EasyRender (
subject,reply_to). config.subjectTemplate(Handlebars) ydata[config.replyToField].- Por defecto:
"New submission · <form name>"y sin reply-to.
Override de apikeys
Cada formulario puede usar sus propias apikeys en lugar de las del servicio:
config.easyemailApikeyOverride— envía con tu cuenta EasyEmails (From con tu dominio verificado).config.easyrender.apikeyOverride— renderiza con tu cuenta EasyRender.
GET/api/v1/forms/:id/email-deliveriesaccount/form
Últimos 100 envíos de email.
Ejemplo
{
"deliveries": [
{
"id": "eml_…",
"submissionId": "sub_…",
"status": "success" | "pending" | "failed",
"providerId": "<message id de EasyEmails>",
"error": null,
"executedAt": "…",
"createdAt": "…"
}
]
}API Keys
GET/api/v1/apikeysaccount
{
"apikeys": [
{
"id": "key_…",
"name": "producción",
"scope": "account" | "form",
"formId": null,
"prefix": "ef_Rkgetyq",
"lastUsedAt": "2026-05-26T18:43:46.000Z",
"revokedAt": null,
"createdAt": "2026-05-26T18:43:46.000Z"
}
]
}POST/api/v1/apikeysaccount
Cuerpo · scope account
{ "scope": "account", "name": "producción" }Cuerpo · scope form
{ "scope": "form", "name": "contacto", "formId": "frm_yau0t67tuMoO2TBSdnt-" }Ejemplo
{
"id": "key_…",
"name": "producción",
"scope": "account",
"formId": null,
"prefix": "ef_Rkgetyq",
"key": "ef_RkgetyqI2HRMYMTi6NFzmqZNE75XKsxSKUZi5zyB",
"_notice": "Store this key now. It will not be shown again."
}Devuelve 402 apikey_limit_reached al alcanzar plan.maxApikeys.
DELETE/api/v1/apikeys/:idaccount
Revoca la clave (soft delete). Deja de autenticar inmediatamente.
Facturación
GET/api/v1/usage
{
"period": "2026-05",
"plan": { "slug": "pro", "name": "Pro" },
"submissions": { "count": 1247, "limit": 50000, "remaining": 48753 },
"limits": { /* PlanLimits */ },
"features": { /* PlanFeatures */ }
}GET/api/v1/billing/plansaccount
Catálogo público de planes.
{
"plans": [
{
"slug": "pro",
"name": "Pro",
"priceCents": 2900,
"stripeAvailable": true,
"limits": { /* PlanLimits */ },
"features": { /* PlanFeatures */ }
}
]
}POST/api/v1/billing/checkoutaccount
Cuerpo
{ "plan": "pro" }Ejemplo
{ "id": "cs_…", "url": "https://checkout.stripe.com/c/pay/…" }Redirige al usuario a url. Al pagar, los webhooks de Stripe actualizan su planSlug.
POST/api/v1/billing/portalaccount
Abre el Billing Portal de Stripe. Devuelve 400 no_stripe_customer si aún no ha hecho checkout.
Handlebars
Introducción
Handlebars está disponible en:
config.subjectTemplate— Subject del email.config.webhook.payloadTemplate— Body del webhook.fieldSchema.derived[].template— Campos derivados.config.conditionalRules[].if— Expresiones lógicas.
Contexto disponible: data.*, derived.*, meta.*, form.{id,name,publicId,tags}.
Comparadores
| Helper | Devuelve | Ejemplo |
|---|---|---|
| eq | boolean | {{#if (eq data.country "ES")}}…{{/if}} |
| neq | boolean | (neq data.status "spam") |
| gt / gte | boolean | (gt data.amount 100) |
| lt / lte | boolean | (lt data.amount 100) |
| contains | boolean | String o array contiene needle. |
| match | boolean | (match data.email ".+@example\\.com") |
Strings & otros
| Helper | Ejemplo |
|---|---|
| lower / upper | {{lower data.email}} |
| json | {{json data}} — útil en webhooks. |
| default | {{default data.name "Anónimo"}} |
Subexpresiones
Combina helpers con paréntesis:
{{#if (and (eq data.country "ES") (gt data.amount 1000))}}
Lead grande de España
{{/if}}Solo and/or nativos de Handlebars no están incluidos por defecto — anida {{#if (eq ...)}}{{#if (gt ...)}}…{{/if}}{{/if}}.
Referencia
Form config
Forma completa de form.config. Todos los campos son opcionales.
{
"recipients": ["me@example.com"], // capado por plan.maxRecipients
"subjectTemplate": "{{data.name}}", // Handlebars (1 línea)
"replyToField": "email", // nombre del campo a usar como Reply-To
"redirectOkUrl": "https://example.com/ok",
"redirectKoUrl": "https://example.com/error",
"honeypotField": "_url",
"allowedOrigins": ["https://example.com"],
"easyemailApikeyOverride": "ee_…",
"easyrender": {
"enabled": true,
"templateId": "tpl_…",
"apikeyOverride": "er_…"
},
"webhook": {
"url": "https://example.com/hook",
"secret": "shared-secret",
"headers": { "X-Trace-Id": "abc" },
"payloadTemplate": "{ \"text\": \"{{data.email}}\" }"
},
"captcha": {
"provider": "hcaptcha",
"sitekey": "10000000-ffff-…",
"secret": "ES_…"
},
"conditionalRules": [
{
"if": "(eq data.country \"ES\")",
"then": { "tags": ["spain"], "skipWebhook": false, "skipEmail": false }
}
]
}Field schema
{
"fields": [
{
"name": "email",
"label": "Correo",
"type": "string|number|boolean|email|url|enum",
"required": true,
"enum": ["a","b"],
"pattern": "^[A-Z].*",
"min": 3,
"max": 280,
"includeInEmail": true,
"includeInWebhook": true,
"redacted": false
}
],
"derived": [
{ "name": "domain", "template": "{{lower data.email}}", "type": "string" }
]
}Derived & conditional
Los derived se calculan una vez por envío y se guardan en submission.derived:
"derived": [
{ "name": "full_name", "template": "{{data.first}} {{data.last}}" },
{ "name": "is_eu", "template": "{{#if (contains \"ES,FR,DE,IT,PT\" data.country)}}true{{else}}false{{/if}}", "type": "boolean" }
]Los conditional rules evalúan expresiones Handlebars y aplican tags o saltan canales:
"conditionalRules": [
{ "if": "(eq data.country \"ES\")", "then": { "tags": ["spain"] } },
{ "if": "(gt data.amount 1000)", "then": { "tags": ["big-deal"] } },
{ "if": "(match data.email \".*@spam\\\\.com\")", "then": { "skipEmail": true, "skipWebhook": true } }
]Verificar la firma del webhook
Calcula el HMAC-SHA256 del cuerpo crudo con tu secret, en tiempo constante:
Node / Bun
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, signatureHeader, secret) {
if (!signatureHeader?.startsWith('sha256=')) return false;
const received = signatureHeader.slice('sha256='.length);
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}Python
import hmac, hashlib
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
if not signature.startswith("sha256="):
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature[7:], expected)PHP
function verify(string $raw, string $signature, string $secret): bool {
if (!str_starts_with($signature, 'sha256=')) return false;
$expected = hash_hmac('sha256', $raw, $secret);
return hash_equals(substr($signature, 7), $expected);
}