# Recipe: moderate marketplace posts

> Name the rule a listing breaks, rate how severe it is and check for someone else's personal details, then remove clear scams and queue the rest for reviewers.

Every new listing gets checked before buyers see it, not a sample. One request names the rule the post breaks, rates how bad it is and checks whether it exposes someone else's details. Your code removes a post by itself only when the answer is clear, and reviewers see the rest.

## The request

- **`fallback: "never"`.** The hosted fallback runs behind content filters that block exactly this kind of text, so a moderation request that reached it would fail anyway. With `never` the request stays on the GPU path, where no content filter applies, and gets a quick `503` with `retry-after` when no GPU is free. See [Fallback and served_by](/docs/concepts/fallback/#moderation-traffic).
- **State.** The post, plus two facts about the seller that the model reads next to it: the account is 2 days old and has no earlier listings. Compute facts like these in your own code and pass them as fields.
- **`rule` is a `pick`** with `none` as the way out. The two `criteria` settle the edge cases for your marketplace: payment by gift card or outside the checkout is a scam, and sellers may share their own contact details.
- **`severity` is a `rate`** in beta, with no `min_confidence`, so it never abstains. The code below only uses it to order the review queue, never to remove a post.
- **`personal_details` is a `check`** about someone other than the seller, with a floor of 0.6.

```json
{
  "fallback": "never",
  "state": {
    "post": {
      "board": "Phones for sale",
      "title": "Brand new phone, sealed, half price",
      "text": "Sealed flagship phone, still in its box, EUR 250 today only. Pay first with gift cards and I ship the same day. Do not bother with the platform checkout, message me on WhatsApp instead."
    },
    "seller": { "account_age_days": 2, "earlier_listings": 0 }
  },
  "questions": {
    "rule": {
      "type": "pick",
      "instructions": "Which marketplace rule does {{post.text}} break, if any?",
      "criteria": [
        "Asking for payment by gift card or outside the platform checkout counts as a scam.",
        "Sharing your own contact details in your own listing is allowed."
      ],
      "options": {
        "none": "Breaks no rule",
        "scam": "Fraud, fake offers or payment outside the platform",
        "harassment": "Threats, intimidation or exposing someone's personal details",
        "spam": "Repeated or unsolicited promotion",
        "hate": "Attacks people for a protected characteristic"
      },
      "min_confidence": 0.6
    },
    "severity": {
      "type": "rate",
      "instructions": "How severe is the worst problem in {{post.text}}?",
      "levels": ["None", "Mild", "Serious", "Severe"]
    },
    "personal_details": {
      "type": "check",
      "instructions": "Does {{post.text}} reveal personal details of someone other than the seller, such as a home address or phone number?",
      "min_confidence": 0.6
    }
  }
}
```

## Run it

Put a test key in `DEX_API_KEY` (see the [Quickstart](/docs/quickstart/#get-a-key-in-60-seconds)). Save the Python code as `moderation.py` and run `python moderation.py`. Save the TypeScript code as `moderation.mts` and run `npx tsx moderation.mts`: the code uses `await` at the top level, and the `.mts` ending makes the file an ES module. Install the SDKs from [Downloads](/docs/reference/sdks/#downloads).

```bash tab="curl"
curl https://api.thinqit.ai/v1/decide \
  -H "authorization: Bearer $DEX_API_KEY" \
  -H "content-type: application/json" \
  --data-binary @- <<'DEX_REQUEST'
{
  "fallback": "never",
  "state": {
    "post": {
      "board": "Phones for sale",
      "title": "Brand new phone, sealed, half price",
      "text": "Sealed flagship phone, still in its box, EUR 250 today only. Pay first with gift cards and I ship the same day. Do not bother with the platform checkout, message me on WhatsApp instead."
    },
    "seller": { "account_age_days": 2, "earlier_listings": 0 }
  },
  "questions": {
    "rule": {
      "type": "pick",
      "instructions": "Which marketplace rule does {{post.text}} break, if any?",
      "criteria": [
        "Asking for payment by gift card or outside the platform checkout counts as a scam.",
        "Sharing your own contact details in your own listing is allowed."
      ],
      "options": {
        "none": "Breaks no rule",
        "scam": "Fraud, fake offers or payment outside the platform",
        "harassment": "Threats, intimidation or exposing someone's personal details",
        "spam": "Repeated or unsolicited promotion",
        "hate": "Attacks people for a protected characteristic"
      },
      "min_confidence": 0.6
    },
    "severity": {
      "type": "rate",
      "instructions": "How severe is the worst problem in {{post.text}}?",
      "levels": ["None", "Mild", "Serious", "Severe"]
    },
    "personal_details": {
      "type": "check",
      "instructions": "Does {{post.text}} reveal personal details of someone other than the seller, such as a home address or phone number?",
      "min_confidence": 0.6
    }
  }
}
DEX_REQUEST
```

```python tab="Python"
# Save as dex_request.py, then run: python dex_request.py
import json

from thinqit_dex import Client

client = Client()  # reads DEX_API_KEY from the environment

request = json.loads(r'''
{
  "fallback": "never",
  "state": {
    "post": {
      "board": "Phones for sale",
      "title": "Brand new phone, sealed, half price",
      "text": "Sealed flagship phone, still in its box, EUR 250 today only. Pay first with gift cards and I ship the same day. Do not bother with the platform checkout, message me on WhatsApp instead."
    },
    "seller": { "account_age_days": 2, "earlier_listings": 0 }
  },
  "questions": {
    "rule": {
      "type": "pick",
      "instructions": "Which marketplace rule does {{post.text}} break, if any?",
      "criteria": [
        "Asking for payment by gift card or outside the platform checkout counts as a scam.",
        "Sharing your own contact details in your own listing is allowed."
      ],
      "options": {
        "none": "Breaks no rule",
        "scam": "Fraud, fake offers or payment outside the platform",
        "harassment": "Threats, intimidation or exposing someone's personal details",
        "spam": "Repeated or unsolicited promotion",
        "hate": "Attacks people for a protected characteristic"
      },
      "min_confidence": 0.6
    },
    "severity": {
      "type": "rate",
      "instructions": "How severe is the worst problem in {{post.text}}?",
      "levels": ["None", "Mild", "Serious", "Severe"]
    },
    "personal_details": {
      "type": "check",
      "instructions": "Does {{post.text}} reveal personal details of someone other than the seller, such as a home address or phone number?",
      "min_confidence": 0.6
    }
  }
}
''')

decision = client.decide(
    request["state"],
    request["questions"],
    fallback=request["fallback"],
)
for question_id, answer in decision.answers.items():
    print(question_id, answer)
```

```ts tab="TypeScript"
// Save as dex-request.mts, then run: npx tsx dex-request.mts (Node.js 18 or newer)
import { Client, parseRequest } from "@thinqit/dex";

const client = new Client(); // reads DEX_API_KEY from the environment

// parseRequest keeps the key order of the text (JSON.parse would move labels such as "1" to the front).
const request = parseRequest(`{
  "fallback": "never",
  "state": {
    "post": {
      "board": "Phones for sale",
      "title": "Brand new phone, sealed, half price",
      "text": "Sealed flagship phone, still in its box, EUR 250 today only. Pay first with gift cards and I ship the same day. Do not bother with the platform checkout, message me on WhatsApp instead."
    },
    "seller": { "account_age_days": 2, "earlier_listings": 0 }
  },
  "questions": {
    "rule": {
      "type": "pick",
      "instructions": "Which marketplace rule does {{post.text}} break, if any?",
      "criteria": [
        "Asking for payment by gift card or outside the platform checkout counts as a scam.",
        "Sharing your own contact details in your own listing is allowed."
      ],
      "options": {
        "none": "Breaks no rule",
        "scam": "Fraud, fake offers or payment outside the platform",
        "harassment": "Threats, intimidation or exposing someone's personal details",
        "spam": "Repeated or unsolicited promotion",
        "hate": "Attacks people for a protected characteristic"
      },
      "min_confidence": 0.6
    },
    "severity": {
      "type": "rate",
      "instructions": "How severe is the worst problem in {{post.text}}?",
      "levels": ["None", "Mild", "Serious", "Severe"]
    },
    "personal_details": {
      "type": "check",
      "instructions": "Does {{post.text}} reveal personal details of someone other than the seller, such as a home address or phone number?",
      "min_confidence": 0.6
    }
  }
}`);

const decision = await client.decide(request);
console.log(decision.answers);
```

## Expected output

```json
{
  "id": "req_01M3JE24DES9WY9MBW6EXX490X",
  "object": "decision",
  "created": 1790546350,
  "model": "dex-1.0.1",
  "served_by": "gpu",
  "calibration": "cal-20260926-1",
  "answers": {
    "rule": {
      "type": "pick",
      "choice": "scam",
      "probabilities": {
        "none": 0.0296,
        "scam": 0.913,
        "harassment": 0.0274,
        "spam": 0.0162,
        "hate": 0.0138
      },
      "confidence": 0.8833,
      "abstained": false
    },
    "severity": {
      "type": "rate",
      "rating": 2.1249,
      "levels": ["None", "Mild", "Serious", "Severe"],
      "probabilities": [0.034, 0.1727, 0.4276, 0.3657],
      "confidence": 0.4589,
      "abstained": false
    },
    "personal_details": {
      "type": "check",
      "probability": 0.0752,
      "confidence": 0.8497,
      "abstained": false
    }
  },
  "usage": {
    "input_tokens": 232,
    "state_tokens": 86,
    "question_tokens": 146,
    "allowance_tokens": 0,
    "paid_tokens": 0,
    "charge_micro_cents": 0,
    "unit_price_micro_cents": 0,
    "tier": "test"
  }
}
```

Captured from the live API on 2026-09-27 with a test key: model `dex-1.0.1`, calibration `cal-20260926-1`, `served_by: gpu`, 232 input tokens (86 for the state, 146 for the questions). A test key is charged nothing, so `tier` is `test` and the charge is 0. On this exact version the same request always returns these answers.

| Question | Type | Answer | Confidence | `min_confidence` | Abstained |
| --- | --- | --- | --- | --- | --- |
| `rule` | pick | `scam` (0.913) | 0.8833 | 0.6 | no |
| `severity` | rate | rating 2.1249, most likely `Serious` (0.4276) | 0.4589 | none | no |
| `personal_details` | check | yes with probability 0.0752 | 0.8497 | 0.6 | no |

The post breaks the `scam` rule with probability 0.913. The severity rating of 2.1249 sits between `Serious` and `Severe`, and its confidence of 0.4589 shows how spread out it is. The post exposes no one else's details (0.0752), so the scam is the only problem.

## Act on it

`post_id` and the functions `queue_for_review`, `remove_post` and `set_review_priority` stand for your own code. A post is removed without a reviewer only when one rule has a probability of 0.9 or more. Here `scam` has 0.913, so the post comes down at once, and its review gets a high priority because the severity rating is above 2.

```python tab="Python"
rule = decision.pick("rule")
if rule.abstained:
    queue_for_review(post_id, "unsure")  # two rules are close: a reviewer decides
elif rule.choice != "none" and rule.probabilities[rule.choice] >= 0.9:
    remove_post(post_id, rule.choice)  # a clear case: remove now, review later
elif rule.choice != "none":
    queue_for_review(post_id, rule.choice)

severity = decision.rate("severity")
if not severity.abstained and severity.rating >= 2:
    set_review_priority(post_id, "high")

details = decision.check("personal_details")
if details.abstained or details.probability >= 0.5:
    queue_for_review(post_id, "personal details")
```

```ts tab="TypeScript"
const { rule, severity, personal_details: details } = decision.answers;

if (rule?.type === "pick") {
  if (rule.abstained) queueForReview(postId, "unsure"); // two rules are close: a reviewer decides
  else if (rule.choice !== "none" && rule.probabilities[rule.choice] >= 0.9) removePost(postId, rule.choice); // a clear case
  else if (rule.choice !== "none") queueForReview(postId, rule.choice);
}
if (severity?.type === "rate" && !severity.abstained && severity.rating >= 2) setReviewPriority(postId, "high");
if (details?.type === "check" && (details.abstained || details.probability >= 0.5)) queueForReview(postId, "personal details");
```

An abstained `personal_details` answer sends the post to a reviewer: that is the safe route, not an action on the answer. As in the [support recipe](/docs/cookbook/support-routing/#act-on-it), the TypeScript answers have the general `Answer` type, so the code narrows each one on `type`.

## Adapt it

- **Set the removal bar on your own posts.** Run a few hundred posts your reviewers already judged with a test key, and raise the bar until automatic removals are right often enough. Content moderation is the weakest domain in our behaviour suite, so review removals too. See [Known limits](/docs/concepts/known-limits/#also-measured).
- **Your own rules.** Add an option per rule (up to 255) and keep `none`. Write each edge case as a criterion in English.
- **Sellers write the text you judge.** Text in the state can try to steer the answer. Add a guardrail check as shown in [Known limits](/docs/concepts/known-limits/#instructions-inside-the-state-can-steer-answers) and send those posts to a reviewer.
- **Pin a version** when removals must not shift between runs; an exact version also never uses the fallback. See [Determinism](/docs/concepts/determinism/#scope).
