# Decide

POST /v1/decide

Score one message against your rules and get back a verdict: allow, flag or block, with the winning rule as the reason. One synchronous call, under a second round trip.

- ROUND TRIP: < 1 s
- TIMEOUT: 2 s → 503
- RULES: up to 16
- IDEMPOTENCY: 24 h

## Quickstart

1. **Get your test key.** Every new workspace starts with a dk_test_ key, shown once at sign-up. Lost it? Create another in API keys. Test keys run the same model and never count toward your allowance.

2. **Send one message and your rules.** Copy the example request. Set SIGILLINE_API_KEY and SIGILLINE_API_URL in your environment.

3. **Act on the verdict.**

- allow: Deliver the message.
- flag: Deliver or hold it, and queue it for a person to review.
- block: Don't deliver it. Show reason.name to your moderators.

## Authentication

Send your key as a bearer token. Keys belong to one project. Call the API from your server, never from a browser or app.

- `dk_live_…` Production. Each successful call counts as one decision.
- `dk_test_…` Same model, same response. Never counts. usage.decisions is 0.

A missing or unknown key returns 401 unauthenticated. A revoked key returns 403 forbidden.

## Headers

- `Authorization` (required) — Bearer dk_live_… or dk_test_….
- `Content-Type` (required) — application/json
- `Idempotency-Key` (optional) — Up to 255 printable ASCII characters. Replays the stored response for 24 hours without billing again.
- `X-Request-Id` (optional) — Your id for the call, echoed back. Generated when absent. Quote it when you contact support.

## Request body

The API rejects anything over a limit with 400. It never truncates.

- `schema_version` (string, required) — The contract version you built against. Use "2026-10-18".
- `task` (string, required) — "moderation". Other task names are reserved and return 400 today.
- `message` (string, required) — The message to decide on. Up to 2,000 characters. Not stored unless history is on.
- `channel` ("public" | "private", required) — Where it was sent. Rules scoped to the other channel are skipped, not scored.
- `sender_role` ("viewer" | "performer" | "mod", required) — Who sent it.
- `rules` (object[], required) — 1 to 16 rules. Order matters: it is the final tie-break.
- `rules[].key` (string, required) — Your stable id: 1–64 letters, digits, _ or -. Unique within the request.
- `rules[].name` (string, required) — Up to 64 characters. Returned as reason.name.
- `rules[].description` (string, required) — Up to 240 characters. What the rule catches, in plain words. This is what the model reads.
- `rules[].action` ("allow" | "flag" | "block", required) — What a match means.
- `rules[].threshold` (number, optional) — 0 to 1. A score at or above it matches. Defaults to 0.5.
- `rules[].examples` (string[], optional) — Up to 3, each up to 120 characters. Short real messages the rule should catch.
- `rules[].channels` (string[], optional) — "public", "private" or both. Defaults to both.
- `history` (object[], optional) — Up to 5 earlier turns, oldest first. Each is { sender_role, text } with text up to 500 characters.
- `external_id` (string, optional) — Your id for the message. Echoed back in the response.
- `language` (string, optional) — A hint such as "en". The response reports the language used.
- `rulebook_id` (string, optional) — Reserved. Sending it returns 400 until saved rulebooks can be used on this call. Sending it together with rules also returns 400.
- `includeTtc` (boolean, optional) — When true, the response includes ttcMs. Defaults to false.

## Response

- `id` (string, required) — The decision id. Always present, even when history is off.
- `schema_version` (string, required) — The contract version. The current version is "2026-10-18".
- `task` (string, required) — "moderation".
- `verdict` ("allow" | "flag" | "block", required) — The strictest action among matched rules, or allow when none matched.
- `confidence` (number, required) — The winning rule’s score. With no match, 1 minus the highest score. Not a calibrated probability.
- `reason` (object | null, required) — The winning rule’s key, name and description, exactly as you sent them. Null when nothing matched.
- `rules` (object[], required) — Every scored rule, in request order: key, action, score, threshold, matched.
- `skipped_rules` (object[], required) — Rules that were not scored, each with a reason such as "channel".
- `language` (object, required) — code and supported. Unsupported languages cap confidence at 0.2; the verdict stands.
- `model` (object, required) — id and version of the model that scored the call. A threshold tuned on one version does not carry to another.
- `usage` (object, required) — decisions is 1 for a counted call and 0 for a replay or test key. quota_remaining is the organization’s remaining allowance (omitted for test keys).
- `external_id` (string, optional) — Echoed when you sent one.
- `ttcMs` (number, optional) — Milliseconds from receiving the request to the response. Present when the request set includeTtc.

## How the verdict is computed

The model returns a score per rule. The verdict is computed from those scores the same way every time.

1. Only scored rules count. Rules scoped to another channel are skipped.
2. A rule matches when its score is at or above its threshold.
3. Among matched rules the strictest action wins: block, then flag, then allow.
4. Within the same action, the higher score wins. If scores tie, the earlier rule in the request wins.
5. confidence is the winning rule’s score.
6. If nothing matched, the verdict is allow, reason is null and confidence is 1 minus the highest score.
7. If the language is unsupported, the verdict is unchanged and confidence is capped at 0.2.

An allow rule never cancels a matched flag or block. It only wins when nothing stricter matched.

## Errors

Every error has the same shape: error.type, code, message and request_id. Branch on code. The message names the field and the limit, and never quotes the chat text. Errors are never billed.

| Status | Code | When | Retry |
| --- | --- | --- | --- |
| 400 | invalid_request | Schema or limit failed. The message names the field. | No. Fix the request. |
| 401 | unauthenticated | Missing or unknown key. | No. |
| 403 | forbidden | Revoked or expired key. | No. |
| 409 | idempotency_conflict | Same Idempotency-Key, different body. | No. Use a new key. |
| 413 | input_too_large | Rules and message exceed the model’s input budget. | No. Shorten rules. |
| 429 | quota_exceeded | The allowance is used up. | No. Wait or upgrade. |
| 429 | rate_limited | Per-key rate limit. | Yes, after Retry-After. |
| 503 | model_unavailable | Model down or queue full. | Yes, with backoff. |
| 503 | internal_error | A failure other than the model. The body is not allow. | Yes, with backoff. |

**Fail closed.** A 503 is never an allow. If the model can't answer, you get an error, not a guess. Decide your fallback in code: hold the message, or deliver it and review later.

## Retries and idempotency

Send an Idempotency-Key, such as your message id, on every call. A retry with the same key and body returns the stored response for 24 hours, with usage.decisions: 0, so it isn't billed twice. The same key with a different body returns 409. The stored response contains no message text.

Retry rate_limited after Retry-After seconds, and model_unavailable or internal_error with exponential backoff. Don't retry other errors unchanged.

## Limits

- 16 — rules per call
- 2,000 — characters per message
- 5 — history turns
- 500 — characters per history turn
- 64 — characters per rule name
- 240 — characters per description
- 3 — examples per rule
- 120 — characters per example
- 24 h — idempotency window

If the rules and message together exceed the model's input budget, the call returns 413 input_too_large. Shorten descriptions and examples first.

## Versioning, usage and data

**Versioning.** schema_version pins the contract you built against. A breaking change ships under a new date. The current version is 2026-10-18.

**Usage.** One successful call is one decision, however many rules it scores. Errors, replays and test keys don't count. Past the allowance, calls return 429 quota_exceeded.

**Data.** Message text is not stored. Sigilline keeps the verdict, the winning rule key, language and latency. History is opt-in per project.

**Language.** English is supported in v1. Other languages still get a verdict, with confidence capped at 0.2.

## Samples

### cURL

```
curl "$SIGILLINE_API_URL/v1/decide" \
  -H "Authorization: Bearer $SIGILLINE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: msg_9182" \
  -d '{
    "schema_version": "2026-10-18",
    "task": "moderation",
    "channel": "private",
    "sender_role": "viewer",
    "message": "hey come meet me after, add me on snap",
    "rules": [
      {
        "key": "meetup",
        "name": "Meetup request",
        "description": "Asking to meet in person.",
        "action": "block"
      },
      {
        "key": "off_platform",
        "name": "Off-platform",
        "description": "Moving the conversation to another app or site.",
        "action": "flag"
      }
    ]
  }'
```

### TypeScript

```
const res = await fetch(`${process.env.SIGILLINE_API_URL}/v1/decide`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SIGILLINE_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": message.id,
  },
  body: JSON.stringify({
    schema_version: "2026-10-18",
    task: "moderation",
    channel: "private",
    sender_role: "viewer",
    message: message.text,
    rules, // your saved rules
  }),
});

if (!res.ok) throw new Error((await res.json()).error.code);
const decision = await res.json();
if (decision.verdict === "block") hold(message, decision.reason);
```

### Python

```
import os, requests

res = requests.post(
    f"{os.environ['SIGILLINE_API_URL']}/v1/decide",
    headers={
        "Authorization": f"Bearer {os.environ['SIGILLINE_API_KEY']}",
        "Idempotency-Key": message_id,
    },
    json={
        "schema_version": "2026-10-18",
        "task": "moderation",
        "channel": "private",
        "sender_role": "viewer",
        "message": text,
        "rules": rules,  # your saved rules
    },
    timeout=3,
)
res.raise_for_status()
decision = res.json()
if decision["verdict"] == "block":
    hold(message_id, decision["reason"])
```

### 200 OK · X-Request-Id: req_01HZX

```
{
  "id": "dec_01HZX",
  "schema_version": "2026-10-18",
  "task": "moderation",
  "verdict": "block",
  "confidence": 0.91,
  "reason": {
    "key": "meetup",
    "name": "Meetup request",
    "description": "Asking to meet in person."
  },
  "rules": [
    { "key": "meetup", "action": "block", "score": 0.91, "threshold": 0.5, "matched": true },
    { "key": "off_platform", "action": "flag", "score": 0.84, "threshold": 0.5, "matched": true }
  ],
  "skipped_rules": [],
  "language": { "code": "en", "supported": true },
  "model": { "id": "sigilline-moderation-2026-10", "version": "2026-10-25" },
  "usage": { "decisions": 1, "quota_remaining": 999 }
}
```

### 400 · fix the request; do not retry unchanged

```
{
  "error": {
    "type": "invalid_request",
    "code": "invalid_request",
    "message": "rules must contain at most 16 items",
    "request_id": "req_01HZX"
  }
}
```

### 429 · Retry-After: 1 · retry after the header’s seconds

```
{
  "error": {
    "type": "rate_limited",
    "code": "rate_limited",
    "message": "rate limit exceeded",
    "request_id": "req_01HZX"
  }
}
```

### 503 · never an allow · retry with backoff, same Idempotency-Key

```
{
  "error": {
    "type": "model_unavailable",
    "code": "model_unavailable",
    "message": "model unavailable",
    "request_id": "req_01HZX"
  }
}
```

## For agents and tools

Point your coding agent at these. They are the same contract the API validates against.

- [/llms.txt](https://sigilline.com/llms.txt): These docs as plain text for coding agents
- [/docs/decide.md](https://sigilline.com/docs/decide.md): This page as Markdown
- [decide-request.schema.json](https://sigilline.com/schemas/decide-request.schema.json): Request JSON Schema
- [decide-response.schema.json](https://sigilline.com/schemas/decide-response.schema.json): Response JSON Schema
- [error.schema.json](https://sigilline.com/schemas/error.schema.json): Error JSON Schema
