Documentación · API v1

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:

PrefijoAuthPara qué
/f/:publicIdningunaEndpoint público de envío — embeber en el HTML del formulario.
/api/v1/*Bearer key o sesiónAPI de gestión.
/auth/*varíaSignup, 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-urlencoded y multipart/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

ScopeQué puede hacer
accountAcceso total: crear/editar/borrar cualquier formulario, gestionar claves, ver envíos de todos los formularios, leer uso y facturación.
formAcceso 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.monthlySubmissions y por plan.maxApikeys.

Códigos de error

Toda respuesta de error es JSON con la forma {"error": "<slug>", …}.

HTTPerrorCausa
400invalid_inputEl body no pasó la validación zod. Incluye issues.
400invalid_bodyBody del envío no parseable.
400captcha_required / captcha_failedCaptcha mal o ausente.
401unauthorizedSin sesión ni Bearer.
401invalid_apikeyBearer revocada o inexistente.
401invalid_credentialsLogin con email/password incorrecto.
402apikey_limit_reachedLímite plan.maxApikeys.
402monthly_quota_exceededLímite plan.monthlySubmissions.
402uploads_not_on_planEl plan no permite ficheros.
402csv_export_not_on_planExport CSV no disponible en Free.
403requires_account_scopeUna key de scope form intentó algo global.
403apikey_not_authorized_for_formKey de form distinto.
404form_not_found / submission_not_found / apikey_not_found—
409email_takenSignup con email existente.
413upload_too_largeSobre plan.maxUploadBytes. Incluye maxHuman.
422validation_failedEl envío no cumple fieldSchema. Incluye issues.
422recipients_limit_exceededSobre plan.maxRecipients.
429rate_limited30 req/min/IP/form.
500internal_errorBug — abre issue.
503storage_not_configuredS3 sin configurar.
503provider_not_configuredOAuth/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
Petición
<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)
Petición
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());
Respuesta200
{ "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ódigoCuándo
200Envío aceptado.
302Redirige a config.redirectOkUrl cuando el cliente no pide JSON.
400invalid_body / captcha_required / captcha_failed.
402monthly_quota_exceeded.
404form_not_found — publicId inexistente o archivado.
413upload_too_large en algún campo file.
422validation_failed con issues.
429rate_limited.

OPTIONS/f/:publicIdpúblico

Preflight CORS. Devuelve los headers Access-Control-*.

Respuesta204
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
CampoTipoDescripción
filenamestringrequerido · Nombre original del fichero.
contentTypestringopcional · MIME type, p.ej. image/png.
sizeintegeropcional · Tamaño en bytes (se valida contra el plan antes de firmar).
Ejemplo
Petición
POST /f/Crp6zkfAJV/upload-url
{ "filename": "screenshot.png", "contentType": "image/png", "size": 184320 }
Respuesta200
{
  "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ódigoCuándo
200OK. Devuelve uploadUrl, key, maxBytes.
400invalid_input.
402uploads_not_on_plan.
413upload_too_large. La respuesta incluye maxBytes y maxHuman.
503storage_not_configured.

Honeypot & captcha

  • Honeypot: declara config.honeypotField: "_url". Cualquier envío con ese campo no vacío devuelve 200 silencioso y no se cuenta.
  • hCaptcha (Pro): añade config.captcha con sitekey + secret. Envía h-captcha-response junto 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:

  1. Multipart inline: cualquier <input type="file"> en tu HTML. El tamaño se valida contra plan.maxUploadBytes. En el envío almacenado los ficheros se sustituyen por un stub:
    {
      "_file": true,
      "name":  "factura.pdf",
      "size":  524288,
      "type":  "application/pdf"
    }
  2. Presigned S3: ver POST /f/:publicId/upload-url.

Cuenta

POST/auth/signuppúblico

Cuerpo de la petición
CampoTipoDescripción
emailstringrequerido · Email único.
passwordstringrequerido · Mínimo 8 caracteres.
namestringopcional · Nombre del usuario.
Ejemplo
Petición
{ "email": "tu@ejemplo.com", "password": "secreto123", "name": "Alice" }
Respuesta200
{ "ok": true, "userId": "usr_…", "emailVerificationSent": true }
Códigos de respuesta
CódigoSignificado
200Usuario creado. Email de verificación enviado.
400invalid_input.
409email_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
CampoTipoDescripción
emailstringrequerido.
passwordstringrequerido.
Ejemplo
Petición
{ "email": "tu@ejemplo.com", "password": "secreto123" }
Respuesta200
{
  "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
CampoTipoDescripción
tokenstringrequerido · Recibido por email.
passwordstringrequerido · 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
ParamTipoDescripción
tagstringopcional · Filtrar por tag exacto.
qstringopcional · LIKE sobre name y publicId.
Ejemplo
Petición
curl -H "Authorization: Bearer ef_…" \
  "https://easyfrm.dev/api/v1/forms?tag=leads"
Respuesta200
{
  "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
CampoTipoDescripción
namestringrequerido · Hasta 120 chars.
tagsstring[]opcional · Hasta 20 tags.
configobjectopcional · Ver Form config.
fieldSchemaobjectopcional · Ver Field schema.
Ejemplo
Petición
{
  "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 }
    ]
  }
}
Respuesta201
{
  "id":         "frm_…",
  "publicId":   "Crp6zkfAJV",
  "name":       "Contacto",
  "tags":       ["leads"],
  "config":     { … },
  "fieldSchema":{ … },
  "submitUrl":  "/f/Crp6zkfAJV"
}
Códigos de respuesta
CódigoSignificado
201Creado.
400invalid_input.
402form_limit_reached.
422recipients_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
Petición
{ "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
Respuesta200
{
  "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
Respuesta200
{
  "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
ParamTipoDescripción
fromISO dateopcional · Mínimo createdAt.
toISO dateopcional · Máximo createdAt.
statusenumopcional · received | spam | invalid.
limitintegeropcional · Default 50, máx 200.
Ejemplo
Respuesta200
{
  "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
Respuesta200
{
  "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

  1. Lo que devuelva EasyRender (subject, reply_to).
  2. config.subjectTemplate (Handlebars) y data[config.replyToField].
  3. 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
Respuesta200
{
  "deliveries": [
    {
      "id":           "eml_…",
      "submissionId": "sub_…",
      "status":       "success" | "pending" | "failed",
      "providerId":   "<message id de EasyEmails>",
      "error":        null,
      "executedAt":   "…",
      "createdAt":    "…"
    }
  ]
}

API Keys

GET/api/v1/apikeysaccount

Respuesta200
{
  "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
Respuesta201
{
  "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

Respuesta200
{
  "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.

Respuesta200
{
  "plans": [
    {
      "slug":            "pro",
      "name":            "Pro",
      "priceCents":      2900,
      "stripeAvailable": true,
      "limits":          { /* PlanLimits */ },
      "features":        { /* PlanFeatures */ }
    }
  ]
}

POST/api/v1/billing/checkoutaccount

Cuerpo
{ "plan": "pro" }
Ejemplo
Respuesta200
{ "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

HelperDevuelveEjemplo
eqboolean{{#if (eq data.country "ES")}}…{{/if}}
neqboolean(neq data.status "spam")
gt / gteboolean(gt data.amount 100)
lt / lteboolean(lt data.amount 100)
containsbooleanString o array contiene needle.
matchboolean(match data.email ".+@example\\.com")

Strings & otros

HelperEjemplo
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);
}