Webhooks

En vez de preguntarnos cada minuto si te pagaron, te avisamos: mandamos un POST firmado a tu servidor cada vez que pasa algo en tu cuenta.

Configurar el endpoint

En Desarrolladores → Webhooks registra una URL https (aceptamos http://localhost solo para pruebas). Al crearla te damos un secreto de firma (whsec_…): guárdalo, es lo que te permite comprobar que el POST vino de nosotros.

Desde ahí mismo puedes mandar un evento de prueba, pausar el endpoint y ver el historial de entregas con la respuesta que dio tu servidor.

Eventos

CampoTipoDescripción
payment.succeededdata.paymentUn cobro quedó aprobado. Es el evento principal: aquí marcas la orden pagada.
payment.faileddata.paymentEl banco rechazó un intento de cobro. Sin cargo al cliente.
payment_link.completeddata.payment_linkUn link alcanzó su cupo de usos y ya no admite más pagos.
customer.createddata.customerAlguien se registró por tu link público de afiliación.
customer.card_saveddata.customerUn cliente dejó su tarjeta guardada. Es la señal para activarle el servicio sin pedirle que confirme a mano.
settlement.createddata.settlementGeneramos tu liquidación. Mira el status del payload: PENDING es transferencia en camino; DEBT es neto ≤ 0 — no hay transferencia y el saldo queda como deuda.
settlement.paiddata.settlementLa transferencia a tu banco salió, con su referencia.

Por defecto el endpoint recibe todos. Los objetos dentro de data tienen exactamente la misma forma que en la API.

Cómo llega

POST a tu URL
POST /webhooks/tiptap HTTP/1.1
Content-Type: application/json
User-Agent: TipTapPay-Webhooks/1.0
Tiptap-Event: payment.succeeded
Tiptap-Delivery: cms126txf0001j5bu36f79f3o
Tiptap-Signature: t=1785025675,v1=7c27e53b6cccda40cae46aa17ec4aab2a674ac2c…

{
  "event": "payment.succeeded",
  "created_at": "2026-07-26T00:27:55.412Z",
  "data": {
    "payment": {
      "object": "payment",
      "id": "cmqbbfsbd000fjy7yy29qmahh",
      "amount": 25,
      "currency": "USD",
      "status": "APPROVED",
      "payer": { "name": "Carlos Ruiz", "email": "carlos@ejemplo.com" },
      "customer_id": null,
      "payment_link": { "id": "cmqbbfpsc000djy7y33dylouo", "title": "Orden #1042", "url": "…" },
      "authorization_code": "202123",
      "processor_reference": "7812922904586158104805",
      "settlement_id": null,
      "created_at": "2026-07-26T00:27:55.097Z"
    }
  }
}

El User-Agent dice TipTapPay-Webhooks por razones históricas y no cambia con la marca: es un identificador de integración, así que si lo tienes en una allowlist, déjalo tal cual.

Verificar la firma

El header Tiptap-Signature trae dos partes: t, el momento del envío en segundos Unix, y v1, el HMAC‑SHA256 de `${t}.${cuerpo_crudo}` con tu secreto. Compara en tiempo constante y rechaza timestamps de más de 5 minutos.

Usa el cuerpo crudo

Firma sobre el texto exacto que recibiste, antes de parsear el JSON. Si lo parseas y lo vuelves a serializar, la firma no va a coincidir.
Node.js / Express
import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.TIPTAP_WEBHOOK_SECRET;

function firmaValida(header, cuerpoCrudo) {
  const partes = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("="))
  );
  const t = Number(partes.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const esperada = crypto
    .createHmac("sha256", SECRET)
    .update(`${t}.${cuerpoCrudo}`)
    .digest("hex");

  const a = Buffer.from(esperada);
  const b = Buffer.from(partes.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw: necesitamos el cuerpo sin parsear
app.post("/webhooks/tiptap", express.raw({ type: "application/json" }), (req, res) => {
  const crudo = req.body.toString("utf8");
  if (!firmaValida(req.header("Tiptap-Signature") ?? "", crudo)) {
    return res.status(400).send("firma inválida");
  }

  const { event, data } = JSON.parse(crudo);

  // Responde rápido y procesa después: tienes 10 segundos
  res.status(200).send("ok");

  if (event === "payment.succeeded") {
    encolar(() => marcarOrdenPagada(data.payment));
  }
});
PHP
$crudo  = file_get_contents("php://input");
$header = $_SERVER["HTTP_TIPTAP_SIGNATURE"] ?? "";

parse_str(str_replace(",", "&", $header), $partes);   // t=…&v1=…
$t = (int) ($partes["t"] ?? 0);

if (abs(time() - $t) > 300) { http_response_code(400); exit("timestamp viejo"); }

$esperada = hash_hmac("sha256", "$t.$crudo", getenv("TIPTAP_WEBHOOK_SECRET"));
if (!hash_equals($esperada, $partes["v1"] ?? "")) {
  http_response_code(400); exit("firma inválida");
}

$evento = json_decode($crudo, true);
http_response_code(200);
echo "ok";
Python / Flask
import hmac, hashlib, os, time
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["TIPTAP_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/tiptap")
def tiptap():
    crudo = request.get_data()
    partes = dict(p.split("=", 1) for p in request.headers.get("Tiptap-Signature", "").split(","))
    t = int(partes.get("t", 0))
    if abs(time.time() - t) > 300:
        return "timestamp viejo", 400

    esperada = hmac.new(SECRET, f"{t}.".encode() + crudo, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(esperada, partes.get("v1", "")):
        return "firma inválida", 400

    evento = request.get_json()
    # … procesa evento["event"] y evento["data"]
    return "ok", 200

Qué esperamos de tu servidor

  • Responder 2xx en menos de 10 segundos. Cualquier otra cosa (o un timeout) cuenta como fallo.
  • Responder rápido y procesar después. No hagas el trabajo pesado antes de contestar.
  • Ser idempotente: usa Tiptap-Delivery o el id del objeto para no procesar dos veces el mismo evento.

Reintentos

Si tu servidor no responde 2xx, reintentamos hasta 5 veces con esperas de 1 minuto, 5 minutos, 30 minutos, 2 horas y 6 horas. Después la entrega queda marcada como fallida; puedes reintentarla a mano desde el panel.

Los webhooks no son la única fuente de verdad

Si un evento se pierde del todo, tu conciliación diaria contra GET /v1/payments lo recupera. Tener las dos vías es la diferencia entre una integración que aguanta y una que no.

Orden de llegada

No garantizamos el orden. Si recibes settlement.paid antes que settlement.created, quédate con el estado del objeto que viene en el evento — siempre trae la foto completa.