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
| Campo | Tipo | Descripción |
|---|---|---|
payment.succeeded | data.payment | Un cobro quedó aprobado. Es el evento principal: aquí marcas la orden pagada. |
payment.failed | data.payment | El banco rechazó un intento de cobro. Sin cargo al cliente. |
payment_link.completed | data.payment_link | Un link alcanzó su cupo de usos y ya no admite más pagos. |
customer.created | data.customer | Alguien se registró por tu link público de afiliación. |
customer.card_saved | data.customer | Un cliente dejó su tarjeta guardada. Es la señal para activarle el servicio sin pedirle que confirme a mano. |
settlement.created | data.settlement | Generamos 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.paid | data.settlement | La 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 /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
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));
}
});$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";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", 200Qué 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-Deliveryo eliddel 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
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.