> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developer.jelliu.co/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developer.jelliu.co/_mcp/server.

# Security

This page describes the security controls that apply to every integration: transport, authentication, workspace isolation, credential storage, webhook signing and auditing. Each statement describes behavior of the API as it runs today. Where a control needs something from you, the page says so.

## Shared responsibility at a glance

| Jelliu does                                                          | You do                                                                           |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| Serves the API over HTTPS with HSTS.                                 | Call `https://api.jelliu.co` only, and never disable certificate verification.   |
| Stores API keys and widget keys only as hashes, and shows them once. | Keep keys in a secrets manager, never in web pages, mobile apps or repositories. |
| Scopes every request to the workspace of the credential.             | Use one key per integration with the least scope it needs.                       |
| Signs every webhook delivery with a per-webhook secret.              | Verify `X-Webhook-Signature-V2` and the timestamp before trusting a delivery.    |
| Refuses webhook URLs that point to private networks.                 | Serve your webhook endpoint over HTTPS.                                          |
| Records mutations in a tamper-evident audit log.                     | Review it, and subscribe to `audit.log_recorded` if you need it in your SIEM.    |

## Transport

* **HTTPS only.** API responses carry `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`, so browsers that have seen it refuse plain HTTP to the domain for a year.
* **Security headers.** Responses served by the API include a restrictive `Content-Security-Policy` with `frame-ancestors 'none'`, `X-Frame-Options: DENY` and `Referrer-Policy: strict-origin-when-cross-origin`. API responses cannot be framed by another site.
* **No fingerprinting headers.** `X-Powered-By` is disabled and the `Server` header is removed.

## Authentication

Every `/api/*` request is authenticated with a workspace API key (`jl_` followed by 64 hexadecimal characters) in the `Authorization: Bearer` header. The full details are in [Authentication](/authentication) and [API keys](/platform/api-keys); the security-relevant properties are:

| Property              | Behavior                                                                                                                                                                                                                                                                                                                                  |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Storage               | Only a SHA-256 hash of the key is stored. The full value is shown once, at creation.                                                                                                                                                                                                                                                      |
| Scopes                | `read`, `write` or `full`, enforced on every request by HTTP method, and again on routes restricted to administrator or owner roles.                                                                                                                                                                                                      |
| Revocation            | Immediate on the instance that handles it; allow about 10 seconds for every instance to refuse the key.                                                                                                                                                                                                                                   |
| Expiry                | Optional. An expired key is refused with `401`.                                                                                                                                                                                                                                                                                           |
| Credential management | Creating or revoking API keys, rotating a webhook secret, changing the workspace identity policy (MFA and SSO) and pinning data residency all require a signed-in person in the dashboard. An API key is refused with `403` even with the `full` scope, so a leaked key cannot mint new keys or disable the controls meant to contain it. |

A `full` key acts as a workspace administrator. Treat it like an admin password: keep it server-side, rotate it on staff changes, and prefer `read` or `write` keys wherever they are enough.

### People in the dashboard

Workspace owners can require **multi-factor authentication** and configure **single sign-on** for the people who sign in to the dashboard. These policies apply to human sessions only. Machine credentials (API keys, webhooks) have no second factor and are not affected, which is another reason to scope keys narrowly.

### MCP clients

The [MCP server](/mcp) accepts the same API keys, or OAuth 2.1 with PKCE (`S256`) for clients such as Claude.ai. OAuth access tokens expire after one hour and never carry more access than the person who approved the connection. Every tool call goes through the same authentication, scopes, rate limits and audit log as a direct API request.

## Workspace isolation

Jelliu is multi-tenant. Your data is separated from other customers' data by the following controls:

* **The workspace comes from the credential, never from the request.** An API key belongs to exactly one workspace. No parameter, header or body field lets a caller select a different workspace.
* **Queries are scoped, and the build enforces it.** Tables that hold customer data carry the workspace identifier, and application reads, updates and deletes on them filter by it. A blocking check in Jelliu's build pipeline analyzes the code and fails the build when such a query lacks that filter; the few intentional exceptions, such as matching an inbound provider callback to its workspace, must each carry a written justification.
* **Foreign IDs look like missing IDs.** Asking for a resource that belongs to another workspace returns `404`, exactly like an ID that does not exist, so IDs cannot be used to probe other workspaces.
* **Encryption bound to the workspace.** Sensitive fields such as third-party credentials are encrypted with AES-256-GCM, with the workspace identifier as additional authenticated data. A value copied into another workspace's row fails authentication and cannot be decrypted.
* **Rate limits per workspace.** Once authenticated, limits are counted per workspace, so another customer's traffic cannot exhaust your budget. See [Rate limits](/rate-limits).

## Browser access and CORS

The REST API under `/api/*` is meant to be called **from servers**. Its CORS policy only admits Jelliu's own dashboard origins, so a browser page on your domain cannot call `/api/*` directly, and it should not: doing so would expose a workspace key to every visitor.

A few surfaces are designed for browsers and accept any origin, without cookies or credentials:

| Surface                                 | Credential                                                  | Protection                                                                                              |
| --------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `/widget/*`, `/widget-embed/*`          | Public widget ID, or the secret widget API key from servers | Allowed origins, per-IP, per-widget, per-workspace and daily limits. See [Web chat](/channels/webchat). |
| `/mcp` (`https://mcp.jelliu.co`)        | API key or OAuth token                                      | Per-IP limit plus the workspace's API limits.                                                           |
| `/openapi.yaml`, `/openapi.public.yaml` | None                                                        | Read-only documentation.                                                                                |

### Widget credentials

* The **widget ID** is public by design and is the only credential the embed uses.
* The **widget API key** is shown once, stored only as a keyed HMAC-SHA256 digest, and accepted only in the `x-widget-api-key` header. It is never accepted in a query string, where it would end up in logs and `Referer` headers.
* Each widget has an explicit list of **allowed origins**; wildcards are rejected. The origin check stops other websites from embedding your chat. Because a non-browser client can claim any origin, it is paired with rate limits and a daily message cap that fail closed.

## Request hardening

These limits apply to requests you send and can explain unexpected `400` responses:

| Control                    | Behavior                                                                                                                                                                                                                                                                                            |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Body size                  | JSON and form bodies are limited to 1 MB. File uploads have their own per-endpoint limits.                                                                                                                                                                                                          |
| JSON depth                 | Bodies nested deeper than 32 levels are rejected before parsing.                                                                                                                                                                                                                                    |
| Duplicate query parameters | `?status=a&status=b` is read as `status=a`: the first value wins.                                                                                                                                                                                                                                   |
| HTML in text fields        | On `POST`, `PUT`, `PATCH` and `DELETE` requests with JSON or form bodies (webhook configuration excepted), HTML tags, script content and `javascript:` URLs are stripped from string values before they reach the application, and an unclosed `<` removes the rest of the string. Send plain text. |
| Input validation           | Bodies, queries and path parameters are validated against strict schemas; failures return `400 VALIDATION_FAILED`. See [Errors](/errors).                                                                                                                                                           |
| Error bodies               | `5xx` responses never include internal details. The cause is logged on Jelliu's side only.                                                                                                                                                                                                          |

## Webhooks

### Signed deliveries

Every outbound webhook delivery is signed with a secret unique to that webhook (`whsec_...`). `X-Webhook-Signature-V2` is an HMAC-SHA256 over the timestamp and the raw body, so a captured delivery cannot be replayed outside your tolerance window. The secret is shown once, and rotating it requires a dashboard session. [Verify every delivery](/webhooks#verifying-signatures) before acting on it.

Reject deliveries whose signature does not match or whose `X-Webhook-Timestamp` is outside your tolerance window, and compare signatures in constant time. The [CRM sync recipe](/recipes/crm-sync-with-webhooks) has a complete receiver.

### Protection against server-side request forgery

Jelliu will not send webhooks into private networks, including its own:

* When you create or update a webhook, its URL is resolved and refused if it points to a loopback, private, link-local, carrier-grade NAT or unique-local address, a cloud metadata endpoint, a `localhost` name, a sensitive port (such as SSH, SMTP, DNS or common database ports) or a scheme other than HTTP and HTTPS.
* At delivery time the connection is pinned to the address that passed validation, so a DNS record changed after the check cannot redirect the request to an internal host.
* Custom headers you configure cannot replace `X-Webhook-*`, `Host` or `Content-Length`.

To receive webhooks during local development, expose your machine through a public HTTPS tunnel; `localhost` URLs are refused.

### Inbound provider callbacks

Callbacks that Jelliu receives from its own providers (telephony, voice and payments) are verified by their providers' signatures before they are processed. They are not part of the public API.

## Audit log

Every mutating `/api` request (`POST`, `PUT`, `PATCH`, `DELETE`) made by a person, an API key or the MCP server is recorded in the workspace audit log, together with the few reads that hand out credentials.

* **Redaction.** Secrets (passwords, tokens, API keys, signing secrets, authorization headers) and contact-identifying fields are redacted before an entry is written.
* **Tamper evidence.** Entries form a hash chain keyed with a server-side secret. `GET /api/audit/verify-chain`, optionally with `from` and `to` as ISO 8601 timestamps, recomputes the chain for your workspace and reports any break.
* **Access.** Reading the audit log and verifying the chain are restricted to the workspace owner, which for API keys means the `full` scope.
* **Streaming.** Subscribe a webhook to `audit.log_recorded` to forward entries to your SIEM. It is not included in `"*"`; name it explicitly.

See [Compliance](/platform/compliance) for the audit log API, retention settings and data subject requests.

## Data residency

`GET /api/settings/data-residency` returns your workspace's effective data residency and the chain of processors it depends on. Pinning a region is a one-time, owner-only action in the dashboard. Details are in [Compliance](/platform/compliance).

## Best practices for your integration

#### Store and scope credentials

* One API key per integration, named after it, with the smallest scope that works.
* Keep keys in a secrets manager or environment variables injected at runtime. Never commit them, log them or send them to a browser.
* Set an expiry on keys used by temporary projects or contractors.
* The workspace can hold 25 active keys; revoke keys you no longer use.

#### Rotate without downtime

Create the new key in the dashboard, deploy it alongside the old one, confirm traffic succeeds with the new key, then revoke the old key. Allow about 10 seconds after revocation before assuming every instance refuses it. See [API keys](/platform/api-keys).

#### Harden your webhook receiver

* Verify `X-Webhook-Signature-V2` against the raw body and check the timestamp.
* Acknowledge with `2xx` quickly and process asynchronously.
* Deduplicate retries: a retry carries the same timestamp and body, and therefore the same signature.
* Ignore fields you do not recognize instead of failing.

#### Keep browsers away from workspace keys

Browser and mobile code should use the public widget ID and the `/widget/*` endpoints, or call your own backend, which holds the key. Never embed a `jl_` key or a widget API key in client-side code.

#### Treat AI tools that act as operations

Assistants connected over MCP can place calls and send messages. Use a `read` key for reporting assistants and configure clients to confirm before running tools that act. See [AI assistant via MCP](/recipes/ai-assistant-via-mcp).

#### Respond to a leaked key

Revoke the key in the dashboard immediately, create a replacement, and review the audit log for actions taken with the leaked key since it was exposed. If a webhook secret may have leaked, rotate it from the dashboard and deploy the new value right away: there is no overlap period.

## Reporting a vulnerability

If you believe you have found a security vulnerability in Jelliu, email **[support@jelliu.com](mailto:support@jelliu.com)** with "Security" in the subject line. Include the affected endpoint or component, steps to reproduce and the impact you observed.

Please do not access, modify or delete data that does not belong to you, do not degrade the service for other customers, and give us reasonable time to fix the issue before disclosing it publicly.

## Related

#### [API keys](/platform/api-keys)

Lifecycle, scopes, rotation and the 25-key cap.

#### [Webhooks](/webhooks)

Signature verification, retries and filters.

#### [Compliance](/platform/compliance)

Audit log API, retention, opt-outs and DPA.

#### [Web chat](/channels/webchat)

Widget credentials and allowed origins.