Saltar al contenido

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

TipoCuándo
payment.approvedCobro del periodo aprobado.
payment.pendingWompi dejó la transacción pendiente.
payment.declinedIntento rechazado (incluye next_retry_at si habrá reintento).
payment.uncollectibleReintentos agotados.
subscription.createdSe creó una suscripción.
subscription.activatedLa suscripción pasó a active (también al recuperarse de unpaid).
subscription.past_dueUn cobro fue rechazado y hay reintentos pendientes.
subscription.unpaidSe agotaron los reintentos.
subscription.paused / subscription.resumedSe pausó o se reanudó.
subscription.canceledSe canceló.
subscription.plan_changedCambió el plan.
subscription.updatedCambio sin transición de estado (p. ej. cancelación o cambio de plan programados).
subscription.access_changedCambió access_status sin cambio de estado (p. ej. vencieron los días de gracia: granted → restricted).
checkout_session.completedEl cliente registró su tarjeta en la página de pago (incluye payment_method y, en modo subscription, la subscription).
checkout_session.expiredLa sesión venció sin completarse.
payment_method.addedSe registró una tarjeta nueva.
payment_method.invalidatedLa fuente de pago dejó de ser válida.
pingPrueba 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"
    }
  }
}
  • livemode es true en producción y false en sandbox. Los envíos de prueba llevan además "test": true.
  • initiated_by dice quién causó el evento: system (cobros y reintentos), tenant_api (tu backend), dashboard (el panel) o customer_portal (tu cliente desde el portal). Si el cliente canceló desde el portal, data.cancellation trae reason y comment.
  • El campo clave es data.subscription.access_status: granted, restricted o revoked. 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):

DatoValor
Secretowhsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
Paygumi-Timestamp1793509212
Cuerpo{"id":"evt_1","type":"ping"}
Paygumi-Signature esperadav1=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 2xx en 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 a id), 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.