Source: https://help.spamfoo.com/api/classify
Last updated: 2026-10-27

***

# Classify a Message

Send a raw message to get a spam verdict and an Email Classification in one call. This is the endpoint a mail server calls while it delivers mail.

`POST /classify-stream`

## When to Use It

- Use it for every incoming message you want SpamFoo to judge.
- To get only an Email Classification with no spam or phishing checks, use [Classify Without Spam Checks](https://help.spamfoo.com/api/classify-tabs).

SpamFoo Client records each verdict for the admin console. It also caches verdicts: the same message sent again with the same headers returns the cached verdict until a correction clears the cache.

## Request

The body is the raw message (raw .eml content), sent unchanged. See [Send the Message in the Request Body](https://help.spamfoo.com/api#request-style).

| Header | Required | Type | Description |
| --- | --- | --- | --- |
| `Content-Type` | Recommended | string | `message/rfc822` |
| `X-SpamFoo-Proxy-Auth` | In standalone mode, or when not loopback | string | The proxy secret. See [Authenticate Requests](https://help.spamfoo.com/api#authentication). |
| `X-User-ID` | No | string | The recipient mailbox, usually a full email address. Turns on the user's own corrections and mailbox-level allow and block rules. |
| `X-Sender-IP` | No | IPv4 or IPv6 | IP address of the server that delivered the message to you. Used for SPF and IP reputation. If you can't send it, see `relays` in [Share Your Authentication Results](https://help.spamfoo.com/api/classify#boundary). |
| `X-Sender-Email` | No | email address | Envelope sender (SMTP `MAIL FROM`). Used for authentication checks. Allow and block rules are only checked when this header is present. |
| `X-Recipient-Email` | No | email address | Envelope recipient (SMTP `RCPT TO`). Used for domain-level allow and block rules and for protected identity checks. |
| `X-SpamFoo-Classify` | No | `true` or `false` | Send `false` to skip Email Classification. See [Skip Email Classification](https://help.spamfoo.com/api/classify#skip-classification). |
| `X-SpamFoo-Log-ID` | No | string, up to 64 characters | Your own ID for the message, written to the classification log. Letters, digits, and `. _ : -` only; other values are ignored. |
| `X-SpamFoo-Priority` | No | `bulk` | Marks work that isn't live delivery, such as sorting old mail. See [Mark Bulk Work](https://help.spamfoo.com/api/classify#priority). |
| `X-SpamFoo-Deadline-Ms` | No | integer, 1 to 600000 | How many milliseconds you'll wait for the verdict. See [Set a Deadline](https://help.spamfoo.com/api/classify#deadline). |
| `X-SpamFoo-Recipient` | No | `<address>[; user=<id>]`, repeatable | One recipient of the message, to check it once for all its recipients. See [Check Several Recipients at Once](https://help.spamfoo.com/api/classify#recipients). |
| `X-SpamFoo-Personalize` | No | `per-recipient` | Adds each recipient's own verdict. Used with `X-SpamFoo-Recipient`. See [Check Several Recipients at Once](https://help.spamfoo.com/api/classify#recipients). |
| `X-SpamFoo-Scan` | No | `keep` | Asks for a `scanId` that later calls for each mailbox can reuse. See [Reuse a Scan for Each Mailbox](https://help.spamfoo.com/api/classify#scan-references). |
| `X-SpamFoo-Scan-Id` | No | string | A `scanId` from an earlier answer for the same message. See [Reuse a Scan for Each Mailbox](https://help.spamfoo.com/api/classify#scan-references). |
| `X-SpamFoo-Boundary` | No | string | Names the `Authentication-Results` headers your mail server added, so SpamFoo Client can trust them. See [Share Your Authentication Results](https://help.spamfoo.com/api/classify#boundary). |
| `X-SpamFoo-Auth-Results` | No | string | Your mail server's SPF, DKIM, and DMARC results for this message. See [Share Your Authentication Results](https://help.spamfoo.com/api/classify#boundary). |
| `X-SpamFoo-Caller` | No | `<name>/<version>` | Names your integration and its version, such as `mymailserver/2.1`. Use a name that is unique to your integration. It tells SpamFoo Client's logs which integration sent a request, which makes problems easier to trace and helps SpamFoo support. The name is 1 to 32 characters from `a-z 0-9 . _ -`, and the version is 1 to 32 characters from `A-Z a-z 0-9 . _ + -`. |

SpamFoo Client reads `X-SpamFoo-Boundary` and `X-SpamFoo-Auth-Results` only from requests that send the correct proxy secret, even from loopback. `X-SpamFoo-` headers inside the message itself are never read as instructions, so a sender can't use them to change the result.

### Skip Email Classification

Send `X-SpamFoo-Classify: false` when you only need the spam verdict. Spam and phishing checks still run.

- The response has the same fields, but `classification` is an empty string and `tabsProbs` is an empty array, even for spam and phishing. Use `isSpam`, `bypassReason`, and `phishingReason` instead.
- Values are case-insensitive. Any value other than one `true` or `false` returns `400`.
- If the license includes Email Classification but not spam detection, the request returns `403` with `"features": ["spam"]`.

### Mark Bulk Work

Send `X-SpamFoo-Priority: bulk` when the request isn't part of live mail delivery, such as when you classify the messages already in a mailbox. Bulk requests have their own, smaller limit on how many can be held at once (one less than Max Concurrency, and at least 1), so they can't crowd out live mail.

- When the bulk limit is full, the request gets `503` with `"code": "server_busy"`, `"priority": "bulk"`, and `Retry-After: 2`. Wait and send it again.
- Any other value, or no header, is live delivery.
- The `bulkIntake` counts in [Check Health](https://help.spamfoo.com/api/health#health) show how the bulk limit is used.

### Set a Deadline

Send `X-SpamFoo-Deadline-Ms` when your process stops waiting after a set time, such as an SMTP session timeout. The time counts from when SpamFoo Client reads the header, so time spent sending the message counts against it.

- When SpamFoo Client expects the wait for a free classification slot to be longer than the time left, it answers right away with `503` and `"code": "deadline_exceeded"`. It also stops waiting when the deadline passes in the queue, and skips work for a caller that has disconnected.
- The body is `{"error": "Classification deadline exceeded", "code": "deadline_exceeded", "deadlineMs": 5000, "queueDepth": 8}` with `Retry-After: 1`. Deliver the message normally.
- Once classification starts, it runs to the end. The deadline covers only the wait.
- The value is a whole number of milliseconds from 1 to 600000. Any other value, or the header sent more than once, returns `400`.
- Without the header, nothing changes. A wait that reaches the normal queue timeout first still returns `queue_timeout`.

### Check Several Recipients at Once

A mail server that checks a message during the SMTP session sees all of its recipients at once. Send one `X-SpamFoo-Recipient` header per recipient, or one header with a comma-separated list, to check the message once for all of them.

- Each value is a recipient address. Add `; user=<id>` when that mailbox's user ID isn't the address, such as `X-SpamFoo-Recipient: sales@example.com; user=ann@example.com`. A value without a valid address returns `400`.
- The models run once. The top-level answer is the verdict without any user's corrections or mailbox rules, so `X-User-ID` and `X-Recipient-Email` are ignored on these calls.
- The answer adds `recipients`, with one entry per recipient, and `recipientsTruncated`. Only the first 100 recipients are used. When there are more, `recipientsTruncated` is `true`, so don't treat the list as complete.
- Each entry's `userId` is the `user=` value, or else the address, changed to the owning user when your [address map](https://help.spamfoo.com/api/address-map) lists it as an alias.
- Each entry's `rule` says whether an allow or block rule applies to the sender for that recipient: `action` is `allow` or `block`, and `scope` is `mailbox` or `domain` for rules an administrator set, or `user` for the user's own spam rule. It's `null` when no rule applies.
- Send `X-SpamFoo-Personalize: per-recipient` to add each recipient's own `verdict` and `stampHeaders`, with that user's corrections and rules applied. Without `X-SpamFoo-Recipient` it has no effect. Any other value returns `400`.
- When recipients get different verdicts, how to act is up to you. Per-recipient verdicts aren't recorded for the admin console. Only a call for one mailbox, with `X-User-ID`, is.

```
"recipients": [
    {
        "address": "ann@example.com",
        "userId": "ann@example.com",
        "rule": { "action": "block", "scope": "user" },
        "verdict": { ... },
        "stampHeaders": [ ... ]
    }
],
"recipientsTruncated": false
```

### Reuse a Scan for Each Mailbox

A mail server that checks a message once during the SMTP session, and then again for each mailbox at delivery, can skip most of the second check:

1. Send the first request without `X-User-ID`, and add `X-SpamFoo-Scan: keep`. The answer includes a `scanId`.
2. For each mailbox, send the same message with `X-User-ID` and `X-SpamFoo-Scan-Id` set to that `scanId`.

- When SpamFoo Client still holds the scan, it runs only that user's steps: impersonation checks for their domain, their rules and corrections, and their classification corrections. The answer adds `"scanReused": true`. Otherwise it checks the message in full, as if the header were absent.
- `X-SpamFoo-Scan` is ignored on a request with `X-User-ID`. Any value other than `keep` returns `400`.
- The answer can leave out `scanId`, such as when a rule decided the message or the license doesn't include spam detection. Classify each mailbox normally then.
- Scans are kept in memory for up to 15 minutes, and a restart drops them. SpamFoo Client can drop the oldest sooner when it holds many.
- A scan is tied to the message body, not its headers. Headers your mail server adds on the way to the mailbox, and LF line endings in place of CRLF, don't break it. A `scanId` sent with a different message only causes a full check.
- A reused result keeps the scores the message got in the first scan, including the phishing detection model's score.
- The first scan counts toward the admin console statistics and sender reputation. Results built on it are recorded for each mailbox but not counted again.

### Share Your Authentication Results

If your mail server already checks SPF, DKIM, and DMARC (the checks that confirm a message came from the domain it claims), it can tell SpamFoo Client its results. SpamFoo Client then treats those results as trusted, and can skip its own DNS lookups when your results are clear. Without these headers, SpamFoo Client doesn't trust any `Authentication-Results`, `ARC-Authentication-Results`, `Received-SPF` or `X-SmarterMail-` header in the message, because a sender can add one. It runs its own checks instead when the request has `X-Sender-IP` and `X-Sender-Email`, and otherwise reports authentication as unknown.

- **`X-SpamFoo-Boundary: authserv-id=<id>`**, such as `X-SpamFoo-Boundary: authserv-id=mx.example.com`. Your mail server adds `Authentication-Results` headers to the top of each message, using `<id>` as its authserv-id (the name at the start of the header value, usually the server's host name). SpamFoo Client trusts the run of `Authentication-Results` headers with that id at the top of the message. It stops at the first header with another id or at the next `Received` header. The id is up to 255 characters from `A-Z a-z 0-9 . - _ :`.
- **`X-SpamFoo-Boundary: authserv-id=<id>; relays=<cidr>,...`**, such as `authserv-id=mx.example.com; relays=172.22.1.0/24,fd4d:6169:6c63:6f77::/64`. Use this when you can't send `X-Sender-IP`. List your own mail hosts (up to 16 IPv4 or IPv6 ranges or addresses). When the request has no `X-Sender-IP`, SpamFoo Client reads the `Received` headers from the top, skips each hop that came from one of your relays, and uses the IP of the first hop from outside them as the sender IP. Your mail server must add the topmost `Received` header. If SpamFoo Client can't read a trustworthy IP, the message is classified with no sender IP. This works on `/classify-stream` only.
- **`X-SpamFoo-Boundary: none`**. Trust no authentication results in the message.
- **`X-SpamFoo-Auth-Results`**. Your results for this message in the `Authentication-Results` format, such as `spf=pass smtp.mailfrom=example.com; dkim=pass header.d=example.com; dmarc=pass header.from=example.com`. It must include at least one `spf`, `dkim`, or `dmarc` result, and be at most 2048 characters. These results take the place of the ones in the message. You can send it with or without `X-SpamFoo-Boundary`.

A value SpamFoo Client can't read is treated as `none`, and the request is still classified. Send each header at most once. These headers change which authentication results SpamFoo Client trusts, and so `senderAuthenticated` and the three auth scores in the result. They don't change how the models read the message.

## What SpamFoo Client Checks

SpamFoo Client stops at the first check that decides the message. `bypassReason` in the response tells you which one it was.

1. Allow and block rules an administrator set for the recipient's mailbox or domain.
2. Whether the message can be parsed. A message that cannot be parsed is treated as spam.
3. Phishing checks: links to known phishing sites, impersonation of protected people (when the license includes SpamFoo Anti-Phishing), and sender domains that misspell well-known domains.
4. The user's own past spam and not-spam corrections, when `X-User-ID` is sent.
5. SpamFoo's permanent allow and block entries for the sender's domain or IP address.
6. The global spam models. For mail that is not spam, the Email Classification model, or the user's own classification corrections.
7. The Anti-Phishing model, when the license includes SpamFoo Anti-Phishing and an administrator has not turned it off.

Allow and block rules that apply to all mail are checked after the models run. A system-wide allow rule gives way when the user's own corrections for the same sender say the message is spam. See [Manage Spam Rules and Senders](https://help.spamfoo.com/guides/rules-protection) and [Protect Against Phishing](https://help.spamfoo.com/guides/phishing-protection).

## How the License Changes the Result

| License Includes | What Runs | Result |
| --- | --- | --- |
| Spam detection and Email Classification | Everything | Full result. |
| Spam detection only | Spam and phishing checks | `classification` is `spam` or `phishing` for spam, and empty otherwise. `tabsProbs` is empty. |
| Email Classification only | Email Classification, plus allow and block rules | No spam or phishing checks run. `isSpam` is `false` and the spam scores are `0`, unless an allow or block rule decided the message. `confidenceMargin` holds the classification confidence. |
| License expired | Nothing | `403` with `"error": "feature_not_licensed"` until a valid license is active. |

See [Use Classification Only](https://help.spamfoo.com/integrations/classification-only) for an integration built on a classification-only license.

## Response

A successful request returns `200` with a JSON object. This example is a message that is not spam:

```
{
    "isSpam": false,
    "classification": "transactions",
    "spamProbability": 0.031,
    "displayConfidence": 0.027,
    "spamLevel": null,
    "textSpamScore": 0.052,
    "metaSpamScore": 0.018,
    "confidenceMargin": 0.469,
    "tabsProbs": [0.08, 0.86, 0.04, 0.02],
    "entropy": 0.138,
    "disagreement": 0.034,
    "shouldEscalate": false,
    "bypassReason": null,
    "spamBypassReason": null,
    "knnThresholdShift": 0,
    "reputationBypass": false,
    "phishingReason": null,
    "phishingScore": null,
    "phishingThreatType": null,
    "phishingEncoderVersion": null,
    "senderAuthenticated": true,
    "spfScore": 1,
    "dkimScore": 1,
    "dmarcScore": 1,
    "attribution": "FromDmarc",
    "dmarcRecord": "RecordPresent",
    "attributedFromDomain": "example.com",
    "attributedEnvelopeDomain": null,
    "envelopeAttribution": "EnvelopeSpf",
    "verdict": {
        "spam": false,
        "spamLevel": null,
        "classification": "transactions",
        "confidence": 0.973,
        "personalized": false,
        "decidedBy": "model",
        "phishing": null
    },
    "stampHeaders": [
        ["X-SpamFoo-Status", "ok"],
        ["X-SpamFoo-Spam", "no"],
        ["X-SpamFoo-Classification", "transactions"],
        ["X-SpamFoo-Confidence", "0.97"],
        ["X-SpamFoo-Personalized", "no"]
    ]
}
```

`verdict` and `stampHeaders` are the simplest fields to act on. See [Verdict](https://help.spamfoo.com/api/classify#verdict). Ignore any field you don't know, because new fields can be added.

| Field | Type | Description |
| --- | --- | --- |
| `isSpam` | boolean | The spam verdict. Base delivery decisions on this field or on `verdict`. |
| `classification` | string | For mail that is not spam: `primary`, `transactions`, `updates`, or `promotions`. For spam: `spam` or `phishing`. Empty when no Email Classification ran, such as when an allow or block rule decided the message. Compare values without regard to case. |
| `spamProbability` | number | Spam score from 0 to 1. The spam threshold is not 0.5, so use `isSpam` for the verdict. When a check other than the global models decided, this is that check's own score, such as `1` for a block rule. |
| `displayConfidence` | number or null | `spamProbability` rescaled so the threshold sits at 0.5: spam is 0.5 or higher and other mail is 0.5 or lower. Use it when you show a score to people. `1` for the known-site, impersonation, and lookalike-domain phishing checks, and `null` when any other check decided. |
| `spamLevel` | string or null | The global models' own spam grade, such as `Spam-High`. `null` when another check decided. Read `verdict.spamLevel` instead. |
| `textSpamScore` | number | Score from the model that reads the message text. |
| `metaSpamScore` | number | Score from the model that reads headers, authentication, and other metadata. |
| `confidenceMargin` | number | When the global models decide, how far `spamProbability` is from 0.5. Other checks set their own value, such as `1` for a rule. |
| `tabsProbs` | array of numbers | Email Classification probabilities in the order primary, transactions, updates, promotions. Empty when no Email Classification ran. |
| `entropy` | number | How uncertain the spam score is. Higher means less certain. |
| `disagreement` | number | Difference between `textSpamScore` and `metaSpamScore`. |
| `shouldEscalate` | boolean | `true` when the global models were unsure or disagreed. You can use it to flag a message for review. |
| `bypassReason` | string or null | Which check decided the message when it was not the global models. See [Bypass Reasons](https://help.spamfoo.com/api/classify#bypass-reasons). |
| `spamBypassReason` | string or null | Set when the user's corrections or a SpamFoo permanent allow entry let the message through. It keeps that reason even when `bypassReason` shows `tabs_knn`. `null` otherwise. |
| `knnThresholdShift` | number | How far the user's past corrections moved the spam threshold without deciding the message on their own. Negative leans toward spam, positive toward not spam, `0` when unused. |
| `reputationBypass` | boolean | `true` when a SpamFoo permanent allow or block entry decided the message. |
| `phishingReason` | array of strings or null | Why the message is phishing: `phishtank_match`, `identity_impersonation`, `identity_impersonation_lookalike`, or `domain_typosquat`. When the Anti-Phishing model flags a message, this is `["identity_impersonation_lookalike"]`. |
| `phishingScore` | number or null | Score from the Anti-Phishing model. `null` when the model did not run. |
| `phishingThreatType` | string or null | The Anti-Phishing model's threat type: `PHISHING`, `SCAM`, `UNWANTED`, or `NONE`. |
| `phishingEncoderVersion` | string or null | Name of the Anti-Phishing model that scored the message. |
| `senderAuthenticated` | boolean | `true` when DMARC passed, or when the domain has no DMARC result and SPF passed for a Return-Path that matches the From domain. `false` when authentication was not checked. Based only on trusted results and SpamFoo Client's own checks (see [Share Your Authentication Results](https://help.spamfoo.com/api/classify#boundary)). |
| `spfScore` `dkimScore` `dmarcScore` | number or null | Authentication results: `1` pass, `0.5` neutral or no record, `0.3` softfail, `0.2` permerror, `0` fail. `null` when not checked. Like `senderAuthenticated`, they come only from trusted results and SpamFoo Client's own checks. |
| `attribution` `dmarcRecord` `attributedFromDomain` `attributedEnvelopeDomain` `envelopeAttribution` | string or null | Which sender domain SpamFoo Client proved, and how. SpamFoo uses these for domain reputation. You don't need them to act on the verdict. |
| `verdict` | object | The verdict in a short, stable form. See [Verdict](https://help.spamfoo.com/api/classify#verdict). |
| `stampHeaders` | array of [name, value] pairs | Headers you can add to the delivered message. See [Stamped Headers](https://help.spamfoo.com/api/classify#stamp-headers). |
| `scanId` | string | Only with `X-SpamFoo-Scan: keep`. See [Reuse a Scan for Each Mailbox](https://help.spamfoo.com/api/classify#scan-references). |
| `scanReused` | boolean | `true` when the result was built on a kept scan. Left out otherwise. |
| `recipients` `recipientsTruncated` | array, boolean | Only with `X-SpamFoo-Recipient`. See [Check Several Recipients at Once](https://help.spamfoo.com/api/classify#recipients). |

### Verdict

All `verdict` values are lowercase, so you can compare them as plain text.

| Field | Type | Description |
| --- | --- | --- |
| `spam` | boolean | Same as `isSpam`. |
| `spamLevel` | string or null | `low`, `medium`, or `high` when `spam` is `true`, otherwise `null`. See [Spam Levels](https://help.spamfoo.com/api/classify#spam-levels). |
| `classification` | string or null | `primary`, `transactions`, `updates`, `promotions`, `spam`, or `phishing`. `null` when no Email Classification ran on mail that is not spam. |
| `confidence` | number | How sure SpamFoo Client is of the verdict it gave, from 0 to 1, for spam and not spam alike. `1` for rules. |
| `personalized` | boolean | `true` when the user's own rules or corrections took part in the result. |
| `decidedBy` | string | `model`, `user_rule`, `user_memory` (the user's corrections), `system_rule` (an allow or block rule from the admin console), `reputation`, `phishing`, `parse_error`, or `other`. |
| `phishing` | object or null | `{"score": ..., "threatType": ...}` from the phishing detection model, or `null` when it didn't run. |

### Stamped Headers

`stampHeaders` lists headers you can add to the copy of the message you deliver, so mail filters and Sieve scripts can act on the verdict. Add them in the order given. Remove any `X-SpamFoo-` headers that arrived with the message first. Otherwise a sender could write `X-SpamFoo-Spam: no` and fool a filter.

| Header | Values |
| --- | --- |
| `X-SpamFoo-Status` | `ok`. When your integration couldn't classify a message, it can write its own value, such as `skipped; reason=timeout`. |
| `X-SpamFoo-Spam` | `yes` or `no`. |
| `X-SpamFoo-Spam-Level` | `low`, `medium`, or `high`. Left out when the message is not spam. |
| `X-SpamFoo-Classification` | The value of `verdict.classification`. Left out when it's `null`. |
| `X-SpamFoo-Confidence` | `verdict.confidence` with two decimals, such as `0.97`. |
| `X-SpamFoo-Personalized` | `yes` or `no`. |

### Spam Levels

SpamFoo Client grades each spam verdict, so every mail server sees the same grade for the same message. The grade says how strict a spam cutoff the message still counts as spam at:

- **`high`:** spam even at the strictest cutoff. Spam decided by something other than the global models, such as a rule, the user's corrections, a SpamFoo permanent block entry, or a phishing check, is also `high`.
- **`medium`:** spam at the normal cutoff, but not the strictest one.
- **`low`:** spam only at the loosest cutoff.

The **Spam Sensitivity** setting on **System > Settings** decides which grades count as spam at all. **Conservative** marks only `high` as spam, **Recommended** marks `medium` and `high`, and **Aggressive** marks all three. A message that is not spam has no grade.

SpamFoo Client only grades. Your integration decides what to do with each grade. A recommended starting point:

| Result | Recommended Starting Action |
| --- | --- |
| Not spam | Deliver. Add the stamped headers only. |
| `low` | Deliver to the inbox with a tag. Don't file to Junk on this grade alone. |
| `medium` | File to Junk. |
| `high` | File to Junk. If the administrator opts in, your mail server can treat it as its own highest spam level. |
| `phishing` classification | File to Junk. SpamFoo doesn't yet recommend rejecting phishing during the SMTP session. |

### Bypass Reasons

| Value | Meaning |
| --- | --- |
| `null` | The global models decided. |
| `system_override` | An allow or block rule from the admin console decided. `classification` is empty. |
| `parse_error` | The message could not be parsed, so it was marked as spam. `classification` is `Spam`. |
| `phishtank_match` | The message links to a known phishing site. |
| `identity_impersonation` | The sender pretends to be a protected person from outside that person's real domains. |
| `identity_impersonation_lookalike` | The sender pretends to be a protected person from a domain that misspells or imitates one of that person's real domains. |
| `domain_typosquat` | The sender's domain is a misspelling of a well-known domain. |
| `knn_memory` | The user's past corrections for messages from the same sender decided. |
| `knn_memory_cross_sender` | The user's past corrections of very similar messages from other senders decided. |
| `reputation_allow` | A SpamFoo permanent allow entry for the sender's IP address or authenticated domain. The spam models were skipped. |
| `reputation_block` | A SpamFoo permanent block entry for the sender's domain or IP address. |
| `tabs_knn` | The user's past classification corrections set the Email Classification. Check `spamBypassReason` for what decided spam or not spam. |
| `phishing_encoder` | The Anti-Phishing model flagged the message as phishing. |

## Errors

| Status | Body | Cause |
| --- | --- | --- |
| `400` | `{"error": "No email content provided"}` | The body was empty. |
| `400` | `{"error": "X-SpamFoo-Classify must be true or false"}` | The header had another value, or was sent more than once. |
| `400` | `{"error": "X-SpamFoo-... must be ..."}` | An `X-SpamFoo-Recipient`, `X-SpamFoo-Personalize`, `X-SpamFoo-Scan`, or `X-SpamFoo-Deadline-Ms` value isn't valid. |
| `401` | `{"error": "unauthorized"}` | The proxy secret was missing or wrong, and the request needed it. |
| `403` | `{"error": "feature_not_licensed", "features": [...]}` | The license includes neither spam detection nor Email Classification, `X-SpamFoo-Classify: false` was sent without spam detection in the license, or the license has expired. |
| `413` | `{"error": "request_body_too_large", "maxBytes": ...}` | The message is over the body size limit. |
| `429` | `{"error": "...", "code": "auth_blocked"}` | Standalone mode only. Too many wrong secrets came from this address. See [Repeated Wrong Secrets](https://help.spamfoo.com/api#blocked). |
| `503` | `{"error": "server_busy", "code": "server_busy", ...}` or `{"error": "Classification queue timeout", "code": "queue_timeout", ...}` | SpamFoo Client is busy. Deliver the message normally. See [Handle Busy Responses](https://help.spamfoo.com/api#busy). |
| `503` | `{"error": "Classification deadline exceeded", "code": "deadline_exceeded", ...}` | Only with `X-SpamFoo-Deadline-Ms`. The deadline couldn't be met. Deliver the message normally. See [Set a Deadline](https://help.spamfoo.com/api/classify#deadline). |

## Example

### curl

```
curl -X POST http://localhost:16253/classify-stream \
    -H "Content-Type: message/rfc822" \
    -H "X-SpamFoo-Proxy-Auth: $SPAMFOO_PROXY_SECRET" \
    -H "X-User-ID: user@example.com" \
    -H "X-Sender-IP: 203.0.113.50" \
    -H "X-Sender-Email: sender@example.org" \
    -H "X-Recipient-Email: user@example.com" \
    -H "X-SpamFoo-Log-ID: queue-4F2A91" \
    --data-binary @message.eml
```

### C#

```
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient { BaseAddress = new Uri("http://localhost:16253") };

await using var eml = File.OpenRead("message.eml");
var content = new StreamContent(eml);
content.Headers.ContentType = new MediaTypeHeaderValue("message/rfc822");

using var request = new HttpRequestMessage(HttpMethod.Post, "/classify-stream") { Content = content };
request.Headers.Add("X-SpamFoo-Proxy-Auth", Environment.GetEnvironmentVariable("SPAMFOO_PROXY_SECRET"));
request.Headers.Add("X-User-ID", "user@example.com");
request.Headers.Add("X-Sender-IP", "203.0.113.50");
request.Headers.Add("X-Sender-Email", "sender@example.org");
request.Headers.Add("X-Recipient-Email", "user@example.com");
request.Headers.Add("X-SpamFoo-Log-ID", "queue-4F2A91");

using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();

var result = await response.Content.ReadFromJsonAsync<JsonElement>();
var isSpam = result.GetProperty("isSpam").GetBoolean();
var classification = result.GetProperty("classification").GetString();
Console.WriteLine($"isSpam={isSpam} classification={classification}");
```

### Python

```
import os

import requests

headers = {
    "Content-Type": "message/rfc822",
    "X-SpamFoo-Proxy-Auth": os.environ.get("SPAMFOO_PROXY_SECRET", ""),
    "X-User-ID": "user@example.com",
    "X-Sender-IP": "203.0.113.50",
    "X-Sender-Email": "sender@example.org",
    "X-Recipient-Email": "user@example.com",
    "X-SpamFoo-Log-ID": "queue-4F2A91",
}

with open("message.eml", "rb") as eml:
    response = requests.post(
        "http://localhost:16253/classify-stream",
        data=eml,
        headers=headers,
        timeout=60,
    )

response.raise_for_status()
result = response.json()
print(f"isSpam={result['isSpam']} classification={result['classification']}")
```
