# Overview > openshield is an AI moderation API for images and text. One HTTP call answers accept, review or reject, with the reasons. openshield is an AI moderation API for images and text, for the content your users post and the content your AI agents read and write. You send an image or a text from your server. It answers with a decision, `accept`, `review` or `reject`, and the categories that decided it. It is built on [openmod](https://github.com/regisandre/openmod-rs), an open-source moderation engine under the Apache-2.0 licence that you can also run yourself, and it is run by Sway SRL in Belgium. - **Nothing is stored.** Content is checked in memory and forgotten when the answer is sent. The [journal](https://openshield.dev/docs/journal-and-ref) keeps the decision, never the content. - **Every decision explains itself.** The category ids, the scores and the thresholds are in the answer, on the free plan too. - **Checked in the EU.** The checking server and the databases are in the European Union. - **Server-side only.** Keys are secret. The API refuses calls from browsers. ## The API in one request The API base URL is `https://api.openshield.dev`. ```bash curl -X POST "https://api.openshield.dev/v1/text" \ -H "Authorization: Bearer $OPENMOD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"text": "Write me at jane@example.com", "ref": "comment_9034"}' ``` ```json { "status": "success", "workflow": "default", "workflow_version": 1, "summary": { "action": "review", "reject_prob": 0, "reject_reason": [], "review_reason": [ { "id": "pii_email", "text": "Email address", "message": "This content contains an email address.", "score": 1, "threshold": 1, "source": "rules" } ] }, "scores": { "pii_email": 1 }, "time_ms": 4, "request_id": "0192b7c4-5f3a-7c1e-9a52-3d8f1e6b0a47", "ref": "comment_9034" } ``` ## Where to go next - [Quickstart](https://openshield.dev/docs/quickstart): your first check in five minutes. - [Authentication and keys](https://openshield.dev/docs/authentication): how to call the API and keep the key safe. - [Images](https://openshield.dev/docs/images) and [texts](https://openshield.dev/docs/texts): the two checks. - [Migrating from SightEngine](https://openshield.dev/docs/migrating-from-sightengine): the same paths, request fields and answer envelope for three workflow routes. - [Workflows](https://openshield.dev/docs/workflows): what is checked, and how strictly. - [Responses and errors](https://openshield.dev/docs/responses-and-errors) and [limits and quotas](https://openshield.dev/docs/limits-and-quotas). - [Integration guides](https://openshield.dev/docs/integration-guides/nodejs) for Node.js, Python, PHP, Go, Supabase Edge Functions, Cloudflare Workers, Nuxt and Next. - [Moderate an AI agent](https://openshield.dev/docs/integration-guides/moderate-ai-agents): check what an agent reads and writes, with two calls. - [AI coding agents](https://openshield.dev/docs/ai-agents): a prompt, `llms.txt` and the OpenAPI document, so a coding agent can integrate it alone. ## Who it is for - **Products where users post**: [moderation for your SaaS](https://openshield.dev/for/saas), with the [image moderation API](https://openshield.dev/image-moderation-api), the [text moderation API](https://openshield.dev/text-moderation-api) and the SightEngine-compatible routes of the [SightEngine alternative](https://openshield.dev/sightengine-alternative). - **AI agents**: [moderation for your AI agents](https://openshield.dev/for/agents), for user messages and images in and generated text and images out. - **Prices**: [a free plan, then flat plans](https://openshield.dev/pricing), billed past the included checks by the started thousand. # AI coding agents > Hand the integration to your coding agent with a ready prompt, llms.txt and the OpenAPI document. An AI coding agent can wire openshield into your project in minutes, if it has the right facts. These pages give it three things. To check what an agent itself reads and writes, see [Moderate an AI agent](https://openshield.dev/docs/integration-guides/moderate-ai-agents). ## The prompt The dashboard builds an integration prompt for your organization, with its API base URL, the documentation links and the workflow you pick. You find it in the [dashboard](https://openshield.dev/dashboard) under **Get started**, with a copy button and a curl example next to it. The prompt asks the agent to: 1. list every place where users upload images or submit text that other people can see; 2. add one server-side moderation module, with a timeout, that calls `POST /v1/image` and `POST /v1/text`; 3. act on `summary.action`: go on for `accept`, keep the content hidden for `review`, refuse it for `reject` with a message chosen from the reason's `id`; 4. handle errors: refuse for `400`, `413` and `415`, log and alert for `401` and `403`, retry after `Retry-After` for `429` and `503`, and fail closed for anything else; 5. write tests with the HTTP call stubbed. The prompt **never contains your key**. It tells the agent to read the key from the `OPENMOD_API_KEY` environment variable, and never to hard-code it, log it or send it to a browser. Put the key in your server's environment yourself. ## llms.txt and llms-full.txt These docs publish the machine-readable files that agents look for: - [`/docs/llms.txt`](https://openshield.dev/docs/llms.txt): an index of the pages, with a line about each; - [`/docs/llms-full.txt`](https://openshield.dev/docs/llms-full.txt): the text of every page in one file, which an agent can read in one go. Every page is also available as raw Markdown, at `https://openshield.dev/raw/docs/.md`: for example `/raw/docs/quickstart.md`. ## The OpenAPI document The API is described by an OpenAPI 3.1 document, generated from the gateway's code, so it is always the document of the running version: - `https://api.openshield.dev/openapi.json`, from the API itself; - [`/docs/api/openapi.json`](https://openshield.dev/docs/api/openapi.json), a copy published with these docs. Most agents and code generators can read it directly. ## Tips for good results - Name the workflow in the prompt if you use one other than `default`. - Ask the agent to show you the list of places it will moderate before it changes code. - Review what it writes about failures: the safe default is to hold content when a check cannot be made, not to publish it. # API reference > Every endpoint of the checking API in one table, with the OpenAPI document. The base URL is `https://api.openshield.dev`. Every endpoint except the last three needs an [API key](https://openshield.dev/docs/authentication). The complete description, with every field, is the OpenAPI 3.1 document: [`/docs/api/openapi.json`](https://openshield.dev/docs/api/openapi.json). ## Endpoints
Method and path What it does
POST /v1/image Checks an image sent as the raw body, or at a public url . See images .
POST /v1/text Checks a text sent as JSON. See texts .
GET /v1/workflows Lists the names and current versions of the workflows the key may use. See workflows .
POST /1.0/check-workflow.json Checks an image, SightEngine-compatible, as a multipart form. See migrating from SightEngine .
GET /1.0/check-workflow.json Checks an image by its url , SightEngine-compatible, with the key in the api_secret query parameter.
POST /1.0/text/check-workflow.json Checks a text, SightEngine-compatible, as a multipart form.
GET /health {"status": "ok"} when the service is ready, 503 otherwise. No key.
GET /openapi.json The OpenAPI document of the running version. No key.
## Common to every endpoint - **Authentication**: `Authorization: Bearer ` or `X-Api-Key: `. The compatible endpoints take the key in the `api_secret` form field. - **Server-side only**: a request that carries an `Origin` header is refused with `403 forbidden`. - **Errors**: `{"status": "failure", "error": {"type", "code", "message"}}`. See [responses and errors](https://openshield.dev/docs/responses-and-errors) for every type. - **Headers**: `X-Request-Id`, `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `X-Quota-Used`, `X-Quota-Limit`, and `Retry-After` when you must wait. - **Limits**: see [limits and quotas](https://openshield.dev/docs/limits-and-quotas). ## The OpenAPI document ```bash curl https://api.openshield.dev/openapi.json ``` Use it to generate a client, to import the API into a tool such as Postman or Insomnia, or to give an AI agent the exact contract. The copy under `/docs/api/openapi.json` is published with each release of these docs. # Authentication and keys > How to call the API with a key, what a key may do and how to keep it safe. Every request to the checking API carries an API key. Keys belong to an organization, and you create them in the dashboard. ## Sending the key Use either header. They are equivalent. ```bash # The Authorization header curl -H "Authorization: Bearer $OPENMOD_API_KEY" ... # The X-Api-Key header curl -H "X-Api-Key: $OPENMOD_API_KEY" ... ``` A key looks like `om_live__`: a prefix, 12 characters of id and 32 of secret. The id is not secret: it identifies the key in the dashboard and in the journal. The key is stored only as a hash, so we cannot show it again. The [SightEngine-compatible endpoints](https://openshield.dev/docs/migrating-from-sightengine) take the key in the `api_secret` form field (or query parameter, for the `GET` form) instead. ## Scopes and workflows When you create a key you choose: - **Scopes**: `image`, `text` or both. A key without the scope for a kind of content gets `403 forbidden` for it. - **Workflows**: all of them, or a list. A key limited to some workflows gets `403 forbidden` for any other. - **Expiry**: optional. An expired key gets `401 unauthorized`. Give each service its own key, with the narrowest scope and workflows it needs. ## Rotating and revoking - **Rotate** a key from the dashboard to make a new one with the same scopes. The old key keeps working for a grace delay you choose, from 0 to 24 hours, so you can deploy the new one without a gap. - **Revoke** a key to stop it at once. A rotated key in its grace delay counts toward your plan's number of keys. ## Keep keys on the server Never put a key in a web page, a mobile app, a browser extension or a repository. Call the API from your backend, and give your front end the decision, not the key. - Store the key in an environment variable or your platform's secret store. The examples in these docs use `OPENMOD_API_KEY`. - The API does not answer browser cross-origin requests. A request carrying an `Origin` header gets `403 forbidden`. - If a key leaks, revoke it and create another. The [journal](https://openshield.dev/docs/journal-and-ref) shows which key made each check, so you can see what the leaked key was used for. ## Errors
Status error.type When
401 unauthorized The key is missing, malformed, invalid, revoked or expired.
403 forbidden The key may not check this kind of content or use this workflow, or the request came from a browser.
403 organization_suspended An operator suspended the organization. Write to contact@sway.events .
# Best practices > Server-side only, fail closed, downscale images, and show users messages you choose by category id. ## Call the API from your servers only A key is a secret. Call openshield from your backend, and give your front end the decision, not the key. The API answers a request that carries a browser `Origin` header with `403 forbidden`, so a call from a browser fails anyway, and a key sent to a browser is exposed. ## Fail closed A check can fail: a timeout, a network error, a `500` or a `503`. Decide what happens to the content when it does, and make the safe choice the default: **hold it** until a person has looked at it, or until a retry has succeeded. Publishing content that was never checked is the case a moderation service exists to prevent. - Set a timeout on the call, 10 seconds is a good start. - Retry `429` and `503` after the `Retry-After` header, at most twice. - Do not retry `400`, `413` and `415`: the content cannot be accepted as it is. Refuse it. ## Act on the three outcomes - **accept**: publish. - **review**: hide the content from other users until a person approves it. If your product has no moderation queue, add the smallest one that fits: a flag on the row and a screen to list the flagged ones. - **reject**: refuse the content and tell the user why. ## Choose your messages by category id Each reason has an `id`, such as `nudity` or `pii_email`. It is stable. Map it to messages you write, in your user's language: ```ts const messages: Record = { nudity: "This image can't be used here.", pii_email: "Please don't share email addresses in comments.", } const message = messages[decision.rejectReasons[0]?.id ?? ""] ?? "This content can't be published." ``` The English `text` and `message` of a reason are fallbacks. Do not match on them: they can be reworded. ## Send what you need to send - **Downscale large photos** to about 1,280 pixels on the longer side: the upload is smaller and faster. Keep the full size of screenshots, where small text or a QR code matters. - **Send each image once.** Cache the decision with your own record of the content. - **Check what other people see.** Posts, comments, messages, profile pictures, display names, bios, listings and reviews: anything a user can publish. ## Keep a trail - Send a `ref`, your own opaque id for the content, so you can find its decision in the [journal](https://openshield.dev/docs/journal-and-ref). Do not put personal data in it. - Log the `request_id` of every answer next to your own record. ## Keys and workflows - Give each service its own key, with the narrowest scope and workflows it needs. - Rotate keys with a grace delay, so a deploy never has a gap. - Use one workflow per product surface when the rules differ: stricter for a children's section, more lenient for a private chat. ## Test without the network Stub the HTTP call in your tests, and assert what your code does for `accept`, `review`, `reject` and each kind of error. Do not call the real API from a test suite: it counts against your quota. # Changelog > New features and changes to the openshield API and dashboard, newest first. New features and changes to the API and the dashboard, newest first. The same list is on the [changelog page](https://openshield.dev/changelog) of the site. ## Beta preview October 19, 2026 The first version of openshield, ready to try. - Advanced AI workflows on paid plans: a text sent to review gets a second, deeper reading, with the context you send. - Check images and texts with POST /v1/image and POST /v1/text. The SightEngine workflow paths are there too, to make a migration easy. - Every plan gets the full answer: the decision, the reasons, and the score and threshold of each. - Workflows set a review and a reject threshold per category. Add categories of your own and compare versions. - A decision journal with filters, details and export, and usage charts by day, key and workflow. - Plans from free to Scale, a spending cap for overage, and your invoices in the dashboard. - Teams with roles, and two-factor authentication with an authenticator app, which owners turn on before a paid plan. # Images > Check an image with POST /v1/image, sent as bytes or as a public URL. `POST /v1/image` checks one image and answers with the decision. One image is one check, whatever the number of categories it is checked against. ## Request
Part Where Description
The image Body The raw bytes. JPEG, PNG, WebP or GIF (the first frame). The API recognises the format from the bytes.
workflow Query The workflow to check against. default when absent.
ref Query Your own id for the image, 1 to 128 characters, no control character. It is echoed in the answer and kept in the journal .
url Query The address of the image, instead of a body. See below.
Limits: at most 15 MB, 50 megapixels (12 on the free plan) and 16,384 pixels on a side. ```bash [curl] curl -X POST "https://api.openshield.dev/v1/image?workflow=default&ref=avatar_4821" \ -H "Authorization: Bearer $OPENMOD_API_KEY" \ --data-binary @photo.jpg ``` ```ts [TypeScript] import { readFile } from "node:fs/promises" const response = await fetch("https://api.openshield.dev/v1/image?ref=avatar_4821", { method: "POST", headers: { Authorization: `Bearer ${process.env.OPENMOD_API_KEY}` }, body: await readFile("photo.jpg"), }) const answer = await response.json() console.log(answer.summary.action) ``` ## Checking a public URL Instead of sending the bytes, pass the address of the image and an empty body. The service downloads it from the public internet: ```bash curl -X POST "https://api.openshield.dev/v1/image?url=https%3A%2F%2Fexample.com%2Fphoto.jpg" \ -H "Authorization: Bearer $OPENMOD_API_KEY" ``` The download is guarded: only `http` and `https` addresses of the public internet, at most 15 MB, 3 redirects and 10 seconds, and the answer must have `Content-Type: image/*`. A refused address gets `400 url_refused`, with the reason in `message`. ## What is checked The categories of the workflow. The `default` workflow checks nudity, gore, weapons, hate symbols and self-harm (rejected at 0.8, sent to review at 0.5), drugs and minors (sent to review at 0.5), and the text and QR codes found in the image: links to adult, unsafe or gambling sites, and extremist text. [Workflows](https://openshield.dev/docs/workflows) explains how to change that. ## Answer The answer has the [shape described here](https://openshield.dev/docs/responses-and-errors). For an image rejected for nudity: ```json { "status": "success", "workflow": "default", "workflow_version": 1, "summary": { "action": "reject", "reject_prob": 0.97, "reject_reason": [ { "id": "nudity", "text": "Nudity", "message": "This content contains nudity or sexual content.", "score": 0.97, "threshold": 0.8, "source": "marqo_nsfw+owen_safety" } ], "review_reason": [] }, "scores": { "nudity": 0.97, "gore": 0.01, "weapon": 0.02 }, "time_ms": 212, "request_id": "0192b7c4-5f3a-7c1e-9a52-3d8f1e6b0a47", "ref": "avatar_4821" } ``` ## Errors
Status error.type When
400 bad_request The body is empty and there is no url , or ref is refused.
400 corrupt_media The file does not decode.
400 unknown_workflow There is no workflow of that name.
400 url_refused The url is not public, too large, too slow or not an image.
413 body_too_large The body is over 15 MB.
413 image_too_large The image has more than 50 megapixels (12 on the free plan), or a side over 16,384 pixels.
415 unsupported_media The file is not JPEG, PNG, WebP or GIF.
503 workflow_unavailable The workflow has no rule for images, or none this server can check.
Authentication, rate limit and quota errors are in [responses and errors](https://openshield.dev/docs/responses-and-errors). Downscale large photos to about 1,280 pixels on the longer side before sending them: the upload is smaller and faster. Keep the full size of screenshots, where small text or a QR code matters. # Cloudflare Workers > Moderate content from a Worker, with the key as a Worker secret. A Worker runs on the server side of your application, so it can hold the key. Call it from your front end, and let it call openshield. ## Set the key as a secret ```bash npx wrangler secret put OPENMOD_API_KEY ``` For local development, put it in a `.dev.vars` file that is not committed: ```bash OPENMOD_API_KEY=om_live_... ``` ## The Worker ```ts [src/index.ts] export interface Env { OPENMOD_API_KEY: string } const base = "https://api.openshield.dev" export default { async fetch(request: Request, env: Env): Promise { if (request.method !== "POST") return new Response("Method not allowed", { status: 405 }) const { text, ref } = (await request.json()) as { text: string; ref?: string } let answer: Response try { answer = await fetch(`${base}/v1/text`, { method: "POST", headers: { Authorization: `Bearer ${env.OPENMOD_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text, ref }), signal: AbortSignal.timeout(10_000), }) } catch { // The check could not be made: fail closed, keep the content hidden. return Response.json({ decision: "hold" }) } const body = (await answer.json()) as { summary?: { action: string; reject_reason: Array<{ id: string }> } error?: { type: string } request_id?: string } if (!answer.ok || body.summary === undefined) { const refused = [400, 413, 415].includes(answer.status) return Response.json({ decision: refused ? "refuse" : "hold", error: body.error?.type ?? "unknown" }) } const { action, reject_reason } = body.summary return Response.json({ decision: action === "accept" ? "publish" : action === "review" ? "hold" : "refuse", reason: reject_reason[0]?.id ?? null, requestId: body.request_id, }) }, } ``` Your Worker is the one that calls openshield, from Cloudflare's servers. A browser must call your Worker, never openshield: a request to our API with an `Origin` header is refused. # Go > A moderation package for Go with net/http and the standard library only. This guide uses only the standard library. ## Set the key Put the key in the environment of your server, never in the code: ```bash export OPENMOD_API_KEY="om_live_..." ``` ## The package ```go [moderation/moderation.go] package moderation import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "time" ) const base = "https://api.openshield.dev" // Reason is a category that reached its threshold. type Reason struct { ID string `json:"id"` Text string `json:"text"` Message string `json:"message"` Score float64 `json:"score"` Threshold float64 `json:"threshold"` Source string `json:"source"` } // Decision is what a check answers. type Decision struct { Action string // "accept", "review" or "reject" RejectReasons []Reason ReviewReasons []Reason RequestID string } // Error is an error answer of the API. type Error struct { Status int Type string Message string RetryAfter string } func (e *Error) Error() string { return fmt.Sprintf("openshield: %s (%d): %s", e.Type, e.Status, e.Message) } var client = &http.Client{Timeout: 10 * time.Second} func send(ctx context.Context, path, contentType string, body io.Reader) (*Decision, error) { request, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, body) if err != nil { return nil, err } request.Header.Set("Authorization", "Bearer "+os.Getenv("OPENMOD_API_KEY")) if contentType != "" { request.Header.Set("Content-Type", contentType) } response, err := client.Do(request) if err != nil { return nil, err } defer response.Body.Close() raw, err := io.ReadAll(response.Body) if err != nil { return nil, err } if response.StatusCode != http.StatusOK { var failure struct { Error struct{ Type, Message string } `json:"error"` } _ = json.Unmarshal(raw, &failure) return nil, &Error{Status: response.StatusCode, Type: failure.Error.Type, Message: failure.Error.Message, RetryAfter: response.Header.Get("Retry-After")} } var answer struct { Summary struct { Action string `json:"action"` RejectReason []Reason `json:"reject_reason"` ReviewReason []Reason `json:"review_reason"` } `json:"summary"` RequestID string `json:"request_id"` } if err := json.Unmarshal(raw, &answer); err != nil { return nil, err } return &Decision{ Action: answer.Summary.Action, RejectReasons: answer.Summary.RejectReason, ReviewReasons: answer.Summary.ReviewReason, RequestID: answer.RequestID, }, nil } // CheckText checks a text. ref is your own id for it, or empty. func CheckText(ctx context.Context, text, ref string) (*Decision, error) { payload := map[string]any{"text": text} if ref != "" { payload["ref"] = ref } encoded, err := json.Marshal(payload) if err != nil { return nil, err } return send(ctx, "/v1/text", "application/json", bytes.NewReader(encoded)) } // CheckImage checks an image sent as bytes. ref is your own id for it, or empty. func CheckImage(ctx context.Context, image []byte, ref string) (*Decision, error) { path := "/v1/image" if ref != "" { path += "?ref=" + url.QueryEscape(ref) } return send(ctx, path, "", bytes.NewReader(image)) } ``` ## Use it, and fail closed ```go func moderateComment(ctx context.Context, text, commentID string) string { decision, err := moderation.CheckText(ctx, text, commentID) if err != nil { var failure *moderation.Error // The content cannot be accepted as it is: refuse it. if errors.As(err, &failure) && (failure.Status == 400 || failure.Status == 413 || failure.Status == 415) { return "refuse" } // Anything else, a timeout included: keep it hidden until someone looks at it. return "hold" } switch decision.Action { case "accept": return "publish" case "review": return "hold" default: return "refuse" } } ``` For rejected content, show your user a message chosen from the `ID` of the first reject reason, in your own words and your own languages. [Best practices](https://openshield.dev/docs/best-practices) says more. # Moderate an AI agent > Check what an agent reads and what it writes with two calls and a fallback, so nothing unchecked reaches the model or the reader. An AI agent reads content it did not write and produces content nobody has read yet. This guide shows where the two checks go, with the module of the [Node.js guide](https://openshield.dev/docs/integration-guides/nodejs). The calls are the same from any language. ## What to check
Side What it is When to check
Input The messages and images a user sends the agent. Before they reach the model or a tool.
Output The text and images the agent generates, and the tool results it reposts. Before you publish or send them.
One image or one text is one check, whatever the number of categories it is checked against. A run that checks one input and one output is two checks. ## The two calls Check the input, run the model, check the output. A check that cannot be made holds the content: it is never published unchecked. ```ts [agent.ts] import { checkText, ModerationError } from "./moderation" type Verdict = "accept" | "review" | "reject" // A check that cannot be made is a "review": hold the content. async function verdict(text: string, ref: string): Promise { try { return (await checkText(text, ref, "support_agent")).action } catch (error) { // The content cannot be accepted as it is: refuse it. if (error instanceof ModerationError && [400, 413, 415].includes(error.status)) return "reject" // Anything else, a timeout included: hold it. return "review" } } // model, refuse and holdForReview are yours. export async function answer(run: { id: string }, message: string) { // Before the model: what the user sent. if ((await verdict(message, `${run.id}:in`)) === "reject") return refuse(run) const reply = await model.generate(message) // Before you publish: what the agent wrote. if ((await verdict(reply, `${run.id}:out`)) !== "accept") return holdForReview(run, reply) return reply } ``` `support_agent` is a workflow you created in the [dashboard](https://openshield.dev/dashboard/workflows). Leave the third argument out to use `default`. For an image the agent received or generated, call `checkImage` the same way: the third argument is the workflow. ## One ref per step `ref` is your own id for the content, 1 to 128 characters with no control character. Use the run id and the step, such as `run_8f3a:in` and `run_8f3a:out`. It comes back in the answer and stays in the [journal](https://openshield.dev/docs/journal-and-ref), so when a reply was held you filter the journal by the start of the ref (`run_8f3a`) and see the decision, the reasons with their scores and thresholds, and the workflow version. The content is never kept. Do not put personal data in a `ref`. Use an opaque id from your own database. ## A workflow per agent A [workflow](https://openshield.dev/docs/workflows) decides which categories are checked and how strictly. Give a customer-facing agent a strict one and an internal tool a lenient one. Create one key for each agent, limit it to the workflow it needs and to images, texts or both: a call outside that gets `403 forbidden`. [Authentication and keys](https://openshield.dev/docs/authentication) explains scopes. ## When a check cannot be made
Answer What to do
400 , 413 , 415 The content cannot be accepted as it is: refuse it. For 413 text_too_long , see below.
401 , 403 A configuration problem. Log it, without the key, and alert your team.
429 , 503 with Retry-After Wait that many seconds and retry, at most twice.
500 , a timeout, a network error Fail closed: hold the content.
[Responses and errors](https://openshield.dev/docs/responses-and-errors) lists every type. ## Long outputs A text is at most 20,000 characters (5,000 on the free plan). A longer output gets `413 text_too_long`. Split it at paragraph boundaries and check each part: each part is one check. ## Keep the key on your servers The agent's runtime calls the API with a key. If the agent runs in a browser or on a user's device, it must call your own backend, which calls openshield: the API refuses requests that carry a browser `Origin` header. [Best practices](https://openshield.dev/docs/best-practices) says more. ## What it does not do - It checks images and text. It does not check video or audio. - It checks content against categories such as unsafe text, hate and personal data. It does not detect prompt injection. - Nobody reviews content for you. A `review` decision goes back to your own team, and the content was never stored at openshield. # Node.js and TypeScript > A small moderation module for Node.js 18 or later, with timeouts and a decision that fails closed. This guide uses the `fetch` that ships with Node.js 18 and later. It has no dependency. ## Set the key Put the key in the environment of your server, never in the code: ```bash OPENMOD_API_KEY=om_live_... ``` ## The module ```ts [moderation.ts] const base = "https://api.openshield.dev" export type Action = "accept" | "review" | "reject" export interface Reason { id: string text: string message: string score: number threshold: number source: string } export interface Decision { action: Action rejectReasons: Reason[] reviewReasons: Reason[] requestId: string } export class ModerationError extends Error { constructor( readonly status: number, readonly type: string, message: string, readonly retryAfter?: number, ) { super(message) } } async function send(path: string, init: RequestInit): Promise { const key = process.env.OPENMOD_API_KEY if (!key) throw new Error("OPENMOD_API_KEY is not set") const response = await fetch(`${base}${path}`, { ...init, headers: { ...init.headers, Authorization: `Bearer ${key}` }, method: "POST", signal: AbortSignal.timeout(10_000), }) const body = await response.json() if (!response.ok) { const retryAfter = Number(response.headers.get("retry-after")) || undefined throw new ModerationError(response.status, body?.error?.type ?? "unknown", body?.error?.message ?? response.statusText, retryAfter) } return { action: body.summary.action, rejectReasons: body.summary.reject_reason, reviewReasons: body.summary.review_reason, requestId: body.request_id, } } // `workflow` names a workflow of yours: `default` when it is left out. export function checkText(text: string, ref?: string, workflow?: string): Promise { return send("/v1/text", { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, ref, workflow }), }) } export function checkImage(bytes: Uint8Array, ref?: string, workflow?: string): Promise { const query = new URLSearchParams() if (ref) query.set("ref", ref) if (workflow) query.set("workflow", workflow) const suffix = query.toString() return send(`/v1/image${suffix ? `?${suffix}` : ""}`, { body: bytes }) } ``` ## Use it, and fail closed Decide what happens to the content for each outcome, and what happens when the check itself fails. ```ts import { checkText, ModerationError } from "./moderation" export async function moderateComment(text: string, commentId: string): Promise<"publish" | "hold" | "refuse"> { try { const decision = await checkText(text, commentId) if (decision.action === "accept") return "publish" if (decision.action === "review") return "hold" return "refuse" } catch (error) { // The content cannot be accepted as it is: refuse it. if (error instanceof ModerationError && [400, 413, 415].includes(error.status)) return "refuse" // Anything else, a timeout included: keep it hidden until someone looks at it. return "hold" } } ``` For `rejected` content, show your user a message chosen from `decision.rejectReasons[0].id`, in your own words and your own languages. [Best practices](https://openshield.dev/docs/best-practices) says more. # Nuxt and Next > Moderate content in a Nuxt server route or a Next.js route handler, so the key never leaves the server. Both frameworks run server code next to your pages. Put the call to openshield there, and let your pages call your own route. ## Nuxt Keep the key in a private runtime config value. In `nuxt.config.ts`, an empty default under `runtimeConfig` (outside `public`) is filled from the environment variable `NUXT_OPENMOD_API_KEY`: ```ts [nuxt.config.ts] export default defineNuxtConfig({ runtimeConfig: { openmodApiKey: "", // set NUXT_OPENMOD_API_KEY in the environment }, }) ``` ```ts [server/api/comments.post.ts] interface Answer { summary: { action: "accept" | "review" | "reject"; reject_reason: Array<{ id: string }> } request_id: string } export default defineEventHandler(async (event) => { const { text, id } = await readBody<{ text: string; id: string }>(event) const { openmodApiKey } = useRuntimeConfig(event) const result = await $fetch("https://api.openshield.dev/v1/text", { method: "POST", headers: { Authorization: `Bearer ${openmodApiKey}` }, body: { text, ref: id }, timeout: 10_000, }).catch((error: { statusCode?: number }) => error.statusCode ?? 0) if (typeof result === "number") { // 400, 413 and 415: the content cannot be accepted as it is. if ([400, 413, 415].includes(result)) throw createError({ statusCode: 422 }) // Anything else, a timeout included: the check could not be made. Fail closed, keep the comment hidden. return { published: false } } const { action, reject_reason } = result.summary if (action === "reject") throw createError({ statusCode: 422, data: { reason: reject_reason[0]?.id } }) // Store the comment, hidden when action is "review", published when it is "accept". return { published: action === "accept", requestId: result.request_id } }) ``` ## Next.js Keep the key in `.env.local` (not committed) and read it in a route handler: ```bash [.env.local] OPENMOD_API_KEY=om_live_... ``` ```ts [app/api/comments/route.ts] import { NextResponse } from "next/server" export async function POST(request: Request) { const { text, id } = (await request.json()) as { text: string; id: string } let answer: Response try { answer = await fetch("https://api.openshield.dev/v1/text", { method: "POST", headers: { Authorization: `Bearer ${process.env.OPENMOD_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text, ref: id }), signal: AbortSignal.timeout(10_000), }) } catch { // The check could not be made: fail closed, keep the comment hidden. return NextResponse.json({ published: false }) } const body = await answer.json() if (!answer.ok) { const refused = [400, 413, 415].includes(answer.status) return NextResponse.json({ published: false, refused }, { status: refused ? 422 : 200 }) } const { action, reject_reason } = body.summary if (action === "reject") return NextResponse.json({ published: false, reason: reject_reason[0]?.id }, { status: 422 }) // Store the comment, hidden when action is "review", published when it is "accept". return NextResponse.json({ published: action === "accept", requestId: body.request_id }) } ``` Do not prefix the variable with `NEXT_PUBLIC_`: that ships it to every visitor's browser. In Nuxt, never put the key under `runtimeConfig.public`. # PHP > A moderation helper for PHP 8.1 or later with cURL, and no dependency. This guide needs PHP 8.1 or later and the cURL extension, which ships with most PHP installations. ## Set the key Put the key in the environment of your server, never in the code. For PHP-FPM, set it in the pool's `env[OPENMOD_API_KEY]` or in your framework's secret store, and read it with `getenv()`. ## The helper ```php [moderation.php] true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => array_merge(['Authorization: Bearer ' . $key], $headers), CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false, CURLOPT_TIMEOUT => 10, ]); $raw = curl_exec($curl); $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); if ($raw === false) { throw new RuntimeException('openshield could not be reached: ' . curl_error($curl)); } $answer = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); if ($status !== 200) { $error = $answer['error'] ?? []; throw new ModerationError($status, $error['type'] ?? 'unknown', $error['message'] ?? 'The check failed'); } return [ 'action' => $answer['summary']['action'], 'reasons' => array_merge($answer['summary']['reject_reason'], $answer['summary']['review_reason']), 'request_id' => $answer['request_id'], ]; } function openmod_check_text(string $text, ?string $ref = null): array { return openmod_send('/v1/text', json_encode(['text' => $text, 'ref' => $ref], JSON_THROW_ON_ERROR), ['Content-Type: application/json']); } function openmod_check_image(string $bytes, ?string $ref = null): array { return openmod_send('/v1/image' . ($ref === null ? '' : '?ref=' . rawurlencode($ref)), $bytes); } ``` ## Use it, and fail closed ```php status, [400, 413, 415], true) ? 'refuse' : 'hold'; } catch (Throwable) { // A timeout or a network error: keep it hidden until someone looks at it. return 'hold'; } return match ($decision['action']) { 'accept' => 'publish', 'review' => 'hold', default => 'refuse', }; } ``` For rejected content, show your user a message chosen from the `id` of the first reason, in your own words and your own languages. [Best practices](https://openshield.dev/docs/best-practices) says more. # Python > A moderation module for Python 3 with the requests library. This guide uses [requests](https://requests.readthedocs.io). Install it with `pip install requests`. ## Set the key Put the key in the environment of your server, never in the code: ```bash export OPENMOD_API_KEY="om_live_..." ``` ## The module ```python [moderation.py] import os import requests BASE = "https://api.openshield.dev" SESSION = requests.Session() class ModerationError(Exception): def __init__(self, status, error_type, message, retry_after=None): super().__init__(message) self.status = status self.type = error_type self.retry_after = retry_after def _send(path, **kwargs): headers = {"Authorization": f"Bearer {os.environ['OPENMOD_API_KEY']}"} response = SESSION.post(BASE + path, headers=headers, timeout=10, **kwargs) body = response.json() if not response.ok: error = body.get("error", {}) raise ModerationError( response.status_code, error.get("type", "unknown"), error.get("message", response.reason), response.headers.get("Retry-After"), ) summary = body["summary"] return { "action": summary["action"], "reasons": summary["reject_reason"] + summary["review_reason"], "request_id": body["request_id"], } def check_text(text, ref=None): return _send("/v1/text", json={"text": text, "ref": ref}) def check_image(data, ref=None): return _send("/v1/image", params={"ref": ref} if ref else None, data=data) ``` ## Use it, and fail closed ```python import requests from moderation import ModerationError, check_text def moderate_comment(text, comment_id): """Returns "publish", "hold" or "refuse".""" try: decision = check_text(text, comment_id) except ModerationError as error: # The content cannot be accepted as it is: refuse it. if error.status in (400, 413, 415): return "refuse" return "hold" except requests.RequestException: # A timeout or a network error: keep it hidden until someone looks at it. return "hold" if decision["action"] == "accept": return "publish" if decision["action"] == "review": return "hold" return "refuse" ``` For rejected content, show your user a message chosen from the `id` of the first reason, in your own words and your own languages. [Best practices](https://openshield.dev/docs/best-practices) says more. # Supabase Edge Functions > Moderate content from a Supabase Edge Function, with the key as a project secret. An Edge Function runs on the server, so it is the right place for the key. Call it from your app, and let it call openshield. ## Set the key as a secret ```bash supabase secrets set OPENMOD_API_KEY=om_live_... ``` Read it in the function with `Deno.env.get("OPENMOD_API_KEY")`. Never put it in the code of your front end. ## The function ```ts [supabase/functions/moderate-text/index.ts] const base = "https://api.openshield.dev" Deno.serve(async (request) => { const { text, ref } = await request.json() let answer: Response try { answer = await fetch(`${base}/v1/text`, { method: "POST", headers: { Authorization: `Bearer ${Deno.env.get("OPENMOD_API_KEY")}`, "Content-Type": "application/json", }, body: JSON.stringify({ text, ref }), signal: AbortSignal.timeout(10_000), }) } catch { // The check could not be made: fail closed, keep the content hidden. return Response.json({ decision: "hold" }) } const body = await answer.json() if (!answer.ok) { const refused = [400, 413, 415].includes(answer.status) return Response.json({ decision: refused ? "refuse" : "hold", error: body?.error?.type ?? "unknown" }) } const { action, reject_reason } = body.summary return Response.json({ decision: action === "accept" ? "publish" : action === "review" ? "hold" : "refuse", // Choose your user's message from this id, in your own words. reason: reject_reason[0]?.id ?? null, requestId: body.request_id, }) }) ``` Deploy it with `supabase functions deploy moderate-text`. Your app calls it with the Supabase client: ```ts const { data } = await supabase.functions.invoke("moderate-text", { body: { text, ref: commentId } }) ``` Your function is the one that calls openshield, from the Supabase servers. The browser calls your function, never openshield: a request to our API with an `Origin` header is refused. # The journal and ref > What the decision journal keeps, how long, and how your own id (ref) lets you find a decision. The journal is the record of the decisions the API made for your organization. You read it in the [dashboard](https://openshield.dev/dashboard/journal), filter it, and export it. ## What a row holds One row per check: - the time and the `request_id`; - the key, by id and name; - the kind (image or text), the workflow and its version; - the decision, and the reasons with their scores and thresholds; - the categories the workflow could not check (`unchecked`); - how long the check took, and the size of the content in bytes; - your `ref`, if you sent one. **Never stored**: the content itself, whatever it is, the IP address of your users, and their browser. The journal keeps what the API decided, never what it was asked about. ## How long it is kept
Plan Days kept
Free 7
Starter, Growth, Scale 30
Older rows are deleted every day. Export what you need to keep for longer. ## ref: your own id for the content `ref` is the id your system gives the image or the text: a post id, a message id, a user id. It is 1 to 128 characters with no control character. ```bash curl -X POST "https://api.openshield.dev/v1/image?ref=post_8812" \ -H "Authorization: Bearer $OPENMOD_API_KEY" \ --data-binary @photo.jpg ``` The API echoes it in the answer and keeps it in the journal. When a user asks why their upload was refused, you search the journal for the `ref` and see the decision, the reasons and the workflow version, and openshield never had to keep the upload to answer. Do not put personal data in a `ref`. Use an opaque id from your own database. ## Filtering and exporting The journal screen filters by: - the decision (accepted, to review, rejected); - the kind, the key and the workflow; - a range of time; - the start of a `ref`. A row opens a detail panel with every reason. **Export** writes the rows of the filter, for any range inside the retention window, as JSONL or CSV. ## From a refusal to the row Every answer carries a `request_id`, in the body and in the `X-Request-Id` header. Log it next to your own records: it is the id of the journal row, and the first thing to quote if you write to support. # Limits and quotas > What each plan includes, how fast you may call the API, and what happens when a limit is reached. ## The plans
Free Starter Growth Scale
Price per month, excluding VAT EUR 0 EUR 29 EUR 199 EUR 699
Checks included per month 2,000 15,000 100,000 500,000
Price per 1,000 checks past them none: hard stop EUR 2.00 EUR 1.60 EUR 1.20
Requests per second 2 10 30 60
Burst 5 20 60 120
Concurrent checks 1 2 4 8
API keys 2 5 20 50
Workflows 3 10 50 200
Journal kept 7 days 30 days 30 days 30 days
Longest text 5,000 characters 20,000 characters 20,000 characters 20,000 characters
Largest image 12 megapixels 50 megapixels 50 megapixels 50 megapixels
Every plan includes every category and the full answer. Prices are the beta prices. The [pricing page](https://openshield.dev/pricing) has the up-to-date plans and a cost estimate. ## What counts as a check One image or one text is one check, whatever the number of categories it is checked against. Text found in an image, QR codes, links and custom categories are included. A request that does not end in an answer, for example one that gets a `400`, a `415` or a `503`, is not counted. ## Request limits
Limit
Request body 15 MB
Image size 50 megapixels (12 on the free plan), and 16,384 pixels on a side
Text length 20,000 characters (5,000 on the free plan)
ref 1 to 128 characters, no control character
Image downloaded from a url 15 MB, 3 redirects, 10 seconds
## Rate limit and concurrency Requests are limited per organization, with a bucket: it holds as many requests as the burst, and refills at the rate per second. A request that finds the bucket empty gets `429 rate_limited` with `Retry-After`. Checks also run a few at a time, as many as your plan's concurrency: another one gets `429 too_many_concurrent` with `Retry-After`. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused. A check waits up to 30 seconds for its turn on the server, then gets `503 busy` with `Retry-After`. The free plans together use at most half of a server's capacity, so a paid plan always keeps its share of it. ## The monthly quota A period is a month. Each plan includes some checks: - **Free plan**: when the included checks are used, the API answers `429 quota_exceeded` until the next period starts. Nothing is billed. - **Paid plans**: checks past the included ones are billed as overage, by the started thousand, as one line on your next invoice. If you change plan during a period, the period is split at the change. The checks counted until then are billed with the old plan's included checks, price and spending cap. The new plan counts from the change with its own price and spending cap, and it includes a share of its checks in proportion to the time left in your subscription's billing period: its included checks times the time left, divided by the length of the period, rounded up, and never more than the plan's whole allowance. Moving to Scale with 10 days left in a 30-day period includes 166,667 checks (500,000 x 10 / 30). The share applies to a move to a cheaper plan too. Checks past the share are billed as overage, up to your spending cap, and the API stops at the share plus what the cap pays for. Each part is a line on the same invoice, with its dates, and the line of a prorated part says so. A new subscription, a renewal and the free plan start with the plan's whole allowance, so the next period includes all of it. You set a **spending cap**: the most you accept to pay in overage. By default it is twice the price of your plan. When the cap is reached the API answers `429 quota_exceeded` until the next period or until you raise the cap, and `Retry-After` is the number of seconds until the next period. Setting the cap to 0 allows no overage at all. You change it on the billing page of the dashboard. A cap cannot go below what the checks already counted in the period cost: the overage is billed from the cap when the period ends, so lowering it would erase what you used. The dashboard tells you that amount; you can lower the cap further once the next period starts. Every response carries `X-Quota-Used` and `X-Quota-Limit`: the checks counted in the period, and the number the period allows before the API stops, the cap included. ### Emails about your quota The members who manage billing (the owner and the billing role) get an email at a verified address: - when the period has used 80% of the included checks; - when it has used all of them. On the free plan, checks stop until the next period starts. On a paid plan, the email says that checks past the included ones are billed, and at which spending cap the API stops; - on a paid plan, when it has used the checks your spending cap pays for. Checks stop until the next period starts or until you raise the cap. With a cap of 0 the checks stop at the included ones, which the previous email already says. After a plan change, these emails count from the share the new plan includes, and say so. Each email goes once in a period. ## When a payment fails The members who manage billing get an email for each invoice that fails. Your plan stays as it is for a grace period of 7 days. After that your organization is served with the free plan's limits until the invoice is paid. Your keys and data are kept. ## When you need more The plans' limits grow with the price. If none fits, write to [contact@sway.events](mailto:contact@sway.events). # Migrating from SightEngine > Three SightEngine workflow routes work here with the same paths, request fields and answer envelope. Change the base URL and key, map the category ids. openshield serves three SightEngine workflow routes. A client written for them needs three changes. Only these routes are served: SightEngine's other endpoints, such as `/1.0/check.json`, are not.
SightEngine openshield
POST /1.0/check-workflow.json with media or url , and workflow The same path and the same fields.
GET /1.0/check-workflow.json?url=&workflow=&api_user=&api_secret= The same path and the same query parameters.
POST /1.0/text/check-workflow.json with text and workflow The same path and the same fields.
api_user Accepted and ignored.
api_secret Holds an openshield API key .
The base URL is `https://api.openshield.dev`. ## 1. Point the client at the new base URL ```bash curl -X POST "https://api.openshield.dev/1.0/check-workflow.json" \ -F "media=@photo.jpg" \ -F "workflow=default" \ -F "api_user=ignored" \ -F "api_secret=$OPENMOD_API_KEY" ``` ```bash curl "https://api.openshield.dev/1.0/check-workflow.json?url=https%3A%2F%2Fexample.com%2Fphoto.jpg&workflow=default&api_user=ignored&api_secret=$OPENMOD_API_KEY" ``` ```bash curl -X POST "https://api.openshield.dev/1.0/text/check-workflow.json" \ -F "text=Write me at jane@example.com" \ -F "workflow=default" \ -F "api_user=ignored" \ -F "api_secret=$OPENMOD_API_KEY" ``` For an image, send either `media` (the file) or `url` (a public address, downloaded for you, with the [same guards](https://openshield.dev/docs/images)). The `GET` form takes `url` only, as a query string cannot carry a file. The whole form must arrive within 10 seconds (`408 request_timeout` otherwise), and every field but `media` is limited to 64 KB. A text field holds up to the 20,000 characters of a [text check](https://openshield.dev/docs/texts) (5,000 on the free plan). ## 2. Create the workflows you used A SightEngine workflow is named in your calls. Create a workflow of the same name in the [dashboard](https://openshield.dev/dashboard/workflows), or use `default`. The new workflow decides what is checked and how strictly: start from a template and adjust the [thresholds](https://openshield.dev/docs/workflows). ## 3. Map the category ids The answer carries the fields of a SightEngine workflow answer: `status`, `request` (`id`, `timestamp` as a float of seconds, `operations`), `workflow` (`{"id": ""}`), `summary.action`, `summary.reject_prob` and `summary.reject_reason`, plus `summary.review_reason`. Errors carry `request` too. The categories are openmod's own: match on `reject_reason[].id` and never on the English `text`. Map each id to the message you show your users. The ids you are most likely to meet are `nudity`, `gore`, `weapon`, `hate_symbol`, `self_harm`, `drugs` and `minor` for images, and `text_unsafe`, `violence`, `sexual`, `hate`, `illegal`, `unethical`, `self_harm`, `pii_email`, `pii_phone`, `pii_iban`, `pii_card`, `spam`, `scam` and `link_unsafe` for texts. [Workflows](https://openshield.dev/docs/workflows) lists where to see all of them. ## What differs - SightEngine's per-model score fields at the top level of the answer (such as `weapon`, `alcohol` or `drugs`) are not served. The scores are in `scores`, keyed by openmod's category ids, and `reject_reason[].text` is openmod's wording. - SightEngine's models and their other fields (faces, text in images, quality and the rest) are not served: a workflow here runs openmod's own categories. - The answer also carries openmod's own fields: `request_id`, `workflow_version`, `scores`, `time_ms` and `ref`. A client may ignore them. `request.operations` is 1 for a check and 0 for a refusal. - The routes accept an optional `ref` field to attach your own id to a check; the `/v1` routes are the documented way to do it. - Errors use the [openmod envelope](https://openshield.dev/docs/responses-and-errors): `{"status": "failure", "error": {"type", "code", "message"}}`. - The key is secret and server-side: a request that carries an `Origin` header is refused. Sightengine is a trademark of its owner; openshield is not affiliated with it. # Privacy and data > What openshield stores and what it never stores, where, and for how long. This page summarizes how data is handled. The [privacy policy](https://openshield.dev/legal/privacy), the [data processing agreement](https://openshield.dev/legal/dpa) and the [sub-processors list](https://openshield.dev/legal/sub-processors) are the binding texts. ## Your content is never stored Images and texts are checked in memory and forgotten when the answer is sent. There is no copy, no thumbnail, no excerpt and no hash of them, in logs or anywhere else. Nothing holds your content in a queue, and it is not used to train models. On paid plans, the [advanced AI workflows](https://openshield.dev/docs/texts#advanced-ai-workflows) also send a text that the first check sends to review, with its `context`, to a sub-processor in the United States. The [privacy policy](https://openshield.dev/legal/privacy) says who it is and what it keeps. For the personal data your content may hold, Sway SRL is your **processor**: it processes the content only to return the decision, on your instructions. ## What is kept
Data Holds Kept
The decision journal For each check: the time, the request id, the key, the kind, the workflow and its version, the decision, the reasons with scores and thresholds, the duration, the size and your ref . Never the content. 7 days on the free plan, 30 days on paid plans
Usage counters Checks per hour, key, workflow and kind. While the organization exists
Account and organization Email addresses, names, members and roles, plan, workflows, keys (as hashes only). While the organization exists
Dashboard sessions For each browser signed in: its IP address, its browser and when it signed in. Until the session expires or you sign out
Audit log Who changed what in the dashboard, with the network prefix of their IP address (IPv4 /24 or IPv6 /48), never the full address. While the organization exists
Billing Handled by Stripe; invoices are kept as accounting law requires. See the privacy policy
The journal never stores the IP address or the browser of your users, only what you send in `ref`. ## Where it runs - The databases and the file storage are in the **European Union**. - The checking server is in the European Union. - The website, the dashboard and the front door of the API are delivered through Cloudflare's network, so a request is handled in the Cloudflare location closest to the sender. ## Your data, your exit You can export your workflows (JSON), your journal (JSONL or CSV) and your usage (CSV) at any time from the dashboard. When you delete your organization, the keys stop at once and your data stays available for 30 days so that you can take it out, then it is erased. ## Questions Write to [contact@sway.events](mailto:contact@sway.events). # Quickstart > Your first check in five minutes, from an account to a key and one request. This page takes you from nothing to your first moderation decision. You need a terminal with `curl`, and an image or a sentence to check. ## 1. Create an account Sign up at [openshield.dev/signup](https://openshield.dev/signup) with an email address and a password, a magic link, GitHub or Google. With a password, the sign-up ends at your mailbox: open the link we send you to confirm your email address. The link signs you in, and you cannot sign in before you have opened it. If it does not arrive or has expired, ask for it again on the sign-up page, or sign in with your password and we send it again. A magic link confirms your address by itself, and so does GitHub or Google when it vouches for the address. Your account starts on the free plan with a working `default` workflow. There is nothing to configure. ## 2. Create an API key Open **API keys** in the [dashboard](https://openshield.dev/dashboard/keys) and create a key. Pick what it may check, images, texts or both. The key is shown once. Copy it into an environment variable on your server: ```bash export OPENMOD_API_KEY="om_live_..." ``` A key is a secret. Keep it on your servers, never in a browser, a mobile app or a repository. The API refuses requests that carry a browser `Origin` header. ## 3. Check an image Send the image as the raw body of the request: ```bash curl -X POST "https://api.openshield.dev/v1/image?ref=avatar_4821" \ -H "Authorization: Bearer $OPENMOD_API_KEY" \ --data-binary @photo.jpg ``` ## 4. Check a text Send JSON, with the `Content-Type` header: ```bash curl -X POST "https://api.openshield.dev/v1/text" \ -H "Authorization: Bearer $OPENMOD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"text": "Write me at jane@example.com", "ref": "comment_9034"}' ``` ## 5. Read the answer Both answer with the same shape: ```json { "status": "success", "workflow": "default", "workflow_version": 1, "summary": { "action": "review", "reject_prob": 0, "reject_reason": [], "review_reason": [ { "id": "pii_email", "text": "Email address", "message": "This content contains an email address.", "score": 1, "threshold": 1, "source": "rules" } ] }, "scores": { "pii_email": 1 }, "time_ms": 4, "request_id": "0192b7c4-5f3a-7c1e-9a52-3d8f1e6b0a47", "ref": "comment_9034" } ``` Act on `summary.action`: - `accept`: go on. - `review`: keep the content hidden until a person approves it. - `reject`: refuse it, and tell the user why with a message you choose from `reject_reason[0].id`. [Responses and errors](https://openshield.dev/docs/responses-and-errors) describes every field. ## What next - Your first decision ticks **Get started** in the dashboard, and shows in the [journal](https://openshield.dev/docs/journal-and-ref). - Wire the check into your code with an [integration guide](https://openshield.dev/docs/integration-guides/nodejs), or hand the job to your coding agent with [the prompt](https://openshield.dev/docs/ai-agents). - Decide what counts as acceptable in your product with [workflows](https://openshield.dev/docs/workflows). # Responses and errors > Every field of an answer, the error envelope with its types, and the headers on every response. ## A successful answer A check answers `200` with this JSON. Every plan gets every field.
Field Type Description
status string Always success .
workflow string The workflow the content was checked against.
workflow_version integer The version of the workflow that decided.
summary.action string reject when a reject threshold is reached, else review when a review threshold is, else accept .
summary.reject_prob number The highest score among the rejected categories, 0 when none is.
summary.reject_reason array Why the content is rejected, highest score first.
summary.review_reason array Why the content goes to review, highest score first.
scores object The best score per category the workflow checks, from 0 to 1.
unchecked array The categories of the workflow this server could not check. Absent when every one was checked.
time_ms integer The time spent checking, in milliseconds.
request_id string The id of this request, also in X-Request-Id and in the journal .
ref string or null Your own id for the content, as you sent it.
A reason in `reject_reason` or `review_reason` has:
Field Description
id The category id, such as nudity or pii_iban . It is stable: map it to your own messages.
text A short English title.
message A full English sentence that can be shown to a user.
score The score the category got.
threshold The threshold it reached.
source The tool that produced the score.
Match on `id`, never on `text` or `message`. They are English defaults and may be reworded. ## Errors An error answers with the HTTP status and this envelope: ```json { "status": "failure", "error": { "type": "unsupported_media", "code": 415, "message": "The file is not a JPEG, PNG, WebP or GIF image." } } ``` Switch on `error.type`: it is stable. `error.code` repeats the HTTP status, and `error.message` is English text for you, not for your users.
error.type Status When
unauthorized 401 The key is missing, malformed, invalid, revoked or expired.
forbidden 403 The key may not check this kind of content or use this workflow, or the request came from a browser.
organization_suspended 403 An operator suspended the organization. Write to contact@sway.events .
bad_request 400 An empty body, a text that is not JSON or has no text , a refused ref , or a form that lacks a field.
unknown_workflow 400 There is no workflow of that name.
corrupt_media 400 The file does not decode.
url_refused 400 The url of an image is not public, too large, too slow or not an image.
unsupported_media 415 Not a JPEG, PNG, WebP or GIF image, or a text sent without Content-Type: application/json .
body_too_large 413 A request body over 15 MB.
image_too_large 413 More than 50 megapixels (12 on the free plan), or a side over 16,384 pixels.
text_too_long 413 More than 20,000 characters (5,000 on the free plan).
rate_limited 429 Too many requests: wait Retry-After seconds.
too_many_concurrent 429 Too many checks at once: wait Retry-After seconds.
quota_exceeded 429 The free plan's included checks are used, or the spending cap is reached. See limits and quotas .
request_timeout 408 A SightEngine-compatible form took more than 10 seconds to arrive.
workflow_unavailable 503 The workflow has no rule for this kind of content, or none this server can check.
busy 503 The server is busy, or the share of it the free plans use is: retry after Retry-After .
internal 500 A bug on our side. Quote the request_id when you write to us.
## What to do with an error - **400, 413, 415**: the request is wrong or the content cannot be accepted. Do not retry; refuse the content or fix the call. - **401, 403**: a configuration problem. Log it, without the key, and alert your team. - **408**: send the form again once, from a faster connection if you can. - **429, 503 with Retry-After**: wait that many seconds, and retry at most twice. - **500, a timeout or a network error**: fail closed, as [best practices](https://openshield.dev/docs/best-practices) explains. ## Headers on every response
Header Description
X-Request-Id The id of the request. Quote it in a support message.
RateLimit-Limit The burst size of your plan: how many requests the bucket holds.
RateLimit-Remaining How many requests are left in the bucket.
RateLimit-Reset Seconds until the bucket is full again.
X-Quota-Used Checks counted in the current period.
X-Quota-Limit Checks the period allows before quota_exceeded .
Retry-After Seconds to wait, on 429 and 503 .
# Texts > Check a text with POST /v1/text, for comments, messages, names and bios. `POST /v1/text` checks one text and answers with the decision. One text is one check, whatever the number of categories it is checked against. ## Request Send JSON, with the header `Content-Type: application/json`. Without it the API answers `415 unsupported_media`.
Field Required Description
text Yes The text, at most 20,000 characters (5,000 on the free plan).
workflow No The workflow to check against. default when absent.
ref No Your own id for the text, 1 to 128 characters, no control character. It is echoed in the answer and kept in the journal .
context No Where the text comes from, in plain words, at most 2,000 characters: for example "a buyer writes to a seller on a marketplace that forbids sharing phone numbers" . Paid plans read it in their advanced AI workflows ; the free plan ignores it. It is not kept in the journal.
```bash [curl] curl -X POST "https://api.openshield.dev/v1/text" \ -H "Authorization: Bearer $OPENMOD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"text": "Write me at jane@example.com", "workflow": "default", "ref": "comment_9034"}' ``` ```ts [TypeScript] const response = await fetch("https://api.openshield.dev/v1/text", { method: "POST", headers: { Authorization: `Bearer ${process.env.OPENMOD_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text: "Write me at jane@example.com", ref: "comment_9034" }), }) const answer = await response.json() console.log(answer.summary.action) ``` ## What is checked The categories of the workflow. The `default` workflow: - **Rejects** unsafe text, violence, sexual content, hate, illegal activity and unethical content at a score of 0.8 (and sends them to review from 0.5), and bank account numbers, payment card numbers and unsafe links outright. - **Sends to review** self-harm from 0.5, and email addresses, phone numbers, requests to continue on another messaging app, spam, scams, extremism and links to adult or gambling sites outright. Some rules look at the text itself, such as personal data and links. Their score is 1 when they match, which is why their thresholds are 1. [Workflows](https://openshield.dev/docs/workflows) explains how to change all of this. ## Answer The answer has the [shape described here](https://openshield.dev/docs/responses-and-errors). For a text that holds an email address: ```json { "status": "success", "workflow": "default", "workflow_version": 1, "summary": { "action": "review", "reject_prob": 0, "reject_reason": [], "review_reason": [ { "id": "pii_email", "text": "Email address", "message": "This content contains an email address.", "score": 1, "threshold": 1, "source": "rules" } ] }, "scores": { "pii_email": 1 }, "time_ms": 4, "request_id": "0192b7c5-1d20-7b3a-8f61-5c9e2a7d4b10", "ref": "comment_9034" } ``` ## Advanced AI workflows On Starter, Growth and Scale, a text that the first check sends to `review` gets a second, deeper reading by a more advanced AI model, with your `context`. It answers: - `reject` when the text clearly breaks the policy on the points flagged, or on another point (then under `text_unsafe`); - `accept` when the flag was a false alarm; - `review` as before when it is not sure. A reason it gives has `"source": "advanced"`. It costs nothing more per check. When it is not available, or slower than 1.5 seconds, the first check's decision stands. Images are not concerned. The model runs at a sub-processor listed on the [sub-processors page](https://openshield.dev/legal/sub-processors). ## Errors
Status error.type When
400 bad_request The body is not JSON, or has no text , or ref is refused, or context has more than 2,000 characters.
400 unknown_workflow There is no workflow of that name.
413 text_too_long The text has more than 20,000 characters (5,000 on the free plan).
413 body_too_large The body is over the size limit.
415 unsupported_media The Content-Type is not application/json .
503 workflow_unavailable The workflow has no rule for texts, or none this server can check.
Authentication, rate limit and quota errors are in [responses and errors](https://openshield.dev/docs/responses-and-errors). # Workflows > A workflow decides which categories are checked and how strictly, with thresholds, custom categories and versions. A workflow lists the categories to check and, for each, a `reject` threshold and a `review` threshold. Every check names a workflow with its `workflow` parameter, and `default` when it names none. ## How a decision is made Each category gets a score between 0 and 1. For each category the workflow lists: - a score at or above `reject` rejects the content; - else, a score at or above `review` sends it to review; - a category the workflow does not list is not checked at all. The answer's `summary.action` is `reject` when any category reaches its reject threshold, else `review` when any reaches its review threshold, else `accept`. The thresholds are numbers from 0 to 1. A category needs at least one of the two, and its review threshold is not above its reject threshold. ## The default workflow Every organization starts with a `default` workflow, copied from openmod's own. It is semi-permissive: it rejects what is plainly unacceptable, sends the doubtful cases to review and lets everyday content pass.
Kind Rejected Sent to review
Image Nudity, gore, weapons, hate symbols and self-harm at 0.8. Extremist text, and links in text or QR codes to adult or unsafe sites. The same five categories from 0.5. Drugs and minors from 0.5. Links to gambling sites.
Text Unsafe text, violence, sexual content, hate, illegal activity and unethical content at 0.8. Bank account numbers, payment card numbers and unsafe links. The same six categories from 0.5. Self-harm from 0.5. Email addresses, phone numbers, requests to continue on another messaging app, spam, scams, extremism, and links to adult or gambling sites.
Two more templates are available when you create a workflow: `strict`, which checks everything openmod can, and `nudity_only`, for profile pictures. Organizations keep their own copy, so a change to a template never changes a workflow of yours. When a newer default is available, the dashboard shows what changed and offers to update, which makes a new version. ## Editing a workflow Edit workflows in the [dashboard](https://openshield.dev/dashboard/workflows): - pick the categories from the catalogue, one list for images and one for texts, and set a review and a reject threshold for each; - add **custom categories** (below); - see every version, and compare any two. A name is 1 to 64 characters. Your plan sets how many workflows you can have: 3 on the free plan, 10 on Starter, 50 on Growth and 200 on Scale. A saved version compiles in the background: until it has, the previous version keeps serving checks, and the dashboard says the new one is waiting. ## Custom categories A custom category teaches a workflow something that is specific to your product, in plain words. You give it: - an **id**: lower-case letters, digits and underscores, up to 64 characters, such as `competitor_mention`; - up to 8 **prompts**: short descriptions of what to look for, each up to 200 characters; - optionally up to 8 **negatives**: descriptions of what looks similar but is fine; - a **shift**, from -20 to 20, to make the category stricter or more lenient. A workflow can have up to 20 custom categories. They count as one check, like any other category. Once defined, a custom category appears in the workflow's category list, and you set its thresholds like any other. ## Versions Every save makes a new version. The answer says which one decided, in `workflow_version`, and so does the journal: you can always tell which rules were in force for a past decision. ## Listing your workflows from the API `GET /v1/workflows` lists the names and current versions of the workflows the key may use: ```bash curl -H "Authorization: Bearer $OPENMOD_API_KEY" https://api.openshield.dev/v1/workflows ``` ```json { "status": "success", "workflows": [ { "name": "default", "version": 3 }, { "name": "profile_picture", "version": 1 } ] } ``` A workflow with no rule for the kind of content you send answers `503 workflow_unavailable` for it: an image sent to a workflow that only has text rules, for example. # openshield docs These pages are served under [/docs](https://openshield.dev/docs). Start with the [quickstart](https://openshield.dev/docs/quickstart). ## Resources for Agents - [API catalog: every service document this site publishes](https://openshield.dev/.well-known/api-catalog) - [Sitemap (XML)](https://openshield.dev/sitemap.xml) - [Sitemap (Markdown): every page on the site](https://openshield.dev/sitemap.md) - [llms.txt: index of the documentation for LLMs](https://openshield.dev/llms.txt) - [llms-full.txt: the full documentation as a single file](https://openshield.dev/llms-full.txt) - [OpenAPI document: every route this site serves to agents](https://openshield.dev/openapi.json)