AntivioesLog in

Webhooks

Antivio tells your server when something happens to a shipment, with a signed HTTPS POST. Messages follow the open Standard Webhooks specification: verify them with a few lines of code (below) or any Standard Webhooks library. No Antivio SDK.

Endpoints

Add endpoints in Settings → Developers → Webhooks (merchants: Portal → Developers) or with the API (POST /v1/webhook-endpoints, scope webhooks:write).

Events

TypeWhen
shipment.createda shipment was created (draft or confirmed)
shipment.status_changedits status changed; data.previous_status → data.status
shipment.deliveredit was delivered (also sent as shipment.status_changed)
shipment.exceptionit went to exception: lost, damaged or held (also sent as shipment.status_changed)

GET /v1/webhook-event-types lists them.

The message

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}"
    }
  }
}

Verify the signature

Always verify before trusting a message. The signature is HMAC-SHA256, keyed with your secret (base64 after whsec_), over {webhook-id}.{webhook-timestamp}.{raw body}, sent as v1,<base64>. During a secret rotation the header carries two signatures separated by a space; accept the message when one of them matches. Refuse timestamps more than 5 minutes from your clock (replays).

Use the raw body bytes as received, before any JSON parsing.

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));
}

Test vector (from the Standard Webhooks specification): secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw, webhook-id msg_p5jXN8AQM9LWM0D4loKWxJek, webhook-timestamp 1614265330, body {"test": 2432232314} → signature v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= (use your own clock check off when you try it).

Answer quickly, process later

Retries and disabling

Anything but a 2xx (or no answer in 10 s) is retried after 30 s, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h and 24 h: 9 tries over about 3 days.

Delivery log

GET /v1/webhook-endpoints/{id}/deliveries lists deliveries (filter by status, page with cursor); GET /v1/webhook-deliveries/{id} shows the message sent and every try: when, how long, your status code and the start of your answer. History is kept 30 days.

Rotate the secret

POST /v1/webhook-endpoints/{id}/rotate-secret returns a new secret. For 24 hours messages are signed with both the new and the old one, so you can deploy the new secret without losing a message.

Test locally

Antivio cannot reach localhost. Expose your local server with a tunnel (for example cloudflared tunnel --url http://localhost:3000 or ngrok http 3000) and use its HTTPS URL, or start with a request inspector such as https://webhook.site. Send test event in the dashboard (or POST /v1/webhook-endpoints/{id}/test) shows your server's answer right away.

All guides