# Overview > openshield is an AI moderation API for images and text, and a fraud prevention API for sign-ups and payments. One HTTP call, a decision, 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. openshield has a second product, the [fraud prevention API](https://openshield.dev/docs/fraud-prevention) under `/fraud/v1`. Before a sign-up, a login or a payment, you send the event and it answers `allow`, `step_up`, `hold` or `block`, with the rules that fired. It runs [openfraud](https://github.com/regisandre/openfraud-rs), the open-source fraud engine under the Apache-2.0 licence, with one instance for each organization. - **No content is stored.** Moderated 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. The fraud API keeps events, decisions and cases for 90 days, with email addresses and IP addresses replaced by pseudonyms. - **Every decision explains itself.** The category ids, the scores and the thresholds are in the answer, with the free use too. - **Checked in the EU.** The moderation server and the databases are in the European Union. Fraud instances run on a server in Germany. - **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 $OPENSHIELD_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 } ] }, "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). - [Fraud prevention](https://openshield.dev/docs/fraud-prevention): decide on sign-ups, logins and payments with your own openfraud instance, under `/fraud/v1`. - [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. - [MCP server](https://openshield.dev/docs/mcp-server): connect Claude, Cursor or your own agent to your organization, under your own role. ## 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. - **Sign-ups, logins and payments**: [fraud prevention](https://openshield.dev/docs/fraud-prevention), to stop sign-up farms, card testing and account takeover. - **Prices**: [prepaid, with no subscription](https://openshield.dev/pricing): EUR 2.00 of free use each month, then each check costs the components its workflow uses, and each fraud decision or event its own price. # 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). To let an agent work with your account itself (read your usage and fraud data, moderate, act on cases), connect it to the [MCP server](https://openshield.dev/docs/mcp-server). ## 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 `OPENSHIELD_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 moderation API in one table, the fraud prevention routes, and the OpenAPI document. The base URL is `https://api.openshield.dev`. Every endpoint except the last two of the table needs an [API key](https://openshield.dev/docs/authentication). The fraud prevention API has [its own routes](#fraud-prevention-endpoints). 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.
## Fraud prevention endpoints The fraud prevention API runs under `/fraud/v1`, with the same base URL and keys that carry fraud access: `fraud_events` for the reporting routes, `fraud` for all of them. [Fraud prevention](https://openshield.dev/docs/fraud-prevention) describes how to use it. The complete list, with its fields, is in the [OpenAPI document](https://openshield.dev/docs/api/openapi.json).
Method and path What it does Scope
POST /fraud/v1/decide Decides on an event before you act on it. fraud_events
POST /fraud/v1/events Reports an event after the fact. fraud_events
POST /fraud/v1/events/batch Reports up to 100 events at once. fraud_events
POST /fraud/v1/cases/{id}/appeal Passes on an end user's appeal of a case. fraud_events
GET /fraud/v1/statements/{id} Reads the statement of reasons of a decision. fraud_events
GET /fraud/v1/actors/{id} , /cluster Reads an account, and the accounts linked to it. fraud
POST /fraud/v1/actors/{id}/label , /status , /snoozes Labels an account, sets its status, silences a rule for it. fraud
DELETE /fraud/v1/actors/{id}/snoozes/{rule} Lifts a snooze. fraud
GET /fraud/v1/tenants/{id} , POST .../status Reads a workspace of your own users, and sets its status. fraud
GET /fraud/v1/cases , GET /fraud/v1/cases/{id} , POST /fraud/v1/cases/{id}/resolve Lists, reads and resolves cases. fraud
GET /fraud/v1/subjects/{id}/export , DELETE /fraud/v1/subjects/{id} Exports, or erases, what is kept about a person. fraud
GET /fraud/v1/lists , GET , POST and DELETE /fraud/v1/lists/{name}/items Lists your lists and fills them. fraud
Its errors are in [responses and errors](https://openshield.dev/docs/responses-and-errors), its prices in [limits and quotas](https://openshield.dev/docs/limits-and-quotas). ## 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 $OPENSHIELD_API_KEY" ... # The X-Api-Key header curl -H "X-Api-Key: $OPENSHIELD_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, and for [fraud prevention](https://openshield.dev/docs/fraud-prevention) `fraud` (the whole fraud API) or `fraud_events` (decide and report events only). A key without the scope for a call 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. ## Editing a key Edit an active key from the dashboard to change its name, scopes, workflows or expiry. The key itself does not change, so servers that use it keep working, and the gateway applies the new access within seconds. A key stays in its environment: a live key cannot become a test key, or the other way round. An expired or revoked key cannot be edited: create a new one. A key a rotation replaced cannot be edited either: it stops at the end of its grace delay, so edit the new key. Owners and admins edit any key; developers edit the keys they created. ## 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 the number of keys your limits allow. ## Key activity and status Open a key's activity from the dashboard to see: - **Right now**: the key's requests this minute, and your organization's requests this minute against its per-minute limit, the checks running at once, and the quota used this period. Limits belong to the organization, so every key shares them. - **Usage**: the key's checks, fraud calls and cost this hour, over the last 24 hours and over the last 30 days. Sandbox calls cost nothing. - **Last calls**: the key's latest checks from the journal, kept as long as your plan's journal retention. Beside its status, the key list flags what needs attention: - **Expires soon**: the key expires within 14 days. - **Grace period**: a rotation replaced the key, and it stops working at the end of its grace delay. - **Never used**: no call has used the key yet. - **Idle 30 days**: the key has not been used for 30 days. Revoke a key you no longer need. ## 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 `OPENSHIELD_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. ## MCP server for AI agents October 25, 2026 Connect Claude, Cursor, VS Code or your own agent to your organization at mcp.openshield.dev/mcp, under your own role. - Sign in from the agent with OAuth, pick the organization and approve what it may do: read, moderate, or change fraud data, one scope at a time. - Tools to read the organization, usage, decisions, keys, workflows, fraud cases, rules and lists, and to search the docs. - Moderate a text or an image from the agent, billed like any check. - With writes allowed, resolve a fraud case, edit list items and snooze rules; each action is in the audit log. - The AI agents page: the address and setup for each client, who may connect, the connected agents with a disconnect button, and the call log. - API keys take an MCP server scope, for an agent that cannot sign in through a browser. Such a key only reads. ## Key activity and status October 25, 2026 See what an API key does right now, what it used, its last calls, and what needs attention. - Open a key's activity from the dashboard: its requests this minute, and your organization's requests, running checks and quota against its limits. - The key's checks, fraud calls and cost this hour, over 24 hours and over 30 days, and its last calls from the journal. - The key list flags a key that expires within 14 days, one in a rotation's grace period, one never used, and one idle for 30 days. ## Edit an API key October 25, 2026 Change an active key's name, scopes, workflows or expiry without replacing it. - Edit an active key from the dashboard: its name, its scopes, the workflows it may use and its expiry. The key itself does not change, and the new access applies within seconds. - A key stays in its environment, and an expired or revoked key cannot be edited. - Owners and admins edit any key; developers edit the keys they created. Each edit is in the audit log. ## Fraud prevention (beta) October 21, 2026 A second product: score sign-ups, logins and payments with your own openfraud instance, under /fraud/v1, with the same API keys. - Turn fraud prevention on in the dashboard: your organization gets an openfraud instance of its own, and its data never mixes with anyone else's. - POST /fraud/v1/decide before a sensitive action, and /fraud/v1/events to report what happened. A decision costs EUR 0.001 and an event EUR 0.0002, from the same balance. - API keys take a fraud scope: the whole fraud API, or decide and events only. - Rule packs for free tiers and trials, cards and payments, messaging, marketplaces, APIs and hosting. Every pack rule starts in shadow until a person promotes it. - From your first top-up, the network data: the operator and country of each address, and the lists of disposable email domains, Tor exits, data centres and VPNs. - Webhooks for decisions and cases, signed with the Standard Webhooks scheme under a secret of your own. - A free organization's instance is paused after 30 days without a call; the next call or a click in the dashboard resumes it. - An attempt before sign-in (a failed login, a password reset) never acts on the account its email address belongs to, and an event your page reports is marked "reported_by": "client" so it names no account. Challenge outcomes count only from your server. - Your instance keeps events, decisions and cases 30 days until your first top-up and 90 days after; the sandbox keeps them 30 days. Each instance gets the memory its options need, so turning on the network data or activity bursts never slows it down. - Fraud lists are read item by item in the dashboard and the API; the CSV export of a whole list is withdrawn. - The network data and the activity bursts now add to the price of a decision while they are on (EUR 0.0002 and EUR 0.0005): they hold more in your instance's memory. The dashboard shows the surcharge next to each one. - The overview shows your limits and what the next top-up raises, instead of a plan: there are no plans any more, only a balance. The key and workflow limits still follow what you topped up in 30 days. Billing lists every limit level, what unlocks it and how much more reaches the next one. ## Beta preview October 20, 2026 The first version of openshield, ready to try. - Prepaid use, with no subscription: top up a balance from EUR 5 and each check costs the components its workflow uses. Every account gets EUR 2.00 of free use each month. Automatic top-ups, a monthly limit on them, and email alerts on a low balance or a month's use. - An advanced reading once you have topped up: 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 account 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. Each workflow shows what it costs per image and per text before you save it. - A decision journal with filters, details and export, and usage charts by day, key and workflow. - Request limits rise with your top-ups, and every top-up has its invoice in the dashboard. - Teams with roles, and two-factor authentication with an authenticator app, which owners turn on before their first top-up. # Device token and challenges > Add the device script to your pages, send its token with each sensitive event, and report the outcome of a step-up challenge. The [fraud prevention API](https://openshield.dev/docs/fraud-prevention) reads more than the fields your server sends. A small script on your pages gives each sensitive action a fresh **device token**, and when a decision asks for proof (`step_up`), you tell your instance how the proof went. This page covers both. ## Include the script openshield serves the script, a copy of openfraud's `sdk/device/device.js` at version 0.10.0, from its own address. Every version has a path of its own that never changes: ```html ``` The `integrity` attribute is the Subresource Integrity hash of that exact file: the browser refuses a script that differs by one byte. A versioned file is served with `cache-control: public, max-age=31536000, immutable` and with CORS open, so the hash can be checked from your site. `https://openshield.dev/sdk/device/latest/device.js` redirects to the newest version. It answers a temporary redirect (`302`) that browsers do not cache. A redirect cannot carry the CORS header that an integrity check needs, so use `latest` without `integrity`, for a quick try, and pin a version with its hash in production. To import it from your own module instead: ```js import { deviceToken } from "https://openshield.dev/sdk/device/v0.10.0/device.js" ``` The script keeps a random 128-bit id in your site's `localStorage`, under the key `openfraud_device`. It reads nothing from the canvas, the fonts, the audio or the hardware. Storing the id is subject to the ePrivacy rules in the EU (Article 5(3)): decide whether it is strictly necessary for security, or ask for consent. `forgetDevice()` removes it when a user withdraws consent. ## Send a token with each sensitive event Call `deviceToken()` just before each sensitive request: a login, a password change, a payment. It returns a new token every time, made of the device id, a random nonce, the time, the time zone, the platform, touch support and the automation flag. Send it to your server with the form, and your server puts it in the event as `device_token`: ```js // In the browser import { deviceToken } from "https://openshield.dev/sdk/device/v0.10.0/device.js" await fetch("/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password, device_token: deviceToken() }), }) ``` ```js // On your server, with the end user's context (see "Send the end user's context") const decision = await fraud.decide({ type: "session.login", actor_id: user.id, email, ip: endUserIp, user_agent: endUserAgent, country: endUserCountry, device_token: body.device_token, }) ``` Make a new token for each submit. A token is single use: sending the same one twice for the same event type is a replay. ## What your instance reads Your instance checks the token against the request and keeps the result in the event, as `event.device_token`:
Value Meaning Signal
ok recent, not used before for this event type, and readable none
stale older than 120 seconds invalid_device_data
replayed its nonce was used before for the same event type replayed_device_data
invalid it cannot be read invalid_device_data
missing no token, and the setting below is on missing_device_data
From a readable token it fills `event.device_info`: `timezone`, `platform` (`windows`, `macos`, `ios`, `android`, `linux`, `linux_arm`, `chromeos` or `other`), `touch` and `webdriver`. It compares the platform with your `user_agent` (`spoofed_device`) and the time zone with the `country` (`timezone_area_mismatch`), and a `webdriver` flag marks an automated browser (`headless_browser`). The device id in the token becomes the event's `device` when the event has none. The token itself is not stored. The script holds no secret, so a token proves freshness and single use, not authenticity: a determined attacker can forge one. It stops the replay of a captured request and catches automated browsers that do not hide. ## Turn on `require_token` Once every page that sends a sensitive event sends a token, turn on **Require the device token** in the dashboard, under **Fraud settings**. From then on an event without a token counts as `missing` and raises `missing_device_data`. Turn it on before every page sends one and your own legitimate events are flagged. The signals show in the dashboard under **Events**, in plain words with the raw name beside. ## The challenge loop A `step_up` decision means: ask for proof before you go on (MFA, a confirmation email, a CAPTCHA). Your instance learns from how the proof went, and a passed proof earns the device some trust. 1. **Decide.** A login answers `step_up`. 2. **Show your challenge** and report that you did, with `challenge.requested`. 3. **Report the outcome**, `challenge.succeeded` or `challenge.failed`, with the account and the device. ```js const decision = await fraud.decide({ type: "session.login", actor_id: user.id, device_token, ip, user_agent, country }) if (decision.decision === "step_up") { await fraud.track({ type: "challenge.requested", actor_id: user.id, device: deviceId }) const passed = await askForProof(user) // your own MFA, email code or CAPTCHA await fraud.track({ type: passed ? "challenge.succeeded" : "challenge.failed", actor_id: user.id, device: deviceId, }) if (!passed) return refuse() } ``` `deviceId` is the id of the device (`dev_...`), which your instance read from the token of the login. Send `device` on `challenge.succeeded`: it is the device that becomes trusted. Your server sends the outcome, after it checked the proof itself (the code matched, the MFA answer verified). Never pass on an outcome your page reports: a page can claim anything, so an event marked `"reported_by": "client"` loses its account and trusts no device (see [events a page reports](https://openshield.dev/docs/fraud-prevention#before-sign-in-and-events-a-page-reports)). What the instance does with it: - **A pass trusts the pair for 14 days.** `challenge.succeeded` puts the account and the device on the `trusted_devices` list for 14 days. The next logins from that device are allowed without asking for proof again, and each new pass restarts the 14 days. A device is trusted for one account only: on another account it is a new device. - **Three failures in an hour are a hold.** Three `challenge.failed` on an account within an hour fire the rule `ato_failed_challenges`, which holds the account's next sensitive actions until the owner confirms. - **A lost device is archived.** When the owner reports a lost device, archive the pair on `trusted_devices` with a `fraud` key, or in the dashboard under **Lists**: `POST /fraud/v1/lists/trusted_devices/items/archive` with `{"items": [{"value": "u_123", "second": "dev_8f2c41"}]}`, the values as the list shows them. These rules start in shadow like every rule that is not a block: until they are promoted (see [when rules start to act](https://openshield.dev/docs/fraud-prevention#when-rules-start-to-act)), the answers list what they would have done. `challenge.requested` and the outcomes themselves are recorded from the first call. ## Sandbox mock tokens Coming soon. A sandbox instance will read a mock token such as `test|device:d1|verdict:deny|score:80|ip:BE` and answer from it, so your tests can drive every branch of this loop (allow, step up, block) without writing a rule. openshield does not offer a sandbox yet: a `test|` token sent to a regular instance is `invalid`. # Fraud prevention > Score sign-ups, logins and payments with /fraud/v1/decide, report events with /fraud/v1/events, and get a webhook when a case opens. Stop fake accounts, card testing and abuse with one API. openshield runs [openfraud](https://github.com/regisandre/openfraud-rs), which is open source, for you under `/fraud/v1`. Call `decide` before a sign-up, a login or a payment, and get `allow`, `step_up`, `review`, `hold`, `block` or `quarantine` with the reasons. Call `events` to report what happened. It scores IP data (datacenter, VPN, Tor, ASN), disposable email domains, phone numbers, device and browser signals, velocity counters and linked accounts. Every rule shows its reasons, and new rules start in shadow before they act. Your organization gets an instance of its own: its events, accounts and cases never mix with anyone else's. It runs on our server in Germany, and your events, decisions and cases are kept 30 days until your first top-up and 90 days after. The fraud API is in beta. ## Turn it on An owner or an admin turns it on in the dashboard, under **Fraud prevention**. The instance starts in a few seconds. Then create an API key with fraud access (**API keys**, then **Create**):
Fraud access Scope What the key may call
Decide and report events only fraud_events decide , events , events/batch , appeals and statements
All of it fraud every route below, cases, accounts, lists and erasure included
A backend that only reports sign-ups and payments needs no more than `fraud_events`. Keep `fraud` keys for your back office. ## Decide before you act Call `decide` before a sensitive action (a sign-up, a login, a payment, a message sent to many people) and act on its answer. ```bash curl -X POST "https://api.openshield.dev/fraud/v1/decide" \ -H "Authorization: Bearer $OPENSHIELD_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "account.created", "actor_id": "u_123", "email": "someone@example.com", "ip": "203.0.113.50", "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", "language": "fr-BE,fr;q=0.9", "country": "BE", "device": "dev_8f2c41" }' ``` openfraud's SDKs work as they are: point them at `https://api.openshield.dev/fraud`. ```js import { Client } from "openfraud" const fraud = new Client({ baseUrl: "https://api.openshield.dev/fraud", apiKey: process.env.OPENSHIELD_KEY, timeoutMs: 800 }) const d = await fraud.decide({ type: "account.created", actor_id: user.id, email, ip, user_agent, language, country, device }) if (d.decision === "block" || d.decision === "quarantine") return refuse() if (d.decision === "step_up") return askForProof() ``` The SDKs fail open: on a timeout, a `429` or a `5xx` they retry, then answer `allow`. Give them at least 800 ms: a call goes through openshield's edge before it reaches your instance. The answer has the `decision` (`allow`, `step_up`, `review`, `hold`, `block` or `quarantine`), the rules that fired and why, the `actions` to apply, and the case it opened, if any. openfraud's [API reference](https://github.com/regisandre/openfraud-rs/blob/main/docs/api.md) describes every field. Besides those, the answer carries:
Field What it holds
signals what the facts show ( new_device , tor_ip , disposable_email_domain ...), and the signal of every rule that fired, acting or in shadow
scores account_takeover , account_abuse and bot , each from 0 to 1: a summary of the rules that fired, by family
lists the lists this event changed, as [{"list": "trusted_devices", "action": "add"}]
stopped_by the allow rule that allowed the event over the other rules, or null
matched_actor for an event sent before sign-in (no actor ), the account its email address belongs to, when there is one. Only a fraud key sees it: a fraud_events key gets null . It is information: never act on that account from it (see before sign-in )
sandbox true when the answer comes from your sandbox
## Send the end user's context, not your server's The gateway passes your request body to your instance untouched. It never fills these fields from its own request: that request comes from your backend, and its address, `User-Agent` and language describe your server.
Field What to send
ip The end user's address. Behind a proxy, the first X-Forwarded-For entry you trust.
user_agent The browser's User-Agent header. Your backend's own ( node , python-requests ) makes every event look scripted.
language The browser's Accept-Language header.
country From your CDN: CF-IPCountry , CloudFront-Viewer-Country .
device The id from openfraud's device script , and attrs.webdriver from its automated() .
`email`, `phone`, `ip` and `device` are replaced by keyed pseudonyms before any rule sees them, and the raw values are not stored. The `User-Agent` is read for the browser, the system and the signs of automation, then dropped. ## Report what happened `POST /fraud/v1/events` reports an event after the fact (a payment that went through, a refund, a dispute) without waiting for a decision. `POST /fraud/v1/events/batch` takes up to 100 at once. Rules learn from these: a card used by many accounts, a trial taken again and again. ## Before sign-in, and events a page reports Before sign-in there is no account to name: send a failed login or a password reset with the `email` the person typed, and no `actor_id`. Anyone can type someone else's address, so such an event is evidence about the sender, never about the account it matches. Your instance counts it under the sender's address, network, device and email domain, and never under the account: a burst of attempts with someone's address cannot change that account's decisions, counters, lists or trusted devices. Each event says who saw it happen, in `reported_by`:
Value When
server (the default) your backend saw it and knows who is signed in: a login it checked, a payment it took
client your page or app sent it, and your backend only passes it on without checking who is signed in
A `client` event is treated like one before sign-in: its `actor_id`, `tenant_id` and `owner_id` are dropped, since a page can claim any of them. If your front end reports events (a page view, a form opened before sign-in) through your backend, mark them `"reported_by": "client"`. Any other value is refused with a `400`. Send `challenge.succeeded` and `challenge.failed` from your server, after it checked the proof itself, never relayed from the page: only a server-reported pass trusts the device (see [step-up challenges](https://openshield.dev/docs/device-and-challenges)). Rules read `event.reported_by` too, so a rule of yours can weigh or leave out the events a page reported. ## Lists Lists hold the values your rules match: accounts, devices, emails, addresses. Read and fill them with a `fraud` key:
Route What it does
GET /fraud/v1/lists your lists
GET /fraud/v1/lists/{name}/items the items added at run time, with their author, end and note ( ?archived=true includes the archived ones)
POST /fraud/v1/lists/{name}/items adds items, each a string or {"value", "second", "for", "note"} ; for (for example 7d ) overrides the list's expiry
DELETE /fraud/v1/lists/{name}/items?item=... removes an item
POST /fraud/v1/lists/{name}/items/archive archives items, given as the entries of the list show them: they stop matching and stay on record
POST /fraud/v1/lists/{name}/items/unarchive gives archived items back
A list is read item by item: it cannot be exported as a file. ## Look up an IP address `GET /fraud/v1/ips/{ip}` answers what your instance knows of an address: its `type`, its network (`asn`, `org`, `country`) and the `lists` that hold it. `POST /fraud/v1/ips/query` does the same for up to 1,000 addresses, sent as `{"ips": ["203.0.113.50", "198.51.100.7"]}`, and answers `{results, meta: {total, valid}}`, with the invalid ones left out. A longer batch is refused with `400` `too_many_ips`. Both need a `fraud` key. A lookup is billed for each valid address it answers for, not for the ones that were left out. A batch reserves the price of 1,000 lookups from your balance before it runs, and gives back what it did not use. ## Prices
Call Price
Decision ( /fraud/v1/decide ) EUR 0.001
Event ( /fraud/v1/events , each accepted event of a batch) EUR 0.0002
IP lookup ( /fraud/v1/ips/{ip} , each valid address of ips/query ) EUR 0.0002
With the network data on, each decision + EUR 0.0002
With the activity bursts on, each decision + EUR 0.0005
The network data and the activity bursts hold more in your instance's memory, so a decision costs more while they are on. You turn them on and off in the fraud settings of the dashboard. Every price comes from the same prepaid balance as the checks, the monthly free use included. A call that does not end in a `2xx` costs nothing. The other routes (cases, accounts and lists) are free. ## What your instance checks The core always runs: blocklists you fill, trust earned by paying, and account takeover signals. In the dashboard you add rule packs for the kind of abuse you see: free tiers and trials, cards and payments, messaging, marketplaces and payouts, APIs and compute, hosting and content. Every pack rule starts in shadow: it says what it would do, in `shadow`, without doing it, until it is promoted to act. [When rules start to act](#when-rules-start-to-act) says how. The network data (the operator and country of each address, and the lists of disposable email domains, Tor exits, data centres and VPNs) comes with your first top-up. openshield refreshes it once a day, and your instance downloads it from inside the node: it reaches nothing on the internet. ### Settings Four more settings sit next to the packs in the dashboard. - **Activity bursts** (from your first top-up, off by default). Flags an account, a device or an address that sends far more events than its usual hour: the signals `high_activity_account`, `high_activity_device` and `high_activity_ip`. It keeps 7 days of counters, so the memory it uses grows with your traffic. - **Email domain check** (from your first top-up, off by default). Asks DNS whether the domain of an email address takes mail, once a day per domain, and raises `unreachable_email` when it does not. It sends the domain only, never the address, through a resolver inside the node. It uses DNS only, no extra memory. - **Require the device token** (off by default). An event without a `device_token` counts as missing. Turn it on once every page sends the device token. - **Webhook types** (all by default). Narrow the types posted to your webhook address. The types are `decision.step_up`, `decision.review`, `decision.hold`, `decision.block`, `decision.quarantine`, `case.opened`, `case.updated`, `case.resolved`, `case.appealed`, `actor.status_changed`, `tenant.status_changed`, `brake.engaged`, `brake.released`, and the three list types, `list_item.created`, `list_item.archived` and `list_item.unarchived`, which are posted when an item is added to a list, archived or restored. While you keep all of them, a type added in a later release reaches you; once you narrow the list, you receive only the types you ticked. ## VPN and residential proxies The network data tells a data centre or a Tor exit. A VPN exit and a residential proxy (a home connection rented out to hide who sits behind it) need fresher data, so openshield looks the address up when the event comes in. Turn on **VPN and residential proxy facts** in the dashboard, from your first top-up. It is off by default, and it costs nothing more: the lookup is included in the price of the call. When it is on, openshield adds four attributes to the `attrs` of an event that has an `ip`, on `/fraud/v1/decide`, `/fraud/v1/events` and `/fraud/v1/events/batch`, before the event reaches your instance:
Attribute Values
attrs.os_ip_tunnel vpn , proxy , tor , relay or none
attrs.os_proxy_type residential , datacenter , isp or unknown ; a line is classed only when a tunnel was detected
attrs.os_tunnel_operator the name of the service that runs the address, when known
attrs.os_tunnel_last_seen_days whole days since the address was last seen acting as a tunnel, when known
The `os_` prefix is openshield's: an attribute you send under another name is never touched, and one you send under an `os_` name is replaced by the lookup's own. Without the setting, or when the lookup does not answer within 300 ms, the event goes through exactly as you sent it: a slow or failed lookup never refuses an event, it only leaves the attributes out. An address is looked up once a day. Two rules read the attributes, in the `openshield-tunnels` rules that join your instance with the setting. Both start in shadow, like every pack rule:
Rule Signal Fires when
residential_proxy residential_proxy (abuse) os_proxy_type is residential and the address was last seen 7 days ago or less, or the days are unknown
vpn_tunnel vpn_tunnel os_ip_tunnel is vpn
A VPN is no proof of fraud (many honest users run one), so `vpn_tunnel` weighs little; read it with the other signals. The facts come from a third-party data source (proxycheck.io), which openshield sends the address and nothing else of the event. Its data is never exposed: your instance gets the four attributes above for the address of each event, and neither the source's lists nor its answers are served to you or to anyone else. ## When rules start to act - **Day 1.** The core blocklists act from the first call. Every other rule starts in shadow: the answer lists what it would have done under `shadow`, and nothing is blocked because of it. - **Automatic promotion, on by default.** Every day after the first 30, openshield looks at each shadow rule that hit at least 20 accounts, and promotes it to act when it has proved reliable. Either you labelled the accounts it hit, with a precision of at least 95 % over at least 10 labelled accounts, or our decision model reviewed pseudonymized timelines of up to 10 accounts the rule hit and found at least 90 % of them fraudulent. - **What a promoted rule may do.** Whatever the rule is built for, a block included. - **Manual mode.** You can switch to manual promotion, so that a rule acts only when a person promotes it. Or you can keep automatic promotion but limit it to rules whose action is hold or review, so that a rule that blocks is promoted by a person only. - **You are told.** Every automatic promotion is emailed to you and recorded in the audit log. - **A brake.** A brake stops the acting rules when a spike of decisions shows up. - **Back to shadow.** A promoted rule whose labelled precision falls under 80 % goes back to shadow on its own. Labelling the accounts you know to be fraud or legitimate (`POST /fraud/v1/actors/{id}/label`) is what makes promotion reliable, and what demotes a rule that has gone wrong. ## Webhooks Set a webhook address in the dashboard. Every decision other than `allow`, every change to a case, an account or a workspace, and every item added to a list, archived or given back (`list_item.created`, `list_item.archived`, `list_item.unarchived`), is posted to it, signed with the [Standard Webhooks](https://www.standardwebhooks.com) scheme under the secret the dashboard shows. Any Standard Webhooks library checks them, as does the SDK: ```js import { verifyWebhook } from "openfraud" const event = await verifyWebhook(process.env.OPENSHIELD_FRAUD_WEBHOOK_SECRET, req.headers, rawBody) ``` Answer with a `2xx` within 10 seconds. A webhook that is not delivered is sent again for about two days, under the same `webhook-id`; a `410` stops it. ## Sandbox Test your integration without touching your live data. Create an API key and pick **Test** as its environment: you get an `om_test_` key, which reaches a sandbox of your own and nothing else. The sandbox is a separate instance, with no rule pack and no network data, so it never sees your live accounts, lists or cases, and a live key never reaches it. - **Keys.** An `om_test_` key has the `fraud` or `fraud_events` scope, for all of the sandbox, and is refused by the moderation API. It counts apart from your live keys against the key limit your top-ups give. Send it the way you send a live key: `Authorization: Bearer om_test_...`. - **Free, with limits.** Each organization may send the sandbox 5 requests a second and 10,000 calls a month (decisions, events and IP lookups, a batch counting each event or address). Past the rate you get `429` `rate_limited`; past the month you get `429` `sandbox_quota_exceeded`, until the 1st of the next month (UTC). Reading cases, lists and accounts is not counted. Nothing is taken from your balance. - **On first use.** The sandbox starts with its first call, which gets `503` `fraud_waking` with `Retry-After: 5`, and is paused after 7 days without a call. The next call wakes it. It keeps its events 30 days. - **Mock tokens.** A device token that starts with `test|` sets the answer, so you can drive every branch of your code without writing a rule: ```json { "type": "session.login", "actor_id": "u_1", "device_token": "test|device:d1|verdict:deny|score:80|ip:BE" } ```
Part Meaning
device the device id, up to 64 characters
verdict allow , challenge (as step_up ), deny (as block ), or any decision name
score 0 to 1,000 (or risk , 0 to 1)
ip a country for the event, such as BE
The answer carries the rule `sandbox_mock`. On your live instance, a `test|` token is `invalid`. - **Webhooks.** The sandbox's webhooks go to a test address you set in the dashboard, under **Fraud prevention** and **Sandbox**, signed with a test secret of their own. Nothing from the sandbox reaches your live address, and nothing from your live instance reaches the test one. The dashboard shows the sandbox's state (off, running, paused), the calls of the month out of 10,000, the test address and its secret. ## A paused instance A free organization's instance is paused after 30 days without a call: its data is kept. The next call wakes it and gets `503` `fraud_waking` with `Retry-After: 5`, and the SDKs answer `allow` meanwhile. An owner or an admin can also resume it from the dashboard. ## Errors These answers come from openshield; every other one is your instance's own, in openfraud's envelope.
Status Code When
400 too_many_ips an ips/query batch holds more than 1,000 addresses
401 unauthorized no key, or a wrong one
403 forbidden the key has no fraud access, or only fraud_events for a route that needs fraud
404 fraud_not_enabled your organization has not turned fraud prevention on
429 quota_exceeded the balance is used up
429 sandbox_quota_exceeded an om_test_ key used the sandbox's 10,000 calls of the month
502 fraud_unavailable your instance did not answer: retry in a few seconds
503 fraud_waking your instance was paused and is starting: retry after Retry-After
503 sandbox_starting an om_test_ key was made a moment ago and the sandbox is not set up yet: retry after Retry-After
# 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 until your first top-up) 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 $OPENSHIELD_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.OPENSHIELD_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 $OPENSHIELD_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 } ], "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 until your first top-up), 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 OPENSHIELD_API_KEY ``` For local development, put it in a `.dev.vars` file that is not committed: ```bash OPENSHIELD_API_KEY=om_live_... ``` ## The Worker ```ts [src/index.ts] export interface Env { OPENSHIELD_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.OPENSHIELD_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 OPENSHIELD_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"` } // 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("OPENSHIELD_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, and it costs what its workflow uses: a text, by the started 1,000 characters. 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 until your first top-up). 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 OPENSHIELD_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 } 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.OPENSHIELD_API_KEY if (!key) throw new Error("OPENSHIELD_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_OPENSHIELD_API_KEY`: ```ts [nuxt.config.ts] export default defineNuxtConfig({ runtimeConfig: { openshieldApiKey: "", // set NUXT_OPENSHIELD_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 { openshieldApiKey } = useRuntimeConfig(event) const result = await $fetch("https://api.openshield.dev/v1/text", { method: "POST", headers: { Authorization: `Bearer ${openshieldApiKey}` }, 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] OPENSHIELD_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.OPENSHIELD_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[OPENSHIELD_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 OPENSHIELD_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['OPENSHIELD_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 OPENSHIELD_API_KEY=om_live_... ``` Read it in the function with `Deno.env.get("OPENSHIELD_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("OPENSHIELD_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
Tier Days kept
Free (never topped up) 7
Tier 1, 2 and 3 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 $OPENSHIELD_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 a check costs, how the prepaid balance works, how fast you may call the API, and what happens when a limit is reached. ## Prices openshield is prepaid: you top up a balance in euros, and each check takes what its workflow costs. There is no subscription. Prices exclude VAT and are the beta prices.
Component Price Unit
Nudity and sexual content EUR 0.001 per image
Visual categories (violence, weapons, drugs, hate symbols, custom categories) EUR 0.001 per image
Text in images EUR 0.002 per image
QR codes EUR 0.001 per image
Text rules and links EUR 0.001 per started 1,000 characters
Text AI models EUR 0.002 per started 1,000 characters
Advanced reading EUR 0.002 per text it reads
An image costs the components its workflow uses: EUR 0.002 for a usual workflow (nudity and visual categories). A text costs its components for each started 1,000 characters: a 300-character text checked by the AI models costs EUR 0.003, and a 2,500-character one EUR 0.009. The advanced reading runs only on a text the first check sends to review. Each workflow shows what it costs per image and per text in the dashboard, next to its save button. The [pricing page](https://openshield.dev/pricing) has a cost estimate. ### Fraud prevention The [fraud prevention API](https://openshield.dev/docs/fraud-prevention) is priced per call, from the same balance. Prices exclude VAT and are the beta prices.
Call Price
Decision ( /fraud/v1/decide ) EUR 0.001
Event ( /fraud/v1/events , each accepted event of a batch) EUR 0.0002
IP lookup ( /fraud/v1/ips/{ip} , each valid address of ips/query ) EUR 0.0002
With the network data on, each decision + EUR 0.0002
With the activity bursts on, each decision + EUR 0.0005
The network data and the activity bursts hold more in your instance's memory, so a decision costs more while they are on. You turn them on and off in the fraud settings of the dashboard. Only a call that ends in a `2xx` costs anything, and a batch costs for the events the instance accepted or the valid addresses it answered for. Cases, accounts, lists, exports and the other routes are free. ## Free use Every organization gets EUR 2.00 of use each month, with every category and the full answer. It is used before what you bought, and what is left of it does not carry over to the next month. It is one allowance for both products: fraud decisions and events draw on it like moderation checks. ## What counts as a check One image or one text is one check. What it costs depends on the components of its workflow, as the table above shows. A request that does not end in an answer, for example one that gets a `400`, a `415` or a `503`, costs nothing. On `/fraud/v1`, a decision is one call to `decide` and an event is one accepted event, alone or in a batch of up to 100. ## Request limits
Limit
Request body 15 MB
Image size 50 megapixels (12 until your first top-up), and 16,384 pixels on a side
Text length 20,000 characters from the second tier (10,000 on the first, 5,000 until your first top-up)
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 tier'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. The rate limit also applies to `/fraud/v1`: a fraud call takes one request from the same bucket as the checks of the organization, with the same burst and rate, and gets `429 rate_limited` when it is empty. The concurrency limit does not apply to fraud calls. A fraud request body is at most 1 MiB. A check waits up to 30 seconds for its turn on the server, then gets `503 busy` with `Retry-After`. Organizations that have never topped up together use at most half of a server's capacity, so the others always keep their share of it. ## Tiers Request limits rise with what you topped up over the last 30 days. Nothing to choose: the tier follows on its own, within the hour.
Free Tier 1 Tier 2 Tier 3
When Never topped up Any top-up EUR 100 in 30 days EUR 500 in 30 days
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 10,000 characters 20,000 characters 20,000 characters
Largest image 12 megapixels 50 megapixels 50 megapixels 50 megapixels
Advanced reading No Yes Yes Yes
## Your balance - **Top-ups.** Top up from the billing page of the dashboard, by card, from EUR 5. A top-up of EUR 100 or more adds 10%, and one of EUR 500 or more adds 20%. Each top-up comes with its invoice, which you find on the billing page. VAT is added where it applies. - **Expiry.** What you top up stays usable for 12 months. The oldest amounts are used first. - **Automatic top-up.** Turn it on from the billing page: when the balance falls under the amount you choose, your saved card is charged the amount you set. A monthly limit caps what automatic top-ups may charge; when it is reached, they pause until the next month and you get an email. If a charge fails, automatic top-ups turn off and you get an email. - **Empty balance.** When nothing is left, the API answers `429 quota_exceeded` until you top up or the next month's free use arrives. `Retry-After` is the number of seconds until the next month. A check already accepted is finished; if it costs more than what was left, the difference is taken from your next top-up or free use. Every response carries `X-Quota-Used` and `X-Quota-Limit`, in micro-euros (millionths of a euro): what the month's checks cost so far, and where the API stops, which is that cost plus your balance. ### Emails about your balance The members who manage billing (the owner and the billing role) get an email at a verified address: - when the balance falls under the amount you set on the billing page; - when the month's use passes the amount you set there; - when an automatic top-up fails, or pauses at its monthly limit. ## Monthly and yearly plans Organizations that subscribed to a monthly or yearly plan before prepaid use keep it until it ends, with its included checks, overage and spending cap. When it ends, the organization moves to prepaid use with its free use for the month. New plans are not sold. ## When you need more The tiers' limits grow with what you top up. If none fits, write to [contact@sway.events](mailto:contact@sway.events). # MCP server > Connect Claude, Cursor, VS Code or your own agent to your openshield organization through the Model Context Protocol, with OAuth and your own role. openshield runs a remote [Model Context Protocol](https://modelcontextprotocol.io) server. An AI agent connected to it can read your organization's usage, decisions, keys, workflows and fraud data, search these docs, moderate a text or an image, and, if you allow it, take a few fraud actions. It acts as the person who connected it, with that person's role, and every call is logged. ```text https://mcp.openshield.dev/mcp ``` To wire openshield into your own code instead, see [AI coding agents](https://openshield.dev/docs/ai-agents). ## Connect an agent The dashboard's **AI agents** page shows the address and these snippets with a copy button. - **Claude Code**: run `claude mcp add --transport http openshield https://mcp.openshield.dev/mcp`, then type `/mcp` to sign in. - **Claude.ai**: in Settings, Connectors, add a custom connector with the address. - **Cursor**: add it to `.cursor/mcp.json` or `~/.cursor/mcp.json`:```json { "mcpServers": { "openshield": { "url": "https://mcp.openshield.dev/mcp" } } } ``` - **VS Code**: add it to `.vscode/mcp.json`:```json { "servers": { "openshield": { "type": "http", "url": "https://mcp.openshield.dev/mcp" } } } ``` The client opens openshield in your browser. You sign in (with your second factor if you have one), pick the organization, and read what the agent asks for before you allow it. The agent never sees your password or your session. ## What the agent may do Three scopes, asked for one by one. An agent starts with `mcp:read` and asks for more only when it needs it, so you approve each step.
Scope What it allows
mcp:read Read the organization, usage, decisions, keys (never a secret), workflows, fraud data, and these docs.
mcp:moderate Check a text or an image against one of your workflows. Billed like any check.
mcp:fraud.write Resolve a case, add, archive or restore list items, snooze or wake a rule. Only when the organization allows writes.
A scope is never more than your role. The agent sees only the tools your role allows: a developer's agent does not see the balance, and a billing member's agent sees no fraud data. If your role changes or you leave the organization, the agent's access follows on its next call. ## Tools
Tool Scope Role permission
get_organization mcp:read any member
get_balance mcp:read billing
get_usage mcp:read usage
list_decisions mcp:read journal
list_keys mcp:read keys
list_workflows , get_workflow mcp:read workflows
search_docs , get_doc_page mcp:read any member
list_fraud_cases , get_fraud_actor , list_fraud_rules , list_fraud_lists , list_fraud_list_items mcp:read fraud read
moderate_text , moderate_image mcp:moderate try workflows
resolve_fraud_case , add_fraud_list_items , archive_fraud_list_items , unarchive_fraud_list_items , snooze_fraud_rule , unsnooze_fraud_rule mcp:fraud.write fraud manage
Every tool says whether it only reads, and the archive and wake tools are marked destructive, so a client that asks before such a call asks you. A tool answers with structured data and a text copy of it. The text reminds the agent that strings in the data (an event attribute, an email, a list item) were written by your users or by third parties, and are data, not instructions. `moderate_text` takes up to 20,000 characters and `moderate_image` an image of up to 5 MB, in base64. They go through the same checks as the API: the workflow, your balance and your limits. The content is checked and kept nowhere, like the dashboard's Try it console. ## Headless agents: an API key An agent that cannot open a browser (a job in CI, a script) can use a live API key with the **MCP server** scope, sent as `Authorization: Bearer om_live_...`. Create it in **API keys**. A key reads only (`mcp:read`): it can never moderate or change anything through the MCP server, and it acts with the role of the person who created it, so only that person may give a key this scope. A key's workflow restriction applies to the API, not to these reads: the key reads what its creator's role reads. Sandbox keys (`om_test_`) are refused. ## Organization settings Owners and admins set, on the **AI agents** page: - whether agents may connect at all (on by default). Off, every agent is refused at once; - whether agents may change fraud data (off by default); - which roles may connect an agent (owner, admin and developer by default). ## Connected agents and the call log The **AI agents** page lists the agents connected to the organization: each member sees their own, owners and admins see every one. **Disconnect** ends the agent's access at once: its tokens stop working and it has to be connected again. Owners and admins also see the call log: each tool call with its time, the agent, the outcome and how long it took. The log never holds what the agent sent or what it got back. It is kept 90 days. Settings changes, disconnections and every fraud action an agent takes are also in the audit log, marked as coming from the MCP server. ## Security - **OAuth 2.1** with PKCE (S256 only), exact redirect addresses, and the `iss` parameter in every answer. Clients register with a Client ID Metadata Document or by dynamic registration. - **Tokens for this server only**: a token is issued for `https://mcp.openshield.dev/mcp` and refused anywhere else. Access tokens last 10 minutes and are checked on every call; refresh tokens last 30 days and change on every use, and a reused one ends the whole connection. - **Consent per organization**: a connection is bound to the organization you picked. To use the agent with another one, connect it again. - **Account recovery**: resetting your password disconnects every agent you connected, in every organization. API keys are not tied to your password: revoke a key with the MCP server scope in **API keys**. - **No browser access**: the server answers only `POST` requests of JSON from programs, one JSON-RPC message per request (no batches). A request from a web page (with an `Origin` header) is refused. - **Limits**: 120 calls a minute per connection or key, and 600 requests a minute per address. A moderation call also counts against the Try it limit of 20 a minute per person. ## Protocol details - Streamable HTTP, stateless, JSON answers. The server speaks MCP `2026-07-28` and `2025-11-25`. - The Protected Resource Metadata (RFC 9728) is at `https://mcp.openshield.dev/.well-known/oauth-protected-resource/mcp`, and a `401` points to it in `WWW-Authenticate`. - The authorization server is `https://openshield.dev/api/auth`; its metadata is at `https://openshield.dev/.well-known/oauth-authorization-server/api/auth`. # 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=$OPENSHIELD_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=$OPENSHIELD_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=$OPENSHIELD_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 until your first top-up). ## 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 [list of sub-processors](https://openshield.dev/legal/dpa#annex-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. Once you have topped up, the [advanced reading](https://openshield.dev/docs/texts#advanced-ai-workflows) also sends 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. ## Fraud prevention data The [fraud prevention API](https://openshield.dev/docs/fraud-prevention) is the exception to "nothing is stored", because a fraud decision needs a history. Each organization has an instance of its own, so its events, accounts and cases never mix with anyone else's. - Events, decisions and cases are kept **30 days** until your first top-up and **90 days** after, then deleted. The sandbox keeps them 30 days. - Email addresses, phone numbers, IP addresses and devices are replaced by keyed pseudonyms before any rule sees them, and the raw values are not stored. The `User-Agent` is read for the browser, the system and the signs of automation, then dropped. - You can export what is kept about one person, and erase it, with one call each (`GET` and `DELETE` on `/fraud/v1/subjects/{id}`). - When you turn fraud prevention off, the instance and its data are erased. ## 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 until your first top-up, 30 days after
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
Fraud events, decisions and cases Only if you turned fraud prevention on. What you report and what was decided, with email addresses, phone numbers, IP addresses and devices as keyed pseudonyms. 30 days until your first top-up, 90 days after
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 fraud prevention instances run on a server in Germany. - 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. The fraud instance's data can be exported and erased person by person at any time, as described above. ## 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 with EUR 2.00 of free use each month and 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 OPENSHIELD_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 $OPENSHIELD_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 $OPENSHIELD_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 } ] }, "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). - Looking to stop fraud on sign-ups, logins and payments instead? That is the [fraud prevention API](https://openshield.dev/docs/fraud-prevention): same account and same balance, with keys that carry fraud access. # 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.
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 until your first top-up), or a side over 16,384 pixels.
text_too_long 413 More than 20,000 characters (5,000 until your first top-up).
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 balance is empty (or, on a monthly or yearly plan, 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 that organizations which never topped up use is: retry after Retry-After .
internal 500 A bug on our side. Quote the request_id when you write to us.
fraud_not_enabled 404 /fraud/v1 only: your organization has not turned fraud prevention on.
fraud_unavailable 502 /fraud/v1 only: your fraud instance did not answer. Retry in a few seconds.
fraud_waking 503 /fraud/v1 only: your paused free instance is starting, in seconds. Retry after Retry-After .
A key without the `fraud` scope gets `forbidden` (403) on the routes that need it, and an empty balance gets `quota_exceeded` (429) on a fraud call too. The other answers of `/fraud/v1` come from your instance, in openfraud's envelope: see [fraud prevention](https://openshield.dev/docs/fraud-prevention#errors). ## 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 your limits allow: 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 until your first top-up).
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" . The advanced reading reads it once you have topped up; before that it is ignored. It is not kept in the journal.
```bash [curl] curl -X POST "https://api.openshield.dev/v1/text" \ -H "Authorization: Bearer $OPENSHIELD_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.OPENSHIELD_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 } ] }, "scores": { "pii_email": 1 }, "time_ms": 4, "request_id": "0192b7c5-1d20-7b3a-8f61-5c9e2a7d4b10", "ref": "comment_9034" } ``` ## Advanced AI workflows Once you have topped up your balance, 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. It costs EUR 0.002 per text it reads, taken from your balance. 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 [list of sub-processors](https://openshield.dev/legal/dpa#annex-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 until your first top-up).
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 tier sets how many workflows you can have: 3 until your first top-up, then 10, 50 or 200 as your top-ups grow (see [limits and quotas](https://openshield.dev/docs/limits-and-quotas#tiers)). 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 $OPENSHIELD_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)