Webhooks
Recibe en tu backend los eventos de pagos y suscripciones, verifica su firma HMAC-SHA256 y procésalos sin duplicados.
Paygumi te avisa por webhook cada vez que cambia un pago o una suscripción, para que sepas en segundos si el pago de un periodo fue aprobado, quedó pendiente o fue rechazado, y qué acceso dar a tu usuario.
Configurar un endpoint
En el panel, en Webhooks → Endpoints, registra una URL https de tu backend y elige los tipos de evento (o * para todos). Cada entorno tiene sus propios endpoints (hasta 5) y cada endpoint su propio secreto whsec_…, que usas para verificar la firma. Con Enviar ping recibes un evento ping de prueba.
Catálogo de eventos
| Tipo | Cuándo |
|---|---|
payment.approved | Cobro del periodo aprobado. |
payment.pending | Wompi dejó la transacción pendiente. |
payment.declined | Intento rechazado (incluye next_retry_at si habrá reintento). |
payment.uncollectible | Reintentos agotados. |
subscription.created | Se creó una suscripción. |
subscription.activated | La suscripción pasó a active (también al recuperarse de unpaid). |
subscription.past_due | Un cobro fue rechazado y hay reintentos pendientes. |
subscription.unpaid | Se agotaron los reintentos. |
subscription.paused / subscription.resumed | Se pausó o se reanudó. |
subscription.canceled | Se canceló. |
subscription.plan_changed | Cambió el plan. |
subscription.updated | Cambio sin transición de estado (p. ej. cancelación o cambio de plan programados). |
subscription.access_changed | Cambió access_status sin cambio de estado (p. ej. vencieron los días de gracia: granted → restricted). |
checkout_session.completed | El cliente registró su tarjeta en la página de pago (incluye payment_method y, en modo subscription, la subscription). |
checkout_session.expired | La sesión venció sin completarse. |
payment_method.added | Se registró una tarjeta nueva. |
payment_method.invalidated | La fuente de pago dejó de ser válida. |
ping | Prueba manual desde el panel (no se suscribe). |
Payload
Todos los eventos tienen el mismo sobre. data trae el estado completo de los objetos al momento del evento (estados en minúsculas):
{
"id": "evt_01JB86R800ABCDEFGHJKMNPQRS",
"type": "payment.declined",
"created_at": "2026-11-01T05:00:12Z",
"api_version": "2026-10-01",
"livemode": true,
"initiated_by": "system",
"data": {
"subscription": {
"id": "sub_01JB86R800ABCDEFGHJKMNPQRS",
"status": "past_due",
"access_status": "granted",
"access_until": "2026-11-04T05:00:00Z",
"customer": { "id": "cus_01JB86R800ABCDEFGHJKMNPQRS", "external_id": "user_42" },
"plan": { "id": "pln_01JB86R800ABCDEFGHJKMNPQRS", "name": "Pro" },
"current_period": { "start": "2026-11-01T05:00:00Z", "end": "2026-12-01T05:00:00Z" }
},
"charge": {
"id": "chg_01JB86R800ABCDEFGHJKMNPQRS",
"status": "failed",
"amount_in_cents": 4990000,
"currency": "COP",
"period": { "start": "2026-11-01T05:00:00Z", "end": "2026-12-01T05:00:00Z" },
"attempt": 1,
"failure_reason": "INSUFFICIENT_FUNDS",
"next_retry_at": "2026-11-02T05:00:00Z"
}
}
}
livemodeestrueen producción yfalseen sandbox. Los envíos de prueba llevan además"test": true.initiated_bydice quién causó el evento:system(cobros y reintentos),tenant_api(tu backend),dashboard(el panel) ocustomer_portal(tu cliente desde el portal). Si el cliente canceló desde el portal,data.cancellationtraereasonycomment.- El campo clave es
data.subscription.access_status:granted,restrictedorevoked. Ver estados de acceso.
Todos los campos en la referencia de eventos.
Cabeceras
POST /webhooks/paygumi HTTP/1.1
Content-Type: application/json
Paygumi-Event-Id: evt_01JB86R800ABCDEFGHJKMNPQRS
Paygumi-Timestamp: 1793509212
Paygumi-Signature: v1=a6e678f685978f4335967817ea566984611995345a4b65a347ccd2bd51afe0ed
Verificar la firma
La firma es un HMAC-SHA256, en hexadecimal, del timestamp, un punto y el cuerpo exacto que recibiste:
Paygumi-Signature: v1=hex(HMAC_SHA256(secreto, Paygumi-Timestamp + "." + cuerpo))
- La llave del HMAC es el texto completo del secreto, incluido el prefijo
whsec_. - Firma sobre los bytes crudos del cuerpo, antes de parsear el JSON: si lo parseas y lo vuelves a serializar, la firma no coincide.
- Rechaza timestamps con más de 5 minutos de diferencia con tu reloj (evita que reenvíen un evento capturado).
- Compara en tiempo constante.
- Rotación de secreto: durante las 24 h siguientes a rotar el secreto en el panel, la cabecera trae dos firmas separadas por coma,
v1=<nuevo>,v1=<anterior>. Acepta el evento si alguna coincide con tu secreto.
Los siguientes ejemplos implementan exactamente eso. Están probados contra el vector de prueba.
Node.js
import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 300
/**
* Verifica un webhook de Paygumi.
* @param {string} secret Secreto del endpoint (whsec_…).
* @param {string} timestamp Cabecera Paygumi-Timestamp.
* @param {string} signatureHeader Cabecera Paygumi-Signature.
* @param {Buffer|string} rawBody Cuerpo crudo, tal como llegó.
* @param {number} [now] Segundos Unix actuales.
*/
export function verifyPaygumiSignature(secret, timestamp, signatureHeader, rawBody, now = Math.floor(Date.now() / 1000)) {
if (!/^\d+$/.test(timestamp ?? '')) return false
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest()
return (signatureHeader ?? '').split(',').some((part) => {
const [scheme, hex] = part.trim().split('=')
if (scheme !== 'v1' || !/^[0-9a-f]{64}$/.test(hex ?? '')) return false
return crypto.timingSafeEqual(Buffer.from(hex, 'hex'), expected)
})
}
Con Express, lee el cuerpo crudo solo en la ruta del webhook:
app.post('/webhooks/paygumi', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyPaygumiSignature(
process.env.PAYGUMI_WEBHOOK_SECRET,
req.get('Paygumi-Timestamp'),
req.get('Paygumi-Signature'),
req.body, // Buffer
)
if (!ok) return res.sendStatus(400)
const event = JSON.parse(req.body.toString('utf8'))
// Encola el evento y responde rápido.
res.sendStatus(200)
})
Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_paygumi_signature(secret, timestamp, signature_header, raw_body, now=None):
"""Verifica un webhook de Paygumi. raw_body son los bytes crudos del cuerpo."""
if not timestamp or not (timestamp.isascii() and timestamp.isdigit()):
return False
now = int(time.time()) if now is None else now
if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"),
timestamp.encode("ascii") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
for part in (signature_header or "").split(","):
scheme, _, signature = part.strip().partition("=")
if scheme == "v1" and hmac.compare_digest(signature, expected):
return True
return False
En Flask, request.get_data() devuelve los bytes crudos; en Django, request.body.
PHP
<?php
const PAYGUMI_TOLERANCE_SECONDS = 300;
/**
* Verifica un webhook de Paygumi. $rawBody es file_get_contents('php://input').
*/
function verify_paygumi_signature(string $secret, string $timestamp, string $signatureHeader, string $rawBody, ?int $now = null): bool
{
if (!ctype_digit($timestamp)) {
return false;
}
$now = $now ?? time();
if (abs($now - (int) $timestamp) > PAYGUMI_TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach (explode(',', $signatureHeader) as $part) {
$pieces = explode('=', trim($part), 2);
if (count($pieces) === 2 && $pieces[0] === 'v1' && hash_equals($expected, $pieces[1])) {
return true;
}
}
return false;
}
Las cabeceras llegan en $_SERVER['HTTP_PAYGUMI_TIMESTAMP'] y $_SERVER['HTTP_PAYGUMI_SIGNATURE'].
Go
package paygumi
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
const signatureTolerance = 5 * time.Minute
// VerifySignature verifica un webhook de Paygumi. body son los bytes crudos del cuerpo.
func VerifySignature(secret, timestamp, signatureHeader string, body []byte, now time.Time) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || ts < 0 {
return false
}
if diff := now.Sub(time.Unix(ts, 0)); diff > signatureTolerance || diff < -signatureTolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, part := range strings.Split(signatureHeader, ",") {
scheme, sig, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok || scheme != "v1" {
continue
}
got, err := hex.DecodeString(sig)
if err == nil && hmac.Equal(got, expected) {
return true
}
}
return false
}
En un http.Handler, lee el cuerpo con io.ReadAll(r.Body) antes de decodificar el JSON y pasa time.Now().
Vector de prueba
Usa estos valores para probar tu implementación (el secreto es de ejemplo, no es de ninguna cuenta):
| Dato | Valor |
|---|---|
| Secreto | whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8 |
Paygumi-Timestamp | 1793509212 |
| Cuerpo | {"id":"evt_1","type":"ping"} |
Paygumi-Signature esperada | v1=a6e678f685978f4335967817ea566984611995345a4b65a347ccd2bd51afe0ed |
Con esos datos (y tu reloj fijado cerca del timestamp), tu función debe aceptar también la cabecera de rotación v1=ea3e29664abd3e52830afcc08e7d6caf9fc0162288737032eb959e5d3bfb443c,v1=a6e678f685978f4335967817ea566984611995345a4b65a347ccd2bd51afe0ed, y rechazar el evento si cambia un solo byte del cuerpo o si el timestamp tiene más de 5 minutos.
Entrega y reintentos
- Responde con cualquier
2xxen menos de 10 segundos. Guarda el evento (por ejemplo en una cola) y procésalo después: no hagas trabajo lento antes de responder. - Si respondes otro código, se vence el tiempo o falla la conexión, Paygumi reintenta a los 1 min, 5 min, 30 min, 2 h, 6 h, 12 h y 24 h (≈ 45 h en total). Después la entrega queda agotada; puedes reenviarla desde el panel.
- No se siguen redirects: registra la URL final.
- Si un endpoint falla 100 veces seguidas durante 3 días, se deshabilita y se avisa por correo a los propietarios y administradores de tu cuenta.
Duplicados y orden
- La entrega es al menos una vez: el mismo evento puede llegar más de una vez (reintentos o reenvíos). Deduplica por
Paygumi-Event-Id(igual aid), que no cambia entre reintentos. - No hay orden garantizado: un evento viejo puede llegar después de uno nuevo. Como cada evento trae el estado completo y
created_at, descarta los que sean más viejos que el último que aplicaste para esa suscripción. - Si te perdiste eventos, reconcilia con
GET /v1/subscriptions/{id}/access.