Webhooks
Antivio avisa a tu servidor cuando algo le pasa a un envío, con un POST HTTPS firmado. Los
mensajes siguen la especificación abierta Standard Webhooks:
verifícalos con unas pocas líneas de código (abajo) o con cualquier librería de Standard Webhooks.
Sin SDK de Antivio.
Endpoints
Agrega endpoints en Configuración → Desarrolladores → Webhooks (comercios: Portal →
Desarrolladores) o con la API (POST /v1/webhook-endpoints, permiso webhooks:write).
- Una URL
https://pública. Se rechazan redes privadas,localhosty direcciones de metadatos de la nube, también cuando un nombre público resuelve después a una de ellas. - Elige los tipos de evento que recibe; hasta 5 endpoints por empresa o comercio.
- Un endpoint es real o de prueba (
livemode): solo recibe eventos de envíos reales, o de sandbox. Los endpoints creados con un token de prueba son de prueba; en el panel tú eliges (por defecto según si tu empresa ya opera en real). Un token de prueba nunca ve, cambia ni lee las entregas de un endpoint real. - Un endpoint de la empresa recibe los eventos de todos sus envíos; el de un comercio, solo los de sus propios envíos.
- Cada endpoint tiene su propio secreto de firma (
whsec_…), que se muestra al crearlo y se puede revelar en el panel.
Eventos
| Tipo | Cuándo |
|---|---|
shipment.created | se creó un envío (borrador o confirmado) |
shipment.status_changed | cambió su estado; data.previous_status → data.status |
shipment.delivered | se entregó (también se envía como shipment.status_changed) |
shipment.exception | pasó a exception: perdido, dañado o retenido (también como shipment.status_changed) |
GET /v1/webhook-event-types los lista.
El mensaje
POST /webhooks/antivio HTTP/1.1
Content-Type: application/json
User-Agent: Antivio-Webhooks/1.0 (+https://antivio.app/developers/webhooks)
webhook-id: evt_0193a6b2c4d87c1e9f0a1b2c3d4e5f60
webhook-timestamp: 1760716800
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
webhook-event: shipment.delivered
{
"id": "evt_0193a6b2c4d87c1e9f0a1b2c3d4e5f60",
"type": "shipment.delivered",
"api_version": "2026-10-01",
"created_at": "2026-10-17T15:20:00.123Z",
"tenant_id": "0192f5a0-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"status": "delivered",
"previous_status": "out_for_delivery",
"shipment": {
"id": "0193a6b2-…",
"tracking_number": "AV2610ABCDE7K",
"status": "delivered",
"external_ref": "ORDER-1001",
"customer_account_id": "…",
"recipient_name": "Ana Pérez",
"payment_mode": "cod",
"cod_amount_minor": 150000,
"currency": "DOP",
"parcels": [ … ],
"…": "the same fields as GET /v1/shipments/{id}"
}
}
}
data.shipmentes el envío tal como lo muestraGET /v1/shipments/{id}cuando se arma el mensaje (normalmente segundos después del evento).data.statusydata.previous_statusdescriben la transición propia del evento; si los eventos llegan desordenados, comparaprevious_statuscon lo que guardaste, o lee el envío, antes de actualizarlo.- El dinero va en unidades menores (
150000DOP = RD$ 1,500.00) con sucurrency. livemode: falsemarca los envíos de sandbox. Los mensajes de prueba enviados desde el panel llevan además"test": truey un envío de ejemplo.
Verifica la firma
Verifica siempre antes de confiar en un mensaje. La firma es HMAC-SHA256 con tu secreto (el
base64 después de whsec_) sobre {webhook-id}.{webhook-timestamp}.{cuerpo}, enviada como
v1,<base64>. Durante una rotación de secreto la cabecera trae dos firmas separadas por un
espacio; acepta el mensaje si una coincide. Rechaza marcas de tiempo a más de 5 minutos de tu
reloj (repeticiones).
Usa los bytes crudos del cuerpo tal como llegan, antes de interpretar el JSON.
Node.js
import crypto from "node:crypto";
export function verifyAntivio(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = Buffer.from(
"v1," + crypto.createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest("base64"),
);
return signatures.split(" ").some((signature) => {
const candidate = Buffer.from(signature);
return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
});
}
// Express: app.post("/webhooks/antivio", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyAntivio(process.env.ANTIVIO_WEBHOOK_SECRET, req.headers, req.body)) return res.sendStatus(400);
// …; res.sendStatus(204);
// });
Python
import base64, hashlib, hmac, time
def verify_antivio(secret: str, headers, raw_body: bytes) -> bool:
try:
msg_id, timestamp = headers["webhook-id"], headers["webhook-timestamp"]
signatures = headers["webhook-signature"]
sent_at = int(timestamp)
except (KeyError, TypeError, ValueError):
return False
if abs(time.time() - sent_at) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
digest = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest()
expected = "v1," + base64.b64encode(digest).decode()
return any(hmac.compare_digest(sig, expected) for sig in signatures.split(" "))
PHP
function verify_antivio(string $secret, array $headers, string $rawBody): bool {
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || $signatures === '' || !ctype_digit($timestamp)) return false;
if (abs(time() - (int) $timestamp) > 300) return false;
$key = base64_decode(substr($secret, strlen('whsec_')));
$expected = 'v1,' . base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true));
foreach (explode(' ', $signatures) as $signature) {
if (hash_equals($expected, $signature)) return true;
}
return false;
}
// $rawBody = file_get_contents('php://input');
Go
func VerifyAntivio(secret string, h http.Header, rawBody []byte) bool {
id, timestamp := h.Get("webhook-id"), h.Get("webhook-timestamp")
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(rawBody)
expected := []byte("v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)))
for _, signature := range strings.Split(h.Get("webhook-signature"), " ") {
if hmac.Equal([]byte(signature), expected) {
return true
}
}
return false
}
C#
using System.Security.Cryptography;
using System.Text;
static bool VerifyAntivio(string secret, string id, string timestamp, string signatures, byte[] rawBody)
{
if (!long.TryParse(timestamp, out var ts)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;
var key = Convert.FromBase64String(secret["whsec_".Length..]);
using var hmac = new HMACSHA256(key);
var signed = Encoding.UTF8.GetBytes($"{id}.{timestamp}.").Concat(rawBody).ToArray();
var expected = Encoding.UTF8.GetBytes("v1," + Convert.ToBase64String(hmac.ComputeHash(signed)));
return signatures.Split(' ').Any(s =>
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(s), expected));
}
Vector de prueba (de la especificación Standard Webhooks): secreto
whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw, webhook-id msg_p5jXN8AQM9LWM0D4loKWxJek,
webhook-timestamp 1614265330, cuerpo {"test": 2432232314} → firma
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= (desactiva tu control de reloj al probarlo).
Responde rápido, procesa después
- Responde cualquier
2xxen menos de 10 segundos y luego haz el trabajo (en una cola). Antivio no sigue redirecciones y solo lee los primeros 4 KB de tu respuesta (se ven en el registro de entregas). - La entrega es al menos una vez: el mismo mensaje puede llegar dos veces. Usa
webhook-idpara ignorar duplicados. - Los mensajes pueden llegar desordenados. Nunca hagas retroceder un envío: aplica un cambio solo
si su
data.previous_statuscoincide con lo que guardaste, o compara elupdated_atdel envío con el tuyo; ante la duda, leeGET /v1/shipments/{id}. - ¿Necesitas el estado más reciente? Lee
GET /v1/shipments/{id}con tu token.
Reintentos y desactivación
Todo lo que no sea 2xx (o sin respuesta en 10 s) se reintenta a los 30 s, 5 min, 30 min, 2 h,
6 h, 12 h, 24 h y 24 h: 9 intentos en unos 3 días.
- Un endpoint que responde
410 Gonese desactiva de inmediato. - Un endpoint cuyas entregas fallan durante 3 días se desactiva, y quien lo creó y los dueños de la empresa (o los administradores del comercio) reciben un correo.
- Las entregas pendientes de un endpoint desactivado se cancelan. Corrígelo, actívalo de nuevo
(
PATCH /v1/webhook-endpoints/{id}con{"enabled": true}) y reenvía lo que falló: una entrega (POST /v1/webhook-deliveries/{id}/replay) o todas las fallidas de los últimos 7 días (POST /v1/webhook-endpoints/{id}/deliveries/replay). Un reenvío es un intento más.
Registro de entregas
GET /v1/webhook-endpoints/{id}/deliveries lista las entregas (filtra por status, pagina con
cursor); GET /v1/webhook-deliveries/{id} muestra el mensaje enviado y cada intento: cuándo,
cuánto tardó, tu código de estado y el inicio de tu respuesta. El historial se guarda 30 días.
Rota el secreto
POST /v1/webhook-endpoints/{id}/rotate-secret devuelve un secreto nuevo. Durante 24 horas los
mensajes se firman con el nuevo y con el anterior, así despliegas el nuevo sin perder mensajes.
Prueba en local
Antivio no puede llegar a localhost. Expón tu servidor local con un túnel (por ejemplo
cloudflared tunnel --url http://localhost:3000 o ngrok http 3000) y usa su URL HTTPS, o empieza
con un inspector de peticiones como https://webhook.site. Enviar evento de prueba en el panel
(o POST /v1/webhook-endpoints/{id}/test) muestra la respuesta de tu servidor al instante.