Skip to content

track_shipment

track_shipment returns shipment tracking for an order. Like order_status, it is customer-scoped: Sill sends customer_email as a required argument, and your handler MUST verify that email belongs to the order before returning any data.

Sill cannot enforce this for you. Only your customer database knows which email owns which order.

Correct

Request: { order_id: "#1001", customer_email: "buyer@example.com" }
Handler:
1. Look up order #1001.
2. Compare its customer email (in constant time) to "buyer@example.com".
3. If they match → look up the shipment and return the canonical result.
4. If they don't match, or order does not exist → return HTTP 404 with an empty body.

Wrong

Request: { order_id: "#1001", customer_email: "attacker@example.com" }
Handler (missing the check):
1. Look up order #1001.
2. Return its tracking data. ← BUG: attacker can now enumerate any tracking chain.

The wrong pattern leaks a tracking chain (carrier, tracking number, expected delivery) to any caller who guesses an order number. Refuse it.

Compare the email argument to your stored customer email in constant time, lowercased and NFKC-normalized on both sides. In Node.js:

import { timingSafeEqual } from 'node:crypto';
function emailsMatch(a: string, b: string): boolean {
const na = a.trim().toLowerCase().normalize('NFKC');
const nb = b.trim().toLowerCase().normalize('NFKC');
const ba = Buffer.from(na, 'utf8');
const bb = Buffer.from(nb, 'utf8');
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}

If the emails do not match, respond HTTP 404 with an empty body. Do NOT distinguish “order not found” from “wrong customer”.

A 404 is a normal, expected response — it is treated as a clean “not found” and returned to the agent as such. It does not count against your endpoint’s health. Reserve non-404 error statuses (5xx, timeouts) for genuine failures.

Machine-readable: /skills/v1/track_shipment.request.schema.json

FieldTypeRequiredNotes
order_idstringyes1–128 chars.
customer_emailstringyes3–320 chars.
{
"additionalProperties": false,
"properties": {
"customer_email": {
"maxLength": 320,
"minLength": 3,
"type": "string"
},
"order_id": {
"maxLength": 128,
"minLength": 1,
"type": "string"
}
},
"required": ["order_id", "customer_email"],
"type": "object"
}
{
"skill_id": "track_shipment",
"site_id": "01EXAMPLE00000000000000000",
"arguments": {
"order_id": "#1001",
"customer_email": "buyer@example.com"
},
"observed_at": "2026-07-05T18:22:15.140Z",
"nonce": "01K1EXAMPLE0000000000000000"
}

Machine-readable: /skills/v1/track_shipment.response.schema.json

Only order_id is required. Every other field is optional so you can honestly represent “the order exists but nothing has shipped yet” as { "order_id": "#1001" }.

FieldTypeRequiredNotes
order_idstringyesEcho the order id. 1–256 chars.
tracking_numberstringnoCarrier-issued tracking number. 1–256 chars.
carrierstringnoCarrier name / code. 1–256 chars.
statusstringnoFree-form status. 1–64 chars. not_yet_shipped, in_transit, delivered, pending, cancelled, or a carrier-specific string.
expected_deliverystringnoISO-8601 UTC. 20–40 chars.
tracking_urlstringnoHTTPS URL to the carrier’s tracking page. 1–2048 chars.

Do NOT include the customer name, shipping address, phone, or other customer-scoped detail — additionalProperties: false rejects them as malformed_response. Minimum disclosure by construction.

{
"additionalProperties": false,
"properties": {
"carrier": { "maxLength": 256, "minLength": 1, "type": "string" },
"expected_delivery": { "maxLength": 40, "minLength": 20, "type": "string" },
"order_id": { "maxLength": 256, "minLength": 1, "type": "string" },
"status": { "maxLength": 64, "minLength": 1, "type": "string" },
"tracking_number": { "maxLength": 256, "minLength": 1, "type": "string" },
"tracking_url": { "maxLength": 2048, "minLength": 1, "type": "string" }
},
"required": ["order_id"],
"type": "object"
}
{
"order_id": "#1001",
"tracking_number": "1Z999AA10123456784",
"carrier": "UPS",
"status": "in_transit",
"expected_delivery": "2026-07-08T00:00:00.000Z",
"tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784"
}
{
"order_id": "#1001",
"status": "not_yet_shipped"
}

For any of these conditions, return HTTP 404 with an empty body:

  • Order does not exist.
  • Email does not match the order’s stored customer email.

Do NOT return a canonical response with a fabricated status — that leaks the existence of an order id you refused to reveal.

  • Extra fields are rejected. additionalProperties: false — no shipping_address, no customer_name.
  • Bounds. tracking_url up to 2048 chars; status up to 64.

Every request Sill POSTs to your endpoint carries a X-Sill-Signature: t=<unix>,v1=<hex> header, where <hex> is HMAC-SHA256("<t>.<raw-body>") with your shared secret.

import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(header: string, body: string, secret: string): boolean {
const map = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
const t = Number(map.t);
const sig = String(map.v1 ?? '');
if (!Number.isFinite(t) || sig.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest();
const got = Buffer.from(sig, 'hex');
if (expected.length !== got.length) return false;
return timingSafeEqual(expected, got);
}
import hmac, hashlib, time
def verify(header: str, body: bytes, secret: bytes) -> bool:
parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
try:
t = int(parts['t'])
except (KeyError, ValueError):
return False
sig = parts.get('v1', '')
if abs(int(time.time()) - t) > 300 or not sig:
return False
expected = hmac.new(secret, f'{t}.'.encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
func Verify(header string, body []byte, secret []byte) bool {
var t int64 = -1
var sig string
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) != 2 { continue }
switch kv[0] {
case "t":
n, err := strconv.ParseInt(kv[1], 10, 64)
if err != nil { return false }
t = n
case "v1":
sig = kv[1]
}
}
if t < 0 || sig == "" { return false }
diff := time.Now().Unix() - t
if diff < 0 { diff = -diff }
if diff > 300 { return false }
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(body)
got, err := hex.DecodeString(sig)
if err != nil { return false }
return hmac.Equal(mac.Sum(nil), got)
}