Get started · 7 min read
Webhooks
Receive booking confirmations and tracking events as they happen.
Polling works well for showing a tracking timeline, but not for reacting to something the moment it happens. Set up a webhook endpoint and Sendify will POST new events to it as they happen.
Webhooks cover two families of events:
- Booking confirmations, sent as soon as Sendify has booked a shipment with the carrier. These fire for every shipment your team books, including the ones booked by hand in the Sendify web app, which makes them the way to keep your own system in step without polling.
- Tracking events, sent as the carrier reports progress on a shipment.
Setting up Webhook endpoints
Webhooks endpoints are added in Settings > API.
You can add a maximum of 5 different endpoints, and you choose which event types to push to each endpoint. An endpoint only receives the types you have selected for it; new event types are never added to an existing endpoint automatically.
Each endpoint gets its own secret used for verification which may be regenerated if you believe it to be compromised.
Responding to Webhook events
Respond with any 2xx status code within 3 seconds to acknowledge an event. Anything else, including a timeout, counts as a failed delivery.
Do the actual work after you have responded: store the event, answer 200, and process it from there. That keeps a slow downstream system from turning into failed deliveries.
Events are sent independently of each other, so they can arrive out of order. Use the timestamps inside data to order events, not the order in which they arrived.
Webhooks are a notification, not a guaranteed record. If your endpoint was down, reconcile by fetching the shipment with GET /shipments/{shipment_id} rather than relying on every event arriving.
Verifying Webhook events
Every POST request sent to webhook endpoints by Sendify includes a custom http header Sendify-Signature: t=<TIME>,v1=<SIGNATURE>.
<TIME> is a unix timestamp in base 10 referring to when the request was created.
<SIGNATURE> is a hexadecimal (base 16) representation of the message signature.
The signature is verified using HMAC-SHA256 with the endpoint secret as the secret key and <TIME>.<REQUEST BODY> as the bytestream.
<REQUEST BODY> is the entire body of the POST request as a bytestream. Verify against the raw bytes exactly as they arrived, before parsing the JSON: re-serialising a parsed body changes the bytes and the signature will not match.
Reject events whose <TIME> is more than a few minutes from your own clock, so a captured request cannot be replayed later.
Example verification go code
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"strconv"
"strings"
"time"
)
const maxClockSkew = 5 * time.Minute
// parseSignatureHeader splits "t=<TIME>,v1=<SIGNATURE>" into its two parts.
func parseSignatureHeader(header string) (timestamp string, signature string) {
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(part, "=")
switch key {
case "t":
timestamp = value
case "v1":
signature = value
}
}
return timestamp, signature
}
func verifySignature(secret string, timestamp string, signature string, body []byte) bool {
sent, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if time.Since(time.Unix(sent, 0)).Abs() > maxClockSkew {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return subtle.ConstantTimeCompare([]byte(expected), []byte(signature)) == 1
}This verifies that the POST request was made by someone with access to the endpoint secret. If you accidentally share this secret you should go into the web application and regenerate the secret to ensure only Sendify may send verified events again.
Event schema
Every event is delivered in the same envelope. type names the shape of data, which holds the
fields specific to that kind of event. The API reference also describes each payload as a model
under Webhooks, for generating types or validating what you receive.
{
"message_id": "c29tZWFyYml0cmFyeXVuaXF1ZWJ5dGVzZm9yZGVkdXA",
"type": "TRACKING_EVENT",
"created_at": "2025-04-18T10:00:03Z",
"shipment_id": "2c85cd096caedc5666b494409ad743c9f0c3841095c8e367d12928950bc1eaa7",
"data": { }
}The message ID is arbitrary but unique per event and endpoint, and stays the same if a delivery is retried. Use it to deduplicate. The same event sent to two of your endpoints carries a different message_id on each.
created_at is when Sendify sent the message, which is not the same as when the event itself
happened — for that, read the timestamp inside data.
| Field | Type | Description |
|---|---|---|
message_id | string | Unique per event and endpoint. Use it to deduplicate. |
type | string | "SHIPMENT_BOOKED" or "TRACKING_EVENT". Decides the shape of data. |
created_at | string | RFC 3339 timestamp of when Sendify sent the message. |
shipment_id | string | The shipment the event concerns. Use it against GET /shipments/{shipment_id}. |
data | object | Fields specific to type. See below. |
Switch on type to decide how to read data. Treat an unrecognised type as something to
acknowledge and ignore rather than an error, and tolerate fields in data you do not know yet: new
event types and fields are added over time.
What you subscribe to in Settings > API is more specific than type. You pick individual
events, such as SHIPMENT_BOOKED_APP or DELAYED, and each arrives as the type for its shape,
with the specific event inside data.
Booking confirmations
type is "SHIPMENT_BOOKED", sent once when Sendify has booked the shipment with the carrier.
There is one subscription per booking channel, so you can subscribe to only the channel you care
about:
SHIPMENT_BOOKED_APP— booked by hand in the Sendify web app. Arrives withdata.source"APP".SHIPMENT_BOOKED_API— booked through this API. Arrives withdata.source"API".
If you book through the API you already know about those shipments, so subscribing to
SHIPMENT_BOOKED_APP alone is usually what you want. Subscribe to both to hear about every
booking your team makes.
{
"message_id": "c29tZWFyYml0cmFyeXVuaXF1ZWJ5dGVzZm9yZGVkdXA",
"type": "SHIPMENT_BOOKED",
"created_at": "2025-04-17T08:30:02Z",
"shipment_id": "2c85cd096caedc5666b494409ad743c9f0c3841095c8e367d12928950bc1eaa7",
"data": {
"carrier": "ups_sweden",
"product": "ups_standard",
"source": "APP",
"agreement": "SENDIFY",
"tracking_id": "1ZXXXXXXXXXXXXXXXX",
"reference_id": "ORDER-1234",
"invoice_reference": "INV-77",
"customer_reference": "CUST-9",
"booked_at": "2025-04-17T08:30:00Z"
}
}data field | Type | Description |
|---|---|---|
carrier | string | Carrier code the shipment was booked with. |
product | string | Carrier product code the shipment was booked on. |
source | string | The booking channel: "APP" or "API", matching the subscription the event was sent for. |
agreement | string | "SENDIFY" when booked on a Sendify carrier agreement, "OWN" when booked on your own. Not separately subscribable — filter on it yourself. |
tracking_id | string | null | The shipment's main tracking number. null for carriers that issue none at booking time. |
reference_id | string | Your own identifier for the shipment. Can be passed as the reference_id filter on GET /shipments. |
invoice_reference | string | null | The shipment's invoice reference. |
customer_reference | string | The shipment's customer reference. Empty when unset. |
booked_at | string | RFC 3339 timestamp of when the booking was confirmed with the carrier. |
Use shipment_id to fetch the full shipment, including its packages and available documents, from
GET /shipments/{shipment_id}.
This is Sendify confirming the booking, so it does not wait on the carrier: the carrier's own first tracking event can follow hours later, or not at all.
Tracking events
type is "TRACKING_EVENT", sent as the carrier reports progress on a package. data.event is
the specific event, named as in your subscription:
{
"message_id": "c29tZWFyYml0cmFyeXVuaXF1ZWJ5dGVzZm9yZGVkdXA",
"type": "TRACKING_EVENT",
"created_at": "2025-04-18T10:00:03Z",
"shipment_id": "2c85cd096caedc5666b494409ad743c9f0c3841095c8e367d12928950bc1eaa7",
"data": {
"event": "DELAYED",
"carrier": "ups_sweden",
"tracking_id": "1ZXXXXXXXXXXXXXXXX",
"reason": "OPERATING_CONDITIONS",
"description": "Your parcel was delayed due to operating conditions. We are trying our best to deliver it as soon as possible.",
"location": "Arlanda, SE",
"event_time": "2025-04-18T10:00:00Z"
}
}data field | Type | Description |
|---|---|---|
event | string | The tracking event. See the values below. |
carrier | string | Carrier code the shipment was booked with. |
tracking_id | string | The package the event concerns. |
reason | string | Why the event happened, as far as the carrier reported it. See the values below. |
description | string | null | The carrier's own wording for the event, when it supplied any. |
location | string | Where the event happened. Empty when the carrier reported no location. |
event_time | string | RFC 3339 timestamp of when the carrier reported the event happening. |
The valid values for data.event are:
"AWAITING_ACTION""CANCELED""DELAYED""DELIVERED""DELIVERY_FAILURE""IN_TRANSIT""ON_HOLD""OUT_FOR_DELIVERY""OUT_FOR_PICKUP""PICKUP""PICKUP_FAILURE""RETURNED"
The valid values for data.reason are:
"CAPACITY_SHORTAGE""CARRIER_ERROR""CLOSEST_CENTER""CONSIGNEE_ADDRESS_INCORRECT""CONSIGNEE_NOT_MET""CONSIGNEE_REFUSED""CUSTOMS""CUSTOMS_COMMENCED""DELIVERY_UNSUCCESSFUL""FUTURE_DELIVERY""FUTURE_PICKUP""INCORRECT_SIZE_OR_WEIGHT""LATE_LINEHAUL""LOCATION_CLOSED""MISROUTED""MISSING_OR_INCORRECT_INFO""MISSING_PACKAGE""NEED_INFORMATION""NEED_PAYMENT""NON_DAILY_DELIVERY""OPERATING_CONDITIONS""PROHIBITED_ITEMS""RECEIVER_REFUSED""RECEIVER_UNAVAILABLE""SENDER_UNAVAILABLE""SERVICE_CHANGE""SHIPMENT_DAMAGED""TIME_CONSTRAINT""UNKNOWN"
Need help? Contact api@sendify.com and include the X-Sendify-Request-ID header from the relevant request if available.