Postulaciones de Saplic: correo y webhook

Las ofertas que llegan a Saplic por un feed reciben las postulaciones en Saplic: el candidato no sale del sitio. Cada postulación se reenvía a la fuente:

  • Por correo, al correo de postulaciones de la oferta (si el feed lo trae) o, si no, al correo de postulaciones registrado para la fuente.
  • Por webhook, si la fuente registró una URL https://.

Los dos llevan los mismos datos. Solo se envían candidatos que postularon a una oferta de esa fuente.

El webhook

POST a la URL registrada, con Content-Type: application/json; charset=utf-8.

Cabecera Qué lleva
X-Saplic-Event application.created (una postulación) o test.ping (prueba).
X-Saplic-Delivery Identificador de la entrega. Es el mismo en todos los reintentos de una postulación.
X-Saplic-Timestamp Momento del envío, en segundos Unix.
X-Saplic-Signature HMAC-SHA256(secreto, timestamp + "." + cuerpo), en hexadecimal en minúsculas.
  • Solo https. Saplic no sigue redirecciones: una respuesta 3xx cuenta como fallo.
  • Tiempo máximo de respuesta: 10 segundos.
  • La entrega se da por hecha con cualquier respuesta 2xx. El cuerpo de la respuesta se ignora.

Cuerpo de application.created

{
  "event": "application.created",
  "delivery_id": "4f6c1d0a9b7e4c2f8a1d3e5b7c9f0a2b",
  "created_at": "2026-10-07T15:04:05+00:00",
  "source": "saplic",
  "job": {
    "reference": "ACME-2026-0412",
    "title": "Marketing Coordinator",
    "url": "https://acmefoods.example/careers/marketing-coordinator",
    "saplic_url": "https://saplic.com/oferta.php?id=41234"
  },
  "application": {
    "applied_at": "2026-10-07T15:03:58+00:00",
    "answers": [
      { "question": "Do you have a valid work permit?", "answer": "Yes" }
    ]
  },
  "candidate": {
    "name": "Ana López",
    "email": "[email protected]",
    "phone": "+503 70001234",
    "country": "El Salvador",
    "city": "San Salvador",
    "experience": [
      { "title": "Marketing Analyst", "company": "Globex", "from": "2022-03", "to": "", "current": true }
    ],
    "education": [
      { "title": "Marketing", "school": "Universidad Centroamericana", "level": "Universitario" }
    ],
    "cv_url": "https://saplic.com/feed-cv.php?t=…",
    "cv_url_expires_in_hours": 72
  }
}

job.reference es el referencenumber que la fuente mandó en su feed. cv_url es null si el candidato no subió un archivo de CV.

Cuerpo de test.ping

{
  "event": "test.ping",
  "delivery_id": "…",
  "created_at": "2026-10-07T15:04:05+00:00",
  "source": "saplic",
  "message": "Evento de prueba de Saplic. No corresponde a ninguna postulación."
}

Se manda desde el panel para comprobar la conexión y la firma. No lleva datos de ningún candidato.

Cómo verificar la firma

El secreto es una cadena de 64 caracteres hexadecimales que Saplic le entrega a la fuente. Se usa tal cual, como texto (no hay que convertirla a bytes).

  1. Leer el cuerpo crudo, byte por byte, antes de interpretarlo como JSON.
  2. Calcular HMAC-SHA256(secreto, X-Saplic-Timestamp + "." + cuerpo) en hexadecimal.
  3. Compararlo con X-Saplic-Signature con una comparación de tiempo constante.
  4. Rechazar el pedido si X-Saplic-Timestamp tiene más de 5 minutos de diferencia con la hora del servidor.
  5. Rechazar (o responder 200 sin procesar) si X-Saplic-Delivery ya se recibió antes: es un reintento de algo ya procesado.

Los pasos 4 y 5 son los que impiden que alguien que capture un envío lo vuelva a mandar.

PHP:

$cuerpo = file_get_contents('php://input');
$ts     = $_SERVER['HTTP_X_SAPLIC_TIMESTAMP'] ?? '';
$firma  = $_SERVER['HTTP_X_SAPLIC_SIGNATURE'] ?? '';
$ok = ctype_digit($ts)
   && abs(time() - (int)$ts) <= 300
   && hash_equals(hash_hmac('sha256', $ts . '.' . $cuerpo, $secreto), $firma);
if (!$ok) { http_response_code(401); exit; }
// … descartar si $_SERVER['HTTP_X_SAPLIC_DELIVERY'] ya se procesó …
http_response_code(200);

Python:

import hmac, hashlib, time

def firma_valida(secreto: str, timestamp: str, cuerpo: bytes, firma: str) -> bool:
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    esperado = hmac.new(secreto.encode(), timestamp.encode() + b"." + cuerpo, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperado, firma)

Node.js:

const crypto = require('crypto');

function firmaValida(secreto, timestamp, cuerpo /* Buffer */, firma) {
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const esperado = crypto.createHmac('sha256', secreto).update(timestamp + '.').update(cuerpo).digest('hex');
  return esperado.length === firma.length && crypto.timingSafeEqual(Buffer.from(esperado), Buffer.from(firma));
}

Reintentos

Si el correo no sale o el webhook no responde 2xx en 10 segundos, Saplic reintenta a los 5 minutos, a los 30 minutos y a las 2 horas. Si el último reintento también falla, el envío queda en error y el equipo de Saplic recibe una alerta; la postulación sigue guardada y se puede reenviar a mano.

Cada reintento del webhook lleva un X-Saplic-Timestamp y una firma nuevos, y el mismo X-Saplic-Delivery.

El enlace al CV

El CV no va adjunto: va como enlace (cv_url en el webhook, un botón en el correo).

  • Vence a las 72 horas.
  • Está firmado y no lleva identificadores a la vista: no se puede modificar ni adivinar otro.
  • Deja de funcionar si el candidato retira su postulación o elimina su cuenta.
  • Vencido, la página indica cómo pedir uno nuevo.

Conviene descargar el archivo al recibir la postulación y no guardar el enlace.

El correo

Asunto: Nueva postulación: {título de la oferta} — vía Saplic, en el idioma con el que se registró la fuente (español, inglés, portugués o francés). Lleva la referencia de la oferta, los datos del candidato (nombre, correo, teléfono, país, ciudad, experiencia, estudios y sus respuestas a las preguntas de la oferta) y el enlace al CV.

Cambiar el secreto

El secreto se puede regenerar desde Saplic en cualquier momento. El anterior deja de servir de inmediato, así que hay que actualizarlo del lado de la fuente en el mismo momento.