SendifyAPI docs
Open Sendify

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

go
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.

json
{
    "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.

FieldTypeDescription
message_idstringUnique per event and endpoint. Use it to deduplicate.
typestring"SHIPMENT_BOOKED" or "TRACKING_EVENT". Decides the shape of data.
created_atstringRFC 3339 timestamp of when Sendify sent the message.
shipment_idstringThe shipment the event concerns. Use it against GET /shipments/{shipment_id}.
dataobjectFields 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 with data.source "APP".
  • SHIPMENT_BOOKED_API — booked through this API. Arrives with data.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.

json
{
    "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 fieldTypeDescription
carrierstringCarrier code the shipment was booked with.
productstringCarrier product code the shipment was booked on.
sourcestringThe booking channel: "APP" or "API", matching the subscription the event was sent for.
agreementstring"SENDIFY" when booked on a Sendify carrier agreement, "OWN" when booked on your own. Not separately subscribable — filter on it yourself.
tracking_idstring | nullThe shipment's main tracking number. null for carriers that issue none at booking time.
reference_idstringYour own identifier for the shipment. Can be passed as the reference_id filter on GET /shipments.
invoice_referencestring | nullThe shipment's invoice reference.
customer_referencestringThe shipment's customer reference. Empty when unset.
booked_atstringRFC 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:

json
{
    "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 fieldTypeDescription
eventstringThe tracking event. See the values below.
carrierstringCarrier code the shipment was booked with.
tracking_idstringThe package the event concerns.
reasonstringWhy the event happened, as far as the carrier reported it. See the values below.
descriptionstring | nullThe carrier's own wording for the event, when it supplied any.
locationstringWhere the event happened. Empty when the carrier reported no location.
event_timestringRFC 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.