Webhooks
Webhooks push events to your HTTP endpoint as they happen, so you do not need to poll the tracking endpoints. Every delivery is signed with HMAC-SHA256 so you can verify it comes from Spedisci.online.
Endpoints can be managed through the API (below) or from the Webhooks page of the web interface; both use the same signature and events.
Events
| Event | When | Payload |
|---|---|---|
tracking.update | The status of a shipment changes (tracking refresh or carrier event) | see below |
shipment.created | A shipment is created — through the API, the web interface or from an order | see below |
stock.opened | The carrier opens a stock (held shipment) on a shipment | see below |
stock.closed | The stock is closed (shipment released, delivered or returned) | same as stock.opened |
Only the events listed in events at registration are delivered. An endpoint registered from the web interface without a list receives tracking.update, stock.opened and stock.closed.
Managing endpoints
| Method | Endpoint | Description |
|---|---|---|
GET | /webhooks | Registered endpoints |
POST | /webhooks | Register an endpoint — requires an active subscription |
GET | /webhooks/{id} | Detail |
DELETE | /webhooks/{id} | Delete the endpoint and its delivery log (204) |
GET | /webhooks/{id}/deliveries | Deliveries of the last 10 days (paginated) |
POST | /webhooks/{id}/test | Send a real, signed tracking.update — rate limit 5 / min |
Register an endpoint
| Field | Type | Required | Description |
|---|---|---|---|
url | string (255) | Yes | HTTPS URL of your endpoint |
description | string (255) | No | |
events[] | string[] | Yes | One or more of the events above |
Request
{
"url": "https://erp.acme.it/webhooks/spedisci",
"description": "ERP sync",
"events": ["tracking.update", "shipment.created", "stock.opened", "stock.closed"]
}The signing secret is returned only in the creation response. Store it right away; it cannot be read again. To rotate it, delete the endpoint and register it again.
| HTTP | code | When |
|---|---|---|
402 | subscription_required | The account has no valid subscription |
422 | validation_failed | Invalid URL or unknown event |
Delivery
Each event is one HTTP POST to your endpoint with a JSON body and these headers:
| Header | Description | Example |
|---|---|---|
Content-Type | application/json | |
User-Agent | spedisci.online webhook v1 <subdomain> | |
Webhook-Timestamp | Unix timestamp (seconds) of the delivery | 1756886400 |
Webhook-Signature | t=<timestamp>,v1=<hex HMAC-SHA256> | t=1756886400,v1=4f8a9b2c…d7e1f3 |
Deliveries are sent asynchronously, independently for each endpoint, with a 10-second timeout:
- A
2xxanswer marks the delivery assuccess. - Network errors and
5xxanswers are retried 3 times in total, after 30 seconds, 2 minutes and 5 minutes. - A
4xxanswer is not retried: it is the endpoint’s final word and is only logged. - An endpoint with no
2xxanswer for 72 hours is deactivated automatically (isActive: false) and the client is notified by email. Re-enable it from the web interface, or delete and register it again.
Answer quickly (ideally 200 before processing) and process the event asynchronously. Events may occasionally arrive more than once or out of order: use ldv + timestamp to deduplicate.
Verifying the signature
- Read
Webhook-TimestampandWebhook-Signature; reject the request if either is missing. - Reject it if the timestamp is older than 300 seconds (replay protection).
- Compute
HMAC-SHA256("<timestamp>.<raw body>", secret)as lowercase hex. - Compare it to the
v1=value with a timing-safe comparison.
Node.js
const crypto = require('crypto')
function verifyWebhook(rawBody, headers, secret) {
const timestamp = headers['webhook-timestamp']
const signature = headers['webhook-signature']
if (!timestamp || !signature) return false
if (Math.floor(Date.now() / 1000) - parseInt(timestamp, 10) > 300) return false
const v1 = signature.split(',').find(p => p.startsWith('v1='))?.slice(3)
if (!v1) return false
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
return v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
}Sign the raw request body exactly as received. Re-serialising the JSON before hashing changes whitespace and key order and breaks the signature.
Payloads
All payloads carry event, timestamp (ISO 8601), ldv (tracking number), contractCode, statusCode (numeric shipment status) and domain (the platform host).
tracking.update
{
"event": "tracking.update",
"timestamp": "2026-09-04T11:50:12+02:00",
"ldv": "DEMO05407027",
"vector_name": "Brt",
"order_id": "ORD-1234",
"client_store_id": null,
"TrackingDettaglio": [
{ "Data": "04/09/2026 11:42", "Stato": "Consegnata", "Luogo": "Roma" },
{ "Data": "04/09/2026 08:15", "Stato": "In consegna", "Luogo": "Roma" },
{ "Data": "03/09/2026 10:26", "Stato": "Spedizione generata", "Luogo": "Milano" }
],
"statusCode": 5,
"contractCode": "brt-standard",
"domain": "demo.spedisci.online"
}| Field | Description |
|---|---|
vector_name | Carrier name |
order_id | The reference of the shipment |
client_store_id | Store the order came from, when created from an order |
TrackingDettaglio[] | Events from the most recent: Data (dd/mm/yyyy HH:MM, local time), Stato, Luogo |
statusCode | Status code after the update (always an integer) |
This payload is identical to the one sent by the legacy webhooks, with event and timestamp added in front: an endpoint written for API v2 keeps working.
shipment.created
{
"event": "shipment.created",
"timestamp": "2026-09-03T10:26:55+02:00",
"ldv": "DEMO05407027",
"shipping_id": 4454058,
"client_id": 1520,
"client_store_id": null,
"order_id": "ORD-1234",
"vector_name": "Brt",
"contractCode": "brt-standard",
"statusCode": 0,
"domain": "demo.spedisci.online"
}stock.opened / stock.closed
{
"event": "stock.opened",
"timestamp": "2026-09-05T09:15:31+02:00",
"ldv": "DEMO05407027",
"giacenza_id": "GIA-2026-000123",
"opened_at": "2026-09-05T09:12:00+02:00",
"stock_id": 9812,
"shipping_id": 4454058,
"statusCode": 6,
"contractCode": "brt-standard",
"domain": "demo.spedisci.online"
}stock_id is the id to use with GET /stocks/{id} and POST /stocks/{id}/actions. giacenza_id is the carrier dossier: after a release the carrier can reissue the LDV and open a second dossier on the same parcel.
Delivery log
GET /webhooks/{id}/deliveries — paginated, last 10 days
{
"data": [
{
"id": 99120,
"event": "tracking.update",
"status": "success",
"responseCode": 200,
"responseBody": "ok",
"payload": { "event": "tracking.update", "ldv": "DEMO05407027", "statusCode": 5 },
"createdAt": "2026-09-04T11:50:13+02:00"
}
],
"links": { "first": "...", "last": "...", "prev": null, "next": null },
"meta": { "current_page": 1, "from": 1, "last_page": 1, "per_page": 50, "to": 1, "total": 1 }
}status is success or failed; responseCode is 0 on connection errors, with the error in responseBody (truncated to 1000 characters).
Testing an endpoint
POST /webhooks/{id}/test — rate limit 5 / min
Sends a real tracking.update built from your most recent shipment in transit, signed with the endpoint’s secret, and returns the outcome:
{
"delivered": true,
"responseCode": 200,
"responseBody": "ok",
"payload": { "event": "tracking.update", "ldv": "DEMO05407027", "statusCode": 3 }
}Answers 404 not_found when the account has no shipment in transit yet.