Skill Fulfillment Contract v1
The Skill Fulfillment Contract v1 defines what Sill sends when your own HTTPS endpoint answers one of the five default read skills, and what your endpoint must send back. To route a skill to your endpoint, see Fulfill skills from your own endpoint.
| Skill | Purpose | Customer-scoped |
|---|---|---|
browse_catalog | Search and browse your catalog. | no |
check_availability | Stock for one SKU. | no |
order_status | Status and line items of an order. | yes |
track_shipment | Tracking for an order. | yes |
recommend | Products related to a SKU or a query. | no |
The transactional skills, place_order and request_refund, are not part of this contract.
Each skill has a request schema and a response schema, published as plain JSON Schema at https://docs.sill.so/skills/v1/<skill_id>.request.schema.json and …response.schema.json. They use only type, required, properties, additionalProperties, minLength / maxLength, minimum / maximum, enum, items and maxItems; there is no $ref, oneOf or format.
The request Sill sends
Section titled “The request Sill sends”Sill sends a POST with a JSON body:
{ "skill_id": "order_status", "site_id": "01JDEMOSITE00000000000MAIN", "arguments": { "order_id": "#1001", "customer_email": "buyer@example.com" }, "observed_at": "2026-10-01T14:22:15.140Z", "nonce": "01JDEMONONCE00000000000001"}| Field | Meaning |
|---|---|
skill_id | Which skill is being called. Route on this. |
site_id | Your site’s ID in Sill. |
arguments | The agent’s arguments, in the shape of the skill’s request schema. |
observed_at | ISO-8601 UTC time Sill received the agent’s call. |
nonce | A unique ID for this call. It is also sent in the X-Sill-Nonce header. |
Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Sill-Signature | t=<unix seconds>,v1=<hex HMAC> — see Verify the signature. |
X-Sill-Nonce | Same value as nonce in the body. |
X-Sill-Skill-Id | Same value as skill_id in the body. |
User-Agent | Sill-Webhook/1 |
These five skills are fulfilled by a webhook: Sill posts the JSON body above to your URL. MCP-server fulfillment is available only for custom skills.
Endpoint requirements
Section titled “Endpoint requirements”- HTTPS on port 443, on the domain you verified for the site or one of its subdomains. No IP addresses and no credentials in the URL.
- Redirects are not followed.
- Respond within 5 seconds.
- Requests are at most 16 KB; your response must be at most 64 KB.
What your response does
Section titled “What your response does”| Your response | What happens |
|---|---|
2xx with JSON matching the response schema | Sill passes the data to the agent. |
404 | The agent is told nothing was found. Your endpoint stays healthy. |
| Anything else | The agent gets a short error, never your response body, and the call counts as a failure. |
A response “in the wrong shape” includes an empty body, a body that is not JSON, a missing required field, a value outside an enum or length limit, and any field the schema does not list. Every object in every schema sets additionalProperties: false, so an extra field such as customer_address is rejected rather than passed on.
When a skill is marked unreachable
Section titled “When a skill is marked unreachable”A failure is an error status other than 404, a timeout, an oversized response or a response in the wrong shape. One failure does not take the skill offline. After 3 failed calls in a row, the skill is marked UNREACHABLE in Settings → Skills, and your agent card, MCP server and catalog stop listing it. A successful call or a 404 resets the count.
Sill then re-checks the endpoint on its own, about 5 minutes after it was marked unreachable, then after 15 minutes, after 1 hour, and every hour after that. When a check passes, the skill is marked HEALTHY and listed again within about a minute. To re-check straight away after you fix the endpoint, click Re-run activation battery on the skill in Settings → Skills.
Before passing your data to an agent, Sill also redacts text that looks like secrets, prompt-injection instructions or script markup. Sill does not otherwise check what your data means: you are the source of truth for orders, stock and catalog content.
Customer-scoped skills
Section titled “Customer-scoped skills”order_status and track_shipment return information about one customer’s order. Sill sends the customer_email the agent supplied with every call, but only your systems know which email owns which order.
Before returning anything, confirm that customer_email belongs to the order. If it does not, or the order does not exist, return 404 with an empty body. Use the same response for both cases, so a caller cannot tell whether an order number exists. Without this check, anyone who guesses an order number can read that order through an agent.
The order_status page has a correct example and a compare function. The self-test checks this against your real data.
Verify the signature
Section titled “Verify the signature”X-Sill-Signature: t=<unix seconds>,v1=<hex>v1 is the lowercase hex HMAC-SHA256 of the string <t>.<raw request body>, keyed with the secret Sill showed you once when you saved the endpoint. If you lose it, click Rotate secret on the skill in Settings → Skills; the new secret applies to every request from then on.
In your handler:
- Read the raw request body. Do not parse and re-serialize it before checking the signature.
- Recompute the HMAC and compare it with
v1in constant time. - Reject the request if
tis more than 5 minutes from your server’s clock. - On any failure, return
401with an empty body.
The examples below were tested against requests signed the way Sill signs them.
TypeScript (Node.js and Express)
Section titled “TypeScript (Node.js and Express)”import { createHmac, timingSafeEqual } from 'node:crypto';import express from 'express';
const SILL_SECRET = process.env.SILL_SKILL_SECRET as string;const MAX_AGE_SECONDS = 5 * 60;
export function verifySillSignature( header_value: string, raw_body: string, now_seconds = Math.floor(Date.now() / 1000),): boolean { let t = -1; let sig = ''; for (const part of header_value.split(',')) { const eq = part.indexOf('='); if (eq < 0) continue; const k = part.slice(0, eq); const v = part.slice(eq + 1); if (k === 't') t = Number(v); else if (k === 'v1') sig = v; } if (!Number.isFinite(t) || t < 0 || sig.length === 0) return false; if (Math.abs(now_seconds - t) > MAX_AGE_SECONDS) return false;
const expected_hex = createHmac('sha256', SILL_SECRET) .update(`${t}.${raw_body}`) .digest('hex');
const a = Buffer.from(expected_hex, 'hex'); const b = Buffer.from(sig, 'hex'); if (a.length !== b.length) return false; return timingSafeEqual(a, b);}
const app = express();
app.post( '/sill/skills', express.raw({ type: 'application/json' }), // keep the raw bytes for the signature check (req, res) => { const raw = req.body.toString('utf8'); if (!verifySillSignature(req.get('x-sill-signature') ?? '', raw)) { res.status(401).send(''); return; } const call = JSON.parse(raw) as { skill_id: string; site_id: string; arguments: Record<string, unknown>; observed_at: string; nonce: string; }; // Route on call.skill_id. Reply with res.json(<response object>), // or res.status(404).send('') when there is nothing to return. res.status(404).send(''); },);Python
Section titled “Python”import hmacimport hashlibimport osimport time
from flask import Flask, request, abort
SILL_SECRET = os.environ["SILL_SKILL_SECRET"].encode("utf-8")MAX_AGE_SECONDS = 5 * 60
def verify_sill_signature(header_value: str, raw_body: bytes) -> bool: t = -1 sig = "" for part in header_value.split(","): if "=" not in part: continue k, v = part.split("=", 1) if k == "t": try: t = int(v) except ValueError: return False elif k == "v1": sig = v if t < 0 or not sig: return False if abs(int(time.time()) - t) > MAX_AGE_SECONDS: return False
signed_payload = f"{t}.".encode("utf-8") + raw_body expected = hmac.new(SILL_SECRET, signed_payload, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig)
app = Flask(__name__)
@app.post("/sill/skills")def sill_skills(): raw = request.get_data() if not verify_sill_signature(request.headers.get("X-Sill-Signature", ""), raw): abort(401) call = request.get_json() # Route on call["skill_id"]. Return the response object as JSON, # or ("", 404) when there is nothing to return. return "", 404package sillfulfill
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os" "strconv" "strings" "time")
var sillSecret = []byte(os.Getenv("SILL_SKILL_SECRET"))
const maxAgeSeconds int64 = 5 * 60
func VerifySillSignature(headerValue string, rawBody []byte) bool { var t int64 = -1 var sig string for _, part := range strings.Split(headerValue, ",") { eq := strings.IndexByte(part, '=') if eq < 0 { continue } k := part[:eq] v := part[eq+1:] switch k { case "t": n, err := strconv.ParseInt(v, 10, 64) if err != nil { return false } t = n case "v1": sig = v } } if t < 0 || sig == "" { return false } diff := time.Now().Unix() - t if diff < 0 { diff = -diff } if diff > maxAgeSeconds { return false }
mac := hmac.New(sha256.New, sillSecret) mac.Write([]byte(strconv.FormatInt(t, 10))) mac.Write([]byte{'.'}) mac.Write(rawBody) expected := mac.Sum(nil)
got, err := hex.DecodeString(sig) if err != nil { return false } return hmac.Equal(expected, got)}
func SillSkillsHandler(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } if !VerifySillSignature(r.Header.Get("X-Sill-Signature"), raw) { w.WriteHeader(http.StatusUnauthorized) return } // Route on the parsed body's skill_id. Write the response object as JSON, // or w.WriteHeader(http.StatusNotFound) when there is nothing to return. w.WriteHeader(http.StatusNotFound)}Activation checks
Section titled “Activation checks”When you save an endpoint, when you click Re-run activation battery, and when Sill re-checks an unreachable endpoint, Sill sends test requests: a GET, a POST with no signature, a POST with a wrong signature, and one correctly signed POST. The GET must get any status below 500. The three POSTs carry the headers X-Sill-Probe: 1 and User-Agent: Sill-Activation-Probe/1. Their bodies are valid against the skill’s request schema, with made-up values such as the order ID sill_probe_…, the SKU sill_probe_sku_… (sent as sku for check_availability and seed_sku for recommend) and the email probe@sill.so. As on every request, the body’s nonce matches the X-Sill-Nonce header.
Answer test requests as you would any request: reject the unsigned and wrong-signature ones, and answer the signed one with 404 or a valid response. The self-test describes each check.
Versioning
Section titled “Versioning”This is version 1. Sill may add optional fields to requests and responses within v1, so ignore request fields you do not use. Removing or renaming a field requires a new version.
See also
Section titled “See also”- Fulfill skills from your own endpoint — setting it up in the dashboard.
- Self-test — the checks to run before going live.
- Custom skills — your own skills, sent to your endpoint with the same signature.
- Audit envelope — every call is recorded in your signed audit log.