Silah Connect
Silah Connect links a Silah workspace to the business's own website or app. Once a site is connected:
Silah AI knows the customer. It can ask your site who the customer is, and use the tools you offer, such as looking up an invoice, checking a server, or listing domains.
Customers can prove who they are. They can do it in the chat widget with a token your server signs, or from WhatsApp and Messenger with a link to a page on your site.
Your site gets a ticket system. Your site can open, list, read and answer support tickets that land in the Silah inbox, and Silah tells your site when the team replies.
Connect is open to every workspace. You need one Site ID and one Secret, made either in Silah or by your site, and one HTTPS base URL on your server. Nothing else is configured by hand.
This page is the full contract. The machine-readable version is the OpenAPI 3.1 file at /openapi/silah-connect-v1.yaml. Silah's own implementation is checked against both.
Overview#
Connect has two directions, and both are signed the same way with the same secret:
| Direction | Who calls | What for |
|---|---|---|
| Silah → your site | Silah calls https://your-site/…/manifest, /customers/resolve, /tools/{name}, /events | The AI learns who the customer is and uses your tools; you hear about ticket replies |
| Your site → Silah | Your server calls https://silah.chat/api/v1/connect/v1/… | Tickets, and confirming verification links |
Every capability is optional. A site that only wants tickets serves a manifest and nothing else. A site that only wants the AI to look things up never calls Silah.
The secret never leaves your server. The browser only ever sees the Site ID and short-lived identity tokens your server mints.
Quick start#
Connect the site in Silah. Go to Admin → Setup → Connected sites → Connect a site, enter a name and the base URL, for example
https://example.com/silah. Silah shows the Site ID and Secret once, so copy both into your server's configuration. If your site generated its own credentials (see Credentials), choose My site made them and paste them instead.Serve the manifest. Your server answers a signed
GET {base}/manifestwith what it supports, plus a proof that it holds the secret (see Manifest).Press Test connection. Silah fetches the manifest, checks the proof, and shows the capabilities and tools it found.
Add what you need:
resolveandtoolsso the AI can help signed-in customers.An identity token in the widget so web visitors are verified.
verify_linkso WhatsApp and Messenger customers can prove who they are.Calls to the ticket API for a support page on your site.
Re-test after every manifest change. Silah keeps the last good manifest, so a broken deploy cannot take your tools away mid-conversation.
Credentials#
| Format | Who makes it | |
|---|---|---|
| Site ID | site_ followed by 16 to 64 letters and digits, for example site_8fK2mQ9xLp4RtV7nYc1Wb3Zs | Silah, or your site |
| Secret | Silah makes scs_ plus 40 random characters. A secret your site makes must be 32 to 200 printable ASCII characters with no spaces, and random (at least 12 distinct characters) | Silah, or your site |
Generated in Silah. The secret is shown once, when it is made. Silah stores it encrypted and never shows it again; the dashboard keeps only a hint (the last four characters).
Generated on your site. Useful when a product connects many customer workspaces, or when you do not want a secret to pass through a browser. Generate the Site ID and Secret on your server, show them to your admin, and have them paste both into Silah. Silah tests the connection at once.
One Site ID belongs to one workspace. To connect the same site to another workspace, generate another Site ID.
Rotating a secret#
A site holds at most two secrets at a time:
In Silah, choose Rotate next to the secret. Silah generates a second secret and shows it once.
Configure the new secret on your server. While both exist, accept signatures from either. Silah signs its own requests with the newest secret.
Revoke the old secret in Silah. A site always keeps at least one.
Signing requests#
Every request in both directions carries four headers:
| Header | Value |
|---|---|
Silah-Site-Id | The Site ID |
Silah-Timestamp | The current Unix time in seconds, for example 1767225600 |
Silah-Nonce | 16 to 64 characters from A-Z a-z 0-9 _ -, new for every request |
Silah-Signature | v1= followed by the hex HMAC-SHA256 of the canonical string, keyed with the secret |
The canonical string is six lines joined with \n (no trailing newline):
v1
{Silah-Timestamp}
{Silah-Nonce}
{METHOD in upper case}
{path and query exactly as sent, for example /api/v1/connect/v1/tickets?status=open}
{hex SHA-256 of the raw body; for an empty body, the SHA-256 of zero bytes}Sign the full request target. That means the path and the query string, byte for byte as they go on the wire. Do not re-encode or sort it.
Silah signs the path it sends you. That is your base URL's path plus the endpoint. With base URL
https://example.com/silah, the manifest request is signed over/silah/manifest. If a reverse proxy strips the prefix before your app sees it, verify against the original path.
To verify a request:
The Site ID is yours.
The timestamp is within 5 minutes of your clock.
The nonce is well formed and you have not seen it in the last 10 minutes. Keep seen nonces in Redis, a database, or memory with a TTL.
A
v1=signature matches one of your secrets. Compare in constant time.
Silah-Signature may carry several signatures, separated by spaces or commas, for example v1=ab12… v1=cd34…. Accept the request when any v1= signature matches, and ignore versions you do not know. That is how a later protocol version can be added without breaking you.
Test vector#
Any correct implementation reproduces these values exactly.
| Input or output | Value |
|---|---|
| Secret | scs_TestSecretDoNotUse0123456789abcdefghij |
| Timestamp | 1767225600 |
| Nonce | n0nce-For-Docs-0001 |
| Request | POST /api/v1/connect/v1/tickets |
| Body | {"customer":{"subject":"user_42"},"subject":"Invoice question","body":"Hello"} |
| Body SHA-256 | bd06a2c758b3155dc1a87abefb94ee5c8793cc826a1d334608e475baca5bfbae |
Silah-Signature | v1=2586dc27ac6b0f0b72bd9ecf2da055fedb642543258cd157acfc6ae0f1b040b1 |
| Manifest proof for this nonce | 5ea0700c2c3b65dafcb99b1b43398cdeea5e3bf91b5aa5f16862027d109791d1 |
Node.js#
import crypto from "node:crypto";
const hex = (alg, data, key) =>
(key ? crypto.createHmac(alg, key) : crypto.createHash(alg)).update(data).digest("hex");
function canonical(ts, nonce, method, target, body) {
return ["v1", ts, nonce, method.toUpperCase(), target, hex("sha256", body)].join("\n");
}
// Sign a call to Silah. `target` is the path and query, e.g. "/api/v1/connect/v1/tickets".
export function signHeaders(siteId, secret, method, target, body = "") {
const ts = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(18).toString("base64url");
const sig = hex("sha256", canonical(ts, nonce, method, target, body), secret);
return { "Silah-Site-Id": siteId, "Silah-Timestamp": ts, "Silah-Nonce": nonce, "Silah-Signature": "v1=" + sig };
}
// Verify a call from Silah. `rawBody` is the exact bytes received (a Buffer or "").
export function verify(headers, method, target, rawBody, { siteId, secrets, seenNonce }) {
const ts = headers["silah-timestamp"], nonce = headers["silah-nonce"];
if (headers["silah-site-id"] !== siteId) return false;
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
if (!/^[A-Za-z0-9_-]{16,64}$/.test(nonce)) return false;
const given = String(headers["silah-signature"] || "").split(/[\s,]+/).filter((s) => s.startsWith("v1=")).map((s) => s.slice(3));
const msg = canonical(ts, nonce, method, target, rawBody);
const ok = secrets.some((secret) => {
const want = Buffer.from(hex("sha256", msg, secret));
return given.some((g) => g.length === want.length && crypto.timingSafeEqual(Buffer.from(g), want));
});
return ok && !seenNonce(nonce); // seenNonce records the nonce and reports whether it was already there
}PHP#
function silah_canonical($ts, $nonce, $method, $target, $body) {
return implode("\n", ["v1", $ts, $nonce, strtoupper($method), $target, hash('sha256', $body)]);
}
function silah_sign_headers($siteId, $secret, $method, $target, $body = '') {
$ts = (string) time();
$nonce = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '=');
$sig = hash_hmac('sha256', silah_canonical($ts, $nonce, $method, $target, $body), $secret);
return ["Silah-Site-Id: $siteId", "Silah-Timestamp: $ts", "Silah-Nonce: $nonce", "Silah-Signature: v1=$sig"];
}
// $target is $_SERVER['REQUEST_URI']; $body is file_get_contents('php://input').
function silah_verify($siteId, array $secrets, $method, $target, $body) {
$ts = $_SERVER['HTTP_SILAH_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_SILAH_NONCE'] ?? '';
if (($_SERVER['HTTP_SILAH_SITE_ID'] ?? '') !== $siteId) return false;
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) return false;
if (!preg_match('/^[A-Za-z0-9_-]{16,64}$/', $nonce)) return false;
$msg = silah_canonical($ts, $nonce, $method, $target, $body);
foreach (preg_split('/[\s,]+/', $_SERVER['HTTP_SILAH_SIGNATURE'] ?? '') as $part) {
if (strncmp($part, 'v1=', 3) !== 0) continue;
foreach ($secrets as $secret) {
if (hash_equals(hash_hmac('sha256', $msg, $secret), substr($part, 3))) {
return silah_claim_nonce($nonce); // your replay store: true if the nonce is new
}
}
}
return false;
}Python#
import hashlib, hmac, re, secrets, time
def canonical(ts, nonce, method, target, body: bytes) -> str:
return "\n".join(["v1", ts, nonce, method.upper(), target, hashlib.sha256(body).hexdigest()])
def sign_headers(site_id, secret, method, target, body=b""):
ts, nonce = str(int(time.time())), secrets.token_urlsafe(18)
sig = hmac.new(secret.encode(), canonical(ts, nonce, method, target, body).encode(), hashlib.sha256).hexdigest()
return {"Silah-Site-Id": site_id, "Silah-Timestamp": ts, "Silah-Nonce": nonce, "Silah-Signature": "v1=" + sig}
def verify(headers, method, target, body: bytes, site_id, secret_list, claim_nonce) -> bool:
ts, nonce = headers.get("Silah-Timestamp", ""), headers.get("Silah-Nonce", "")
if headers.get("Silah-Site-Id") != site_id or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
if not re.fullmatch(r"[A-Za-z0-9_-]{16,64}", nonce):
return False
msg = canonical(ts, nonce, method, target, body).encode()
given = [p[3:] for p in re.split(r"[\s,]+", headers.get("Silah-Signature", "")) if p.startswith("v1=")]
ok = any(hmac.compare_digest(hmac.new(s.encode(), msg, hashlib.sha256).hexdigest(), g) for s in secret_list for g in given)
return ok and claim_nonce(nonce)Your site's endpoints#
All paths are relative to the base URL entered in Silah, which must be HTTPS on a public address. Silah:
signs every request as above and sends JSON with
Content-Type: application/json;identifies itself as
User-Agent: Silah-Connect/1;waits at most 10 seconds for an answer and reads at most 1 MB;
does not follow redirects. Enter the final URL as the base URL.
Answer with JSON. To refuse, return a 4xx or 5xx status with Silah's error shape:
{ "error": { "code": "invoice_not_found", "message": "No invoice INV-1042 on this account." } }The message, up to 300 characters, is what the dashboard shows on a failed test and what the AI reads when a tool fails. Write it for a person.
Reject any request whose signature does not verify with 401. Silah never sends cookies, so do not let your framework fall back to a session.
Manifest#
GET {base}/manifest. This endpoint is required.
{
"silah_connect": "1",
"name": "Example Hosting",
"capabilities": ["resolve", "tools", "verify_link", "events"],
"verify_url": "https://example.com/account/silah/verify",
"tools": [
{
"name": "list_invoices",
"description": "The customer's invoices, newest first, with number, amount, currency, status and due date.",
"input_schema": { "type": "object", "properties": { "status": { "type": "string", "enum": ["unpaid", "paid", "all"] } } },
"requires": "verified"
},
{
"name": "service_status",
"description": "Whether our platform has an ongoing incident.",
"requires": "none"
}
],
"proof": "hex HMAC-SHA256(secret, \"silah-connect-proof\\n\" + the Silah-Nonce of this request)"
}| Field | Rules |
|---|---|
silah_connect | Must be "1" |
name | 1 to 80 characters. The AI calls your site by this name |
capabilities | Any of resolve, tools, verify_link, events. Silah only calls what you list. It ignores capabilities it does not know, so listing a future one is harmless |
verify_url | Required with verify_link. An HTTPS page on your site. Silah appends ?request=<id>, or &request= when the URL already has a query |
tools | Up to 20, only read with tools. name matches ^[a-z][a-z0-9_]{0,47}$, is unique, and is not handoff_to_human or request_verification. description (1 to 1000 characters) is what the AI reads to decide when to use it, so say what it returns. input_schema is a JSON Schema object of at most 8 KB; leave it out for no input. requires is none, channel or verified, and the default is verified |
proof | Proves the server answering holds the secret, so a URL somebody else typed cannot be claimed. Compute it with the secret you sign with, over the nonce of this very request |
Silah refuses a manifest with any problem and shows every problem at once. It fetches the manifest when you press Test connection, and keeps the last good one until the next successful test.
Resolve a customer#
POST {base}/customers/resolve, only called with the resolve capability, before the AI answers a customer it knows something about.
{
"customer": {
"level": "verified",
"subject": "user_42",
"account": "acme",
"name": "Amina Rahman",
"email": "amina@example.com",
"phone": "+8801711000111"
},
"conversation": { "id": "0190f3c2-…", "channel": "whatsapp" }
}Answer 200 with what the AI may know, or 404 when you have no such customer:
{
"customer": {
"subject": "user_42",
"name": "Amina Rahman",
"account": "acme",
"summary": "Customer since 2023. Business plan, 3 servers, 12 domains.",
"facts": [
{ "label": "Plan", "value": "Business" },
{ "label": "Open invoices", "value": "1 (INV-1042, BDT 4,500, due 30 October)" }
]
}
}customer.levelsays how far Silah trusts who this is (see Identity levels).subjectandaccountare sent only atverified, and only when your site, or an issuer your site trusts, is the one that verified them. One site never learns what another site says about somebody.At
claimed, the email or phone is only what the customer typed. Anyone can type anyone's email. Answer404, or with facts you would tell a stranger who knows that address. Never return private details atclaimed.At
channel, the phone number comes from WhatsApp, which confirmed the customer holds it. Answer the way you would answer a phone call from that number.Silah shows the AI at most the summary (1000 characters) and 30 facts. Silah caches the answer, including a
404, for 5 minutes per customer.
Tools#
POST {base}/tools/{name}, only called with the tools capability, when the AI decides to use a tool you declared.
{
"input": { "status": "unpaid" },
"customer": { "level": "verified", "subject": "user_42", "account": "acme", "email": "amina@example.com" },
"conversation": { "id": "0190f3c2-…", "channel": "web" }
}Answer 200 with {"result": <any JSON>}:
{ "result": { "invoices": [{ "number": "INV-1042", "amount": "4500.00", "currency": "BDT", "status": "unpaid", "due": "2026-10-30" }] } }Silah only offers a tool when the customer's level is at least its
requires. Still, checkcustomeryourself: it is the customer you are acting for, and the signature proves the request came from Silah. Scope every lookup tosubjectandaccount, never to an id frominputalone.inputis what the AI chose, shaped by your schema. Validate it like any user input.Return facts, not prose. The AI reads up to 16 KB of the result and words the reply itself. A tool should look things up. Avoid tools that spend money or delete things. If you add one anyway, make it ask for confirmation through its own input, for example
"confirm": true.The AI may use up to four tools in a row for one reply. A tool that fails returns the error shape, and the AI reads its message.
A workspace admin can switch any tool off in Silah without changing your manifest.
Events#
POST {base}/events, only sent with the events capability.
{
"id": "evt_4cQ9w2LmZ8aTnB1xR7kYp3Vd",
"type": "ticket.updated",
"created_at": "2026-10-23T09:14:07Z",
"data": {
"change": "message",
"ticket": { "id": "0190f3c2-…", "number": 1042, "status": "open", "account": "acme", "customer": { "subject": "user_42" } }
}
}data.change | When |
|---|---|
message | The team or the AI replied on the ticket. Internal notes never send an event |
status | The ticket's status changed (open, pending, resolved) |
An event is a hint to look, not the data. Fetch the ticket for the truth. Use events to email the customer, update a badge, or refresh an open page.
Answer any
2xxquickly. On a network error, a5xxor a429, Silah retries after 5 seconds, 30 seconds and 2 minutes. A4xxis final. The same event keeps itsidacross retries, so ignore anidyou have already handled.More event types may be added within v1. Answer
2xxto types you do not handle.
Identity levels#
Every customer Silah talks to has a level. Your tools say which level they need.
| Level | Meaning | How it is reached |
|---|---|---|
anonymous | Nothing is known | Default |
claimed | The customer typed an email or phone, or a chatbot asked for one. Unproven | Pre-chat form, chatbot, the widget's identify |
channel | The channel vouches for the phone number | A WhatsApp conversation |
verified | Your site, or an issuer it trusts, proved who they are | An identity token in the widget, a confirmed verification link, or a ticket your server opened |
requires: "none" on a tool means any level. A verification lasts 24 hours on the contact, after which the widget proves it again with a fresh token, or the AI asks again.
Verifying customers#
Identity tokens in the widget#
On pages where the customer is signed in, your server mints a short-lived JWT and the widget passes it to Silah. The visitor is then verified for your site, and the chat shows the team a Verified badge.
The token is a compact JWT signed HS256 with the site secret:
| Claim | Rules |
|---|---|
iss | Required. Your Site ID |
sub | Required. Your stable id for the customer, at most 255 characters. This becomes customer.subject in every resolve, tool call and ticket |
exp | Required. At most 10 minutes after the time Silah checks it. Silah allows one minute of clock skew |
iat, nbf | Optional, checked when present |
aud | Optional. When present, it must be the workspace's widget key (pk_…), which stops a token being replayed on another workspace's widget |
jti | Recommended. A token with a jti identifies one visitor only, so a copied token cannot verify a second one |
name, email, phone (or phone_number), account | Optional. Shown to the team and sent back to you. account groups customers who share tickets, for example the members of one company |
// Your server: an endpoint only a signed-in user can call.
import crypto from "node:crypto";
const b64 = (v) => Buffer.from(typeof v === "string" ? v : JSON.stringify(v)).toString("base64url");
app.get("/silah/identity-token", requireLogin, (req, res) => {
const now = Math.floor(Date.now() / 1000);
const head = b64({ alg: "HS256", typ: "JWT" });
const body = b64({
iss: process.env.SILAH_SITE_ID, sub: String(req.user.id), exp: now + 300, iat: now,
jti: crypto.randomUUID(), name: req.user.name, email: req.user.email, account: req.user.accountSlug,
});
const sig = crypto.createHmac("sha256", process.env.SILAH_SITE_SECRET).update(head + "." + body).digest("base64url");
res.type("text/plain").send(head + "." + body + "." + sig);
});<!-- Your page: give the widget a function, so it asks for a fresh token only when a chat starts. -->
<script>
window.SilahSettings = {
identityToken: () => fetch("/silah/identity-token", { credentials: "same-origin" }).then((r) => r.text()),
};
</script>
<script async src="https://silah.chat/widget.js" data-silah-key="pk_…"></script>When the customer signs in while the page is open, call
Silah('identify', { identityToken }).When they sign out, call
Silah('shutdown').Silah('on', 'identity', ({ verified, error }) => …)reports whether Silah accepted a token. A refused token never stops the chat, and its reason is logged to the console so you can fix it.
Trusted issuers (OpenID Connect, Mashriqain ID)#
A site whose users sign in with an OpenID Connect provider can skip minting tokens. List the provider under the site's Trusted sign-in providers in Silah:
the issuer, for example
https://id.mashriqain.com;the audience, which is the client id the issuer puts in
aud.
Silah finds the issuer's signing keys through {issuer}/.well-known/openid-configuration.
Pass the user's ID token as identityToken. Silah accepts RS256 tokens from a listed issuer, checks iss, aud, exp and the signature against the issuer's keys, and refetches the keys when it sees an unknown kid. The token's sub becomes customer.subject, so your site must know which of its users that sub belongs to. A site can list up to five issuers. Silah never accepts an HS256 token as if it came from an issuer.
Verification links (WhatsApp, Messenger, email)#
A customer writing on WhatsApp cannot pass a token. With the verify_link capability, when the AI needs a tool the customer is not verified for, Silah sends them a link to your verify_url with ?request=<verification id>.
Your page:
Makes the customer sign in, then comes back to the same URL.
Calls
GET /api/v1/connect/v1/verifications/{id}(signed) and shows what it returns. For example: "Confirm it's you chatting with us on WhatsApp from +8801•••••••111".On a button press, calls
POST /api/v1/connect/v1/verifications/{id}/confirmwith the signed-in user ascustomer. Never confirm on page load, because a link preview would do it for the customer.
The conversation is then verified, and the AI carries on where it stopped. A link works once and expires after 15 minutes. Only the newest link in a conversation works. A used, cancelled or expired link answers 409 verification_closed, and the customer can ask the AI for a new one.
The Connect API#
Your server calls Silah at:
https://silah.chat/api/v1/connect/v1Every request is signed (see Signing requests) over the full path, for example /api/v1/connect/v1/tickets. There are no cookies, no API keys and no CSRF header, because the signature is the credential. The workspace is the one the Site ID is connected to.
| Method | Path | Does |
|---|---|---|
GET | /ping | Checks the credentials: {silah_connect, site: {id, name, status}, workspace: {name, slug}} |
GET | /tickets | Lists a customer's tickets. Query: customer_subject (required), customer_account, status, cursor, limit |
POST | /tickets/query | The same list with the fields in the body: {customer, status?, cursor?, limit?} |
POST | /tickets | Opens a ticket: {customer, subject, body, priority?} → 201 |
GET | /tickets/{id}?customer_subject=… | Reads a ticket and its messages |
POST | /tickets/{id}/get | The same read with {customer} in the body |
POST | /tickets/{id}/messages | The customer replies: {customer, body} → 201 |
POST | /tickets/{id}/status | The customer closes or reopens it: {customer, status: "resolved" | "open"} |
GET | /verifications/{id} | Reads a verification link |
POST | /verifications/{id}/confirm | Confirms it for {customer} |
The POST forms of list and read exist for servers that would rather not put customer ids in URLs or access logs.
The customer#
Every ticket call says who the customer is:
{ "subject": "user_42", "account": "acme", "name": "Amina Rahman", "email": "amina@example.com", "phone": "+8801711000111" }subjectis required: your stable id for the person, at most 200 characters.Your signature is the proof. A customer named here is
verifiedfor your site, so only send the signed-in user. Never pass along an id that came from the browser.account(optional) is a group of customers who share tickets, for example a company or a workspace on your site. A customer sees their own tickets, and every ticket opened with the sameaccount. A reply from somebody other than the opener is shown to the team as[Name] message.name,emailandphone(E.164) update the contact in Silah. Silah changes nothing it does not receive.
Tickets#
A ticket lives in the Silah inbox like any conversation. It is numbered (#1042), with a subject and a priority (low, normal, high, urgent), under the site's own Website channel. Teammates answer it in Silah, and the workspace's automations and AI agent treat it like any new conversation.
{
"ticket": {
"id": "0190f3c2-7d1e-7a4b-9c55-2e8f1d0b6a31",
"number": 1042,
"subject": "Invoice question",
"status": "open",
"priority": "normal",
"account": "acme",
"customer": { "subject": "user_42", "name": "Amina Rahman", "email": "amina@example.com" },
"waiting_on": "team",
"last_message_at": "2026-10-23T09:14:07Z",
"created_at": "2026-10-23T09:12:40Z",
"updated_at": "2026-10-23T09:14:07Z"
},
"messages": [
{ "id": "…", "from": "customer", "author": null, "body": "Hello", "attachments": [], "created_at": "2026-10-23T09:12:40Z" },
{ "id": "…", "from": "team", "author": { "name": "Nadia" }, "body": "Looking at it now.", "attachments": [], "created_at": "2026-10-23T09:14:07Z" }
],
"has_more": false
}| Field | Meaning |
|---|---|
status | open, pending (the team is waiting on something) or resolved |
waiting_on | team when the customer wrote last, customer when the team did, or once the ticket is resolved |
messages[].from | customer, team or ai. Internal notes and system lines are never returned |
messages | The latest 100, oldest first. With has_more, pass ?before=<message id> for older ones |
The list returns
{items: Ticket[], next_cursor}, most recently active first, at most 100 per page (default 25).statusisopen,pending,resolvedorall(the default).A ticket that is not the customer's, or not on this site, is
404, the same as one that does not exist.The customer replying to a resolved ticket reopens it.
subjectis 1 to 200 characters, andbody1 to 4000.
Verifications#
{ "id": "…", "status": "pending", "channel": "whatsapp", "contact": "+8801•••••••111", "expires_at": "2026-10-23T09:29:07Z", "confirmed_at": null }status is pending, confirmed, cancelled or expired. contact is the masked phone number or email address the chat came from, so the customer can tell the request is theirs.
Errors and limits#
Silah's errors use the same shape as everywhere in the API: {"error": {"code", "message", "fields"?}}. Validation errors (400 validation_failed) name each bad field, for example fields["customer.subject"].
| Status | code | Means |
|---|---|---|
| 401 | unsigned | A signing header is missing |
| 401 | stale_timestamp | The timestamp is more than 5 minutes off. Check the server clock |
| 401 | bad_nonce | The nonce is not 16 to 64 characters from A-Z a-z 0-9 _ - |
| 401 | unknown_site | No workspace has this Site ID |
| 401 | bad_signature | The signature does not match any of the site's secrets |
| 401 | replayed | The nonce was already used |
| 403 | workspace_suspended | The Silah workspace is suspended |
| 403 | site_paused | A workspace admin paused the site in Silah |
| 404 | not_found | No such ticket or verification for this customer and site |
| 409 | verification_closed | The verification link was used, replaced or expired |
| 413 | too_large | The body is over 1 MB |
| 429 | rate_limited | Too many requests. Wait for Retry-After seconds |
| Limit | Value |
|---|---|
| Requests per site | 600 per minute |
| New tickets per customer | 20 per hour |
| Replies per customer | 120 per hour |
| Request body | 1 MB |
| Sites per workspace | 20 |
| Tools per site | 20 |
Security checklist#
Keep the secret on the server, in a secret store or environment variable. Never ship it to a browser or a mobile app.
Verify every request from Silah: Site ID, timestamp, nonce replay and signature, in constant time.
Scope every resolve, tool and ticket call to
customer.subjectandaccount, never to ids in the input alone.Return nothing private at
claimed.Mint identity tokens only for the signed-in user, with a short
expand ajti.Confirm verification links only on a button press by a signed-in user.
If a secret leaks, rotate it in Silah, update the server, and revoke the old one.
Versioning#
This is Silah Connect v1. It only grows:
Allowed within v1: new optional request or response fields, new capabilities, new event types, new endpoints, and new error codes. Ignore fields you do not know, and only declare the capabilities you implement.
Never within v1: removing or renaming a field, endpoint or header; changing a field's meaning or type; changing the canonical string; making an optional field required.
A breaking change would be v2. It would use a new path (/api/v1/connect/v2), a new manifest version ("silah_connect": "2") and a new signature version (v2= alongside v1=). v1 would keep working alongside it.
OpenAPI#
silah-connect-v1.yaml (OpenAPI 3.1) describes both directions:
the Connect API under
paths;your site's endpoints under
webhooks.
Generate a client or a server stub from it, or import it into Postman. The file in the repository is docs/openapi/silah-connect-v1.yaml, and Silah's test suite fails when a Connect route is missing from it.