Webhooks
Los salientes avisan a tu sistema cuando ocurre algo en el programa. Los entrantes son los avisos que IncentIA recibe de tiendas, pasarelas y proveedores de evidencias.
Salientes
Los creas en Integraciones › Webhooks (permiso Suscripciones webhook, Pages.Loyalty.Webhooks). Cómo se configuran, en Webhooks. Desde la pantalla, una suscripción escucha un evento; por API puede escuchar varios.
Eventos
| Evento | Cuándo se envía |
|---|---|
MemberCreated | Se registra un socio |
PointsEarned | Un socio gana puntos |
PointsRedeemed | Un socio gasta puntos |
TierChanged | Un socio cambia de nivel |
RewardRedeemed | Un socio canjea una recompensa |
CouponUsed | Se usa un cupón |
PointsExpiring | A un socio le van a caducar puntos |
GamePrizeWon | Un participante gana un premio en un juego |
Eventos del ciclo de vida
Además de los ocho anteriores, hay eventos de participación, juegos, sorteos, cupones, socios y pagos. Comparten el formato, las cabeceras y la firma de cualquier otro aviso.
X-Event-Type | Valor (eventTypes) | data.event | Cuándo se envía |
|---|---|---|---|
EvidenceReceived | 9 | evidence.received | Se recibe un ticket (reclamación) |
EvidenceValidated | 10 | evidence.validated | Se aprueba |
EvidenceRejected | 11 | evidence.rejected | Se rechaza o es duplicado |
EvidenceInReview | 12 | evidence.in_review | Espera revisión de una persona |
EvidencePaid | 13 | evidence.paid | Se liquida su pago |
ParticipationCreated | 14 | participation.created | Se registra una participación nueva |
ParticipationValidated | 15 | participation.validated | Una participación pasa a validada |
ParticipationRejected | 16 | participation.rejected | Una participación pasa a rechazada |
GamePlayed | 17 | game.played | Termina una jugada, con premio o sin él |
GamePrizeWon | 8 | prize.won | Una jugada otorga un premio (también participantes sin cuenta de socio) |
RaffleDrawn | 18 | raffle.drawn | Se realiza un sorteo (uno por sorteo; en uno por tienda, uno por cada tienda) |
CouponIssued | 19 | coupon.issued | Se crea un cupón desde el panel o se completa una tarjeta de sellos. No se envía al emitir lotes por API |
MemberUpdated | 20 | member.updated | Cambia un dato del perfil de un socio |
MemberAnonymized | 21 | member.anonymized | Termina un borrado RGPD |
PayoutCompleted | 22 | payout.completed | Se completa o confirma un pago de cashback |
PayoutFailed | 23 | payout.failed | Un pago falla de forma definitiva (no se envía si hay un reintento pendiente) |
JobCompleted | 24 | job.completed | Termina un trabajo masivo de /api/v1/jobs (importación o exportación), bien o con error: mira status |
X-Event-Type y el campo event del aviso llevan el nombre de la primera columna; data.event lleva el nombre con puntos. data empieza siempre por event, version (versión del formato, hoy 1) y occurredAt, seguidos solo de identificadores, códigos, estados e importes: nunca nombres, datos de contacto ni datos bancarios. Añadir campos no sube version; quitarlos o renombrarlos sí.
{
"event": "ParticipationValidated",
"timestamp": "2026-11-01T10:15:02.0000000Z",
"data": {
"event": "participation.validated",
"version": 1,
"occurredAt": "2026-11-01T10:15:02Z",
"attemptId": 991, "campaignId": 17, "channel": "Landing", "trigger": "Evidence",
"status": "Validated", "outcome": null, "reason": null,
"participantId": 55, "memberId": null, "claimId": 1042
}
}
| Evento | Campos principales de data |
|---|---|
participation.* | attemptId, campaignId, channel, trigger, status, outcome, participantId, memberId, claimId; reason solo en rejected |
game.played | playId, gameId, memberId, participantId, result, prizeId, pointsSpent, pointsWon |
prize.won | playId, gameId, memberId, participantId, prizeId, prizeName, prizeType, pointsAwarded, couponCode |
raffle.drawn | drawId, campaignId, drawnAt, scope, storeId, slotKey, recuentos, outputHashSha256 y winners[] (winnerId, role, participantId, memberId, claimId) |
coupon.issued | couponId, code, source, loyaltyProgramId, campaignId, memberId, type, discountValue, maxUses, validFrom, validUntil |
member.updated | memberId, loyaltyProgramId, status, changedFields (solo los nombres de los campos), source |
member.anonymized | memberId, loyaltyProgramId, certificateId |
payout.* | payoutId, memberId, campaignId, claimId, status, type, amount, currency, processedAt |
evidence.* | claimId, campaignId, externalParticipantRef, status, rejectionCode, rejectionReason, amount, currency |
Todos se emiten cuando la operación que los origina ya se ha confirmado: si se deshace, no se envía nada. En prize.won y coupon.issued va el código del cupón, porque es lo que tu sistema debe entregar.
El selector de Tipo de Evento de Integraciones › Webhooks ofrece hoy solo los ocho primeros. Para el resto, crea o edita la suscripción con la API de administración (/api/services/app/WebhookSubscription/Create o Update) y envía los valores de la tabla en eventTypes, por ejemplo "eventTypes": [14, 15, 16, 18]. Una suscripción puede escuchar varios eventos.
También puedes recibir los eventos en tu cola SQS o tema SNS en lugar de en una URL.
Formato del aviso
IncentIA hace un POST con Content-Type: application/json:
{
"event": "PointsEarned",
"timestamp": "2026-10-22T09:30:00.0000000Z",
"data": { }
}
data depende del evento. El aviso se emite cuando la operación que lo origina ya se ha confirmado.
| Cabecera | Contenido |
|---|---|
X-Event-Type | Nombre del evento |
X-Timestamp | Fecha del aviso (ISO 8601) |
X-Correlation-Id | Identificador de seguimiento |
X-IncentIA-Idempotency-Key | Clave única del aviso. No cambia en los reintentos |
X-Signature | Firma HMAC (solo si la suscripción tiene Secreto) |
Verificar la firma
La firma es v1= seguido del HMAC-SHA256 en hexadecimal (minúsculas) de este texto, con tu Secreto como clave:
{X-Timestamp}.{X-IncentIA-Idempotency-Key}.{cuerpo sin modificar}
Usa el valor de X-Timestamp tal cual llega y el cuerpo en bruto, sin volver a serializarlo.
Antes de calcular nada, comprueba el prefijo v1= (ver la política de versiones) y compara en tiempo constante.
TypeScript
import crypto from 'node:crypto';
export function isValid(headers: Record<string, string | undefined>, rawBody: string, secret: string): boolean {
const signed = `${headers['x-timestamp']}.${headers['x-incentia-idempotency-key']}.${rawBody}`;
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(signed).digest('hex');
const received = headers['x-signature'] ?? '';
return received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
C#
using System.Security.Cryptography;
using System.Text;
static bool IsValid(string timestamp, string idempotencyKey, string rawBody, string received, string secret)
{
var signed = $"{timestamp}.{idempotencyKey}.{rawBody}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = "v1=" + Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(signed))).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(received ?? ""));
}
PHP
<?php
function isValid(array $headers, string $rawBody, string $secret): bool {
$signed = ($headers['X-Timestamp'] ?? '') . '.' . ($headers['X-IncentIA-Idempotency-Key'] ?? '') . '.' . $rawBody;
$expected = 'v1=' . hash_hmac('sha256', $signed, $secret);
return hash_equals($expected, $headers['X-Signature'] ?? '');
}
// $rawBody = file_get_contents('php://input'); // sin json_decode
Como la firma incluye la fecha y la clave de idempotencia, comprobarla valida también esas cabeceras. Guarda las claves ya procesadas para descartar duplicados.
Entrega y reintentos
- Responde con
2xxen menos de 30 segundos. - Ante un error se reintenta hasta tres veces más. Las respuestas
408,425y429también se reintentan. - IncentIA no sigue redirecciones: un
3xxcuenta como fallo. - La URL de destino debe ser pública; las direcciones internas se rechazan.
- La suscripción se desactiva sola tras 10 fallos consecutivos, o 50 respuestas de saturación (
408/425/429) seguidas. Verás el contador en Fallos Consecutivos.
Responde 200 enseguida y procesa el aviso en segundo plano: así no agotas el tiempo de espera.
Reenviar una entrega
Si tu receptor estuvo caído, puedes pedir que se reenvíe una entrega ya registrada: en Integraciones › Webhooks, pestaña Entregas, botón Reenviar, o por API con una clave con el ámbito webhooks:write:
curl -s -X POST "$BASE/api/v1/webhook-deliveries/4821/redeliver" \
-H "Authorization: Bearer $KEY"
# 202 {"deliveryId":4821,"subscriptionId":12,"eventType":"PointsEarned","idempotencyKey":"..."}
Se reenvía el mismo contenido y la misma X-IncentIA-Idempotency-Key (tu sistema debe deduplicar), con una X-Timestamp y una firma nuevas, y solo a la URL original. Respuestas de rechazo:
| Código | code | Cuándo |
|---|---|---|
404 | not_found | La entrega no existe en tu cuenta |
409 | subscription_inactive | La suscripción está desactivada |
409 | event_not_subscribed | La suscripción ya no incluye ese evento |
409 | target_changed | Cambiaste la URL de destino después de la entrega |
409 | delivery_in_progress | Ya hay un reenvío o reintento en curso |
410 | subscription_gone | La suscripción se eliminó |
410 | payload_expired | El contenido ya no se conserva (14 días por defecto) |
403 | feature_disabled | Webhooks no está incluido en el plan |
Entrantes
Son rutas públicas que protege una firma. Normalmente las configura IncentIA al conectar la integración; esta tabla es para depurar. El registro de entregas está en Administración › Webhooks Entrantes.
| Origen | Ruta | Firma |
|---|---|---|
| Shopify | POST /api/webhooks/shopify/{integrationId} | X-Shopify-Hmac-Sha256 (HMAC-SHA256 en Base64 del cuerpo) |
| WooCommerce | POST /api/webhooks/woocommerce/{integrationId} | X-WC-Webhook-Signature (HMAC-SHA256 en Base64 del cuerpo) |
| Stripe | POST /api/member/portal/payments/stripe-webhook | Stripe-Signature |
| Evidencias (validación externa) | POST /api/evidence/callback | X-Evidence-Signature |
En Shopify y WooCommerce la clave de la firma es el secreto de la integración. Los temas no admitidos se ignoran con 200 y {"status":"ignored"}.
Callback de evidencias
Un proveedor externo de validación devuelve el veredicto de un ticket. Firma el cuerpo en bruto con HMAC-SHA256, en hexadecimal o Base64, usando el secreto de callback del servidor, y envíalo en X-Evidence-Signature.
{ "evidenceId": 123, "approved": true, "amount": 24.5, "reason": null, "timestamp": 1793750400 }
| Campo | Notas |
|---|---|
evidenceId | Obligatorio |
approved | true aprueba, false rechaza |
amount | Opcional. Importe validado, hasta 1 000 000 |
reason | Opcional. Hasta 500 caracteres |
timestamp | Obligatorio. Segundos Unix del momento de la firma; se rechaza si se aleja más de 5 minutos de la hora del servidor |
Respuestas: 200 con {"status":"processed"}, 401 si falta o no coincide la firma o si timestamp falta o está fuera de la ventana, 422 si la evidencia ya la rechazó o marcó como duplicada una persona (un approved: true no puede revertir ese veredicto), 404 si no existe la evidencia y 503 si el servidor no tiene el secreto configurado. El cuerpo admite como máximo 2 MB.