Skip to content

order_status

order_status looks up an order’s status and line items. 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 → 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 data. ← BUG: attacker@example.com never owned this order.

The wrong pattern is the same class of bug as writing your own storefront order-lookup form and forgetting the customer-scoping check — an agent that guesses an order number can then read any order.

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” — both leak information about which orders exist.

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; those are what mark an endpoint unhealthy.

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

FieldTypeRequiredNotes
order_idstringyesMerchant-facing order name (e.g. #1001) or your internal id. 1–128 chars.
customer_emailstringyesThe email the caller claims owns the order. 3–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": "order_status",
"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/order_status.response.schema.json

FieldTypeRequiredNotes
order_idstringyesEcho the order id. 1–256 chars.
statusenumyespending | paid | fulfilled | cancelled | refunded.
placed_atstringyesISO-8601 UTC of when the order was placed. 20–40 chars.
fulfilled_atstringnoISO-8601 UTC of fulfillment, when applicable.
line_itemsarrayyesUp to 128 items. Each has sku, title, quantity, unit_price.

The canonical response is minimum disclosure. Do NOT include the customer’s name, shipping address, phone, internal notes, or any other detail — additionalProperties: false at every object depth rejects them with malformed_response.

Each line item:

FieldTypeRequiredNotes
skustringyes1–256 chars.
titlestringyes1–512 chars.
quantityintegeryesNon-negative.
unit_priceobjectyes{ amount, amount_decimal, currency } — same shape as browse_catalog.
{
"additionalProperties": false,
"properties": {
"fulfilled_at": { "maxLength": 40, "minLength": 20, "type": "string" },
"line_items": {
"items": {
"additionalProperties": false,
"properties": {
"quantity": { "minimum": 0, "type": "integer" },
"sku": { "maxLength": 256, "minLength": 1, "type": "string" },
"title": { "maxLength": 512, "minLength": 1, "type": "string" },
"unit_price": {
"additionalProperties": false,
"properties": {
"amount": { "type": "number" },
"amount_decimal": { "maxLength": 64, "minLength": 1, "type": "string" },
"currency": { "maxLength": 8, "minLength": 3, "type": "string" }
},
"required": ["amount", "amount_decimal", "currency"],
"type": "object"
}
},
"required": ["sku", "title", "quantity", "unit_price"],
"type": "object"
},
"maxItems": 128,
"type": "array"
},
"order_id": { "maxLength": 256, "minLength": 1, "type": "string" },
"placed_at": { "maxLength": 40, "minLength": 20, "type": "string" },
"status": {
"enum": ["pending", "paid", "fulfilled", "cancelled", "refunded"],
"type": "string"
}
},
"required": ["order_id", "status", "placed_at", "line_items"],
"type": "object"
}
{
"order_id": "#1001",
"status": "fulfilled",
"placed_at": "2026-06-30T14:12:03.000Z",
"fulfilled_at": "2026-07-02T09:41:18.000Z",
"line_items": [
{
"sku": "beans_medium_12oz",
"title": "Medium roast, 12 oz bag",
"quantity": 2,
"unit_price": {
"amount": 1800,
"amount_decimal": "18.00",
"currency": "USD"
}
}
]
}

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.
  • Order exists but is soft-deleted / archived.

Do NOT return a canonical response with placeholder values (empty line_items, status: "pending") — that leaks the existence of an order id you refused to reveal.

  • Extra fields are rejected. additionalProperties: false at every depth — no customer_address, no internal_notes, no payment_method. This is the minimum-disclosure gate; an over-sharing handler is caught at this boundary.
  • status outside the enum. Only the five canonical values are accepted. Map your internal statuses to the closest canonical value.
  • placed_at malformed. Must be a 20–40 char ISO-8601 UTC string. "2026-06-30T14:12:03Z" and "2026-06-30T14:12:03.000Z" both fit.

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)
}