Skip to content
Docs

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:

DirectionWho callsWhat for
Silah → your siteSilah calls https://your-site/…/manifest, /customers/resolve, /tools/{name}, /eventsThe AI learns who the customer is and uses your tools; you hear about ticket replies
Your site → SilahYour 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#

  1. 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.

  2. Serve the manifest. Your server answers a signed GET {base}/manifest with what it supports, plus a proof that it holds the secret (see Manifest).

  3. Press Test connection. Silah fetches the manifest, checks the proof, and shows the capabilities and tools it found.

  4. Add what you need:

    • resolve and tools so the AI can help signed-in customers.

    • An identity token in the widget so web visitors are verified.

    • verify_link so WhatsApp and Messenger customers can prove who they are.

    • Calls to the ticket API for a support page on your site.

  5. Re-test after every manifest change. Silah keeps the last good manifest, so a broken deploy cannot take your tools away mid-conversation.

Credentials#

FormatWho makes it
Site IDsite_ followed by 16 to 64 letters and digits, for example site_8fK2mQ9xLp4RtV7nYc1Wb3ZsSilah, or your site
SecretSilah 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:

  1. In Silah, choose Rotate next to the secret. Silah generates a second secret and shows it once.

  2. Configure the new secret on your server. While both exist, accept signatures from either. Silah signs its own requests with the newest secret.

  3. Revoke the old secret in Silah. A site always keeps at least one.

Signing requests#

Every request in both directions carries four headers:

HeaderValue
Silah-Site-IdThe Site ID
Silah-TimestampThe current Unix time in seconds, for example 1767225600
Silah-Nonce16 to 64 characters from A-Z a-z 0-9 _ -, new for every request
Silah-Signaturev1= 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):

text
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:

  1. The Site ID is yours.

  2. The timestamp is within 5 minutes of your clock.

  3. 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.

  4. 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 outputValue
Secretscs_TestSecretDoNotUse0123456789abcdefghij
Timestamp1767225600
Noncen0nce-For-Docs-0001
RequestPOST /api/v1/connect/v1/tickets
Body{"customer":{"subject":"user_42"},"subject":"Invoice question","body":"Hello"}
Body SHA-256bd06a2c758b3155dc1a87abefb94ee5c8793cc826a1d334608e475baca5bfbae
Silah-Signaturev1=2586dc27ac6b0f0b72bd9ecf2da055fedb642543258cd157acfc6ae0f1b040b1
Manifest proof for this nonce5ea0700c2c3b65dafcb99b1b43398cdeea5e3bf91b5aa5f16862027d109791d1

Node.js#

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#

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#

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:

json
{ "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.

json
{
  "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)"
}
FieldRules
silah_connectMust be "1"
name1 to 80 characters. The AI calls your site by this name
capabilitiesAny 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_urlRequired with verify_link. An HTTPS page on your site. Silah appends ?request=<id>, or &request= when the URL already has a query
toolsUp 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
proofProves 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.

json
{
  "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:

json
{
  "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.level says how far Silah trusts who this is (see Identity levels). subject and account are sent only at verified, 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. Answer 404, or with facts you would tell a stranger who knows that address. Never return private details at claimed.

  • 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.

json
{
  "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>}:

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, check customer yourself: it is the customer you are acting for, and the signature proves the request came from Silah. Scope every lookup to subject and account, never to an id from input alone.

  • input is 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.

json
{
  "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.changeWhen
messageThe team or the AI replied on the ticket. Internal notes never send an event
statusThe 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 2xx quickly. On a network error, a 5xx or a 429, Silah retries after 5 seconds, 30 seconds and 2 minutes. A 4xx is final. The same event keeps its id across retries, so ignore an id you have already handled.

  • More event types may be added within v1. Answer 2xx to types you do not handle.

Identity levels#

Every customer Silah talks to has a level. Your tools say which level they need.

LevelMeaningHow it is reached
anonymousNothing is knownDefault
claimedThe customer typed an email or phone, or a chatbot asked for one. UnprovenPre-chat form, chatbot, the widget's identify
channelThe channel vouches for the phone numberA WhatsApp conversation
verifiedYour site, or an issuer it trusts, proved who they areAn 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:

ClaimRules
issRequired. Your Site ID
subRequired. Your stable id for the customer, at most 255 characters. This becomes customer.subject in every resolve, tool call and ticket
expRequired. At most 10 minutes after the time Silah checks it. Silah allows one minute of clock skew
iat, nbfOptional, checked when present
audOptional. When present, it must be the workspace's widget key (pk_…), which stops a token being replayed on another workspace's widget
jtiRecommended. A token with a jti identifies one visitor only, so a copied token cannot verify a second one
name, email, phone (or phone_number), accountOptional. Shown to the team and sent back to you. account groups customers who share tickets, for example the members of one company
js
// 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);
});
html
<!-- 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.

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:

  1. Makes the customer sign in, then comes back to the same URL.

  2. 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".

  3. On a button press, calls POST /api/v1/connect/v1/verifications/{id}/confirm with the signed-in user as customer. 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:

text
https://silah.chat/api/v1/connect/v1

Every 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.

MethodPathDoes
GET/pingChecks the credentials: {silah_connect, site: {id, name, status}, workspace: {name, slug}}
GET/ticketsLists a customer's tickets. Query: customer_subject (required), customer_account, status, cursor, limit
POST/tickets/queryThe same list with the fields in the body: {customer, status?, cursor?, limit?}
POST/ticketsOpens a ticket: {customer, subject, body, priority?} → 201
GET/tickets/{id}?customer_subject=…Reads a ticket and its messages
POST/tickets/{id}/getThe same read with {customer} in the body
POST/tickets/{id}/messagesThe customer replies: {customer, body} → 201
POST/tickets/{id}/statusThe customer closes or reopens it: {customer, status: "resolved" | "open"}
GET/verifications/{id}Reads a verification link
POST/verifications/{id}/confirmConfirms 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:

json
{ "subject": "user_42", "account": "acme", "name": "Amina Rahman", "email": "amina@example.com", "phone": "+8801711000111" }
  • subject is required: your stable id for the person, at most 200 characters.

  • Your signature is the proof. A customer named here is verified for 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 same account. A reply from somebody other than the opener is shown to the team as [Name] message.

  • name, email and phone (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.

json
{
  "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
}
FieldMeaning
statusopen, pending (the team is waiting on something) or resolved
waiting_onteam when the customer wrote last, customer when the team did, or once the ticket is resolved
messages[].fromcustomer, team or ai. Internal notes and system lines are never returned
messagesThe 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). status is open, pending, resolved or all (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.

  • subject is 1 to 200 characters, and body 1 to 4000.

Verifications#

json
{ "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"].

StatuscodeMeans
401unsignedA signing header is missing
401stale_timestampThe timestamp is more than 5 minutes off. Check the server clock
401bad_nonceThe nonce is not 16 to 64 characters from A-Z a-z 0-9 _ -
401unknown_siteNo workspace has this Site ID
401bad_signatureThe signature does not match any of the site's secrets
401replayedThe nonce was already used
403workspace_suspendedThe Silah workspace is suspended
403site_pausedA workspace admin paused the site in Silah
404not_foundNo such ticket or verification for this customer and site
409verification_closedThe verification link was used, replaced or expired
413too_largeThe body is over 1 MB
429rate_limitedToo many requests. Wait for Retry-After seconds
LimitValue
Requests per site600 per minute
New tickets per customer20 per hour
Replies per customer120 per hour
Request body1 MB
Sites per workspace20
Tools per site20

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.subject and account, never to ids in the input alone.

  • Return nothing private at claimed.

  • Mint identity tokens only for the signed-in user, with a short exp and a jti.

  • 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.