Skip to content

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.

SkillPurposeCustomer-scoped
browse_catalogSearch and browse your catalog.no
check_availabilityStock for one SKU.no
order_statusStatus and line items of an order.yes
track_shipmentTracking for an order.yes
recommendProducts 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.

Sill sends a POST with a JSON body:

{
"skill_id": "order_status",
"site_id": "01JDEMOSITE00000000000MAIN",
"arguments": {
"order_id": "#1001",
"customer_email": "[email protected]"
},
"observed_at": "2026-10-01T14:22:15.140Z",
"nonce": "01JDEMONONCE00000000000001"
}
FieldMeaning
skill_idWhich skill is being called. Route on this.
site_idYour site’s ID in Sill.
argumentsThe agent’s arguments, in the shape of the skill’s request schema.
observed_atISO-8601 UTC time Sill received the agent’s call.
nonceA unique ID for this call. It is also sent in the X-Sill-Nonce header.

Headers:

HeaderValue
Content-Typeapplication/json
X-Sill-Signaturet=<unix seconds>,v1=<hex HMAC> — see Verify the signature.
X-Sill-NonceSame value as nonce in the body.
X-Sill-Skill-IdSame value as skill_id in the body.
User-AgentSill-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.

  • 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.
Your responseWhat happens
2xx with JSON matching the response schemaSill passes the data to the agent.
404The agent is told nothing was found. Your endpoint stays healthy.
Anything elseThe 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.

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.

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.

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:

  1. Read the raw request body. Do not parse and re-serialize it before checking the signature.
  2. Recompute the HMAC and compare it with v1 in constant time.
  3. Reject the request if t is more than 5 minutes from your server’s clock.
  4. On any failure, return 401 with an empty body.

The examples below were tested against requests signed the way Sill signs them.

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('');
},
);
import hmac
import hashlib
import os
import 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 "", 404
package 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)
}

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 [email protected]. 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.

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.