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).
- A public
https://URL. Private networks,localhostand cloud metadata addresses are refused, also when a public name later resolves to one of them. - Choose the event types it receives; up to 5 endpoints per company or merchant.
- An endpoint is live or test (
livemode): it only receives events of live, or of sandbox, shipments. Endpoints added with a test token are test endpoints; in the dashboard you choose (the default follows whether your company is live yet). A test token never sees, changes or reads the deliveries of a live endpoint. - A company endpoint receives the events of every shipment of the company; a merchant's endpoint only its own shipments'.
- Each endpoint has its own signing secret (
whsec_…), shown when you create it and revealable in the dashboard.
Events
| Type | When |
|---|---|
shipment.created | a shipment was created (draft or confirmed) |
shipment.status_changed | its status changed; data.previous_status → data.status |
shipment.delivered | it was delivered (also sent as shipment.status_changed) |
shipment.exception | it 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}"
}
}
}
data.shipmentis the shipment asGET /v1/shipments/{id}shows it when the message is built (normally within seconds of the event).data.statusanddata.previous_statusdescribe the event's own transition; when events arrive out of order, checkprevious_statusagainst what you stored, or read the shipment, before updating it.- Money is in minor units (
150000DOP = RD$ 1,500.00) with itscurrency. livemode: falsemarks sandbox shipments. Test messages sent from the dashboard also carry"test": trueand an example shipment.
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
- Answer any
2xxwithin 10 seconds, then do the work (queue it). Antivio does not follow redirects and only reads the first 4 KB of your answer (shown in the delivery log). - Delivery is at least once: the same message may arrive twice. Use
webhook-idto ignore duplicates. - Messages may arrive out of order. Never move a shipment back: apply a change only when its
data.previous_statusmatches what you stored, or compare the shipment'supdated_atwith yours; when in doubt, readGET /v1/shipments/{id}. - Need the freshest state? Read
GET /v1/shipments/{id}with your token.
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.
- An endpoint that answers
410 Goneis disabled at once. - An endpoint whose deliveries keep failing for 3 days is disabled, and the person who made it and the company's owners (or the merchant's admins) get an email.
- Pending deliveries of a disabled endpoint are cancelled. Fix it, enable it again
(
PATCH /v1/webhook-endpoints/{id}with{"enabled": true}) and replay what failed: one delivery (POST /v1/webhook-deliveries/{id}/replay) or every failed one of the last 7 days (POST /v1/webhook-endpoints/{id}/deliveries/replay). A replay is one more try.
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.