> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.portreeve.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.portreeve.com/_mcp/server.

# Report a real-world outcome about a subject

POST https://api.portreeve.com/v1/feedback
Content-Type: application/json

Tell Portreeve what actually happened. The `subject` names what the report is about: an `event_id`, an `external_user_id` (the natural shape from a payment provider's dispute webhook — "this account charged back"), or an `email`. Feedback propagates across the subject's linked identity cluster, so a `confirmed_abuse` or `chargeback` report sharpens future decisions about every account linked to it, and a `false_positive` suppresses the pattern that misfired.

The subject kind set is deliberately closed: feedback convicts, and identifiers that cannot safely convict (IPs, user agents) will never be accepted as subjects. New kinds are added as enum values — the route never changes.

**Safe to retry.** An identical repost — same subject, same outcome — is deduplicated server-side and returns `200 { "ok": true }` without recording a second row, so no `Idempotency-Key` is needed. The same subject with a *different* outcome records both.

Reference: https://docs.portreeve.com/api-reference/feedback/create-feedback

## Authentication

- `Authorization` header (bearer token, required) — `Authorization: Bearer sk_live_…` (or `sk_test_…`). Server-side only — never ship a secret key to a browser or a mobile app. The key determines the tenant *and* the mode; there is no mode parameter on any request.

## Request

### Body (application/json)

This endpoint expects a FeedbackRequest.

- `subject` (FeedbackSubject, required) — What the report is about. `{kind: "event_id"}` matches the old per-event behavior; `{kind: "external_user_id"}` is the dispute-webhook shape.
- `outcome` (enum, required) — `confirmed_abuse`: the actor turned out to be abusive — marks their whole linked cluster. `false_positive`: we were wrong — send these even when they feel minor, they are the metric we hold ourselves to. `chargeback`: a dispute was filed.
  - Allowed values: `confirmed_abuse`, `false_positive`, `chargeback`
- `note` (string, optional) — Optional free text for your own records, up to 2000 characters.

## Response

### 200

Feedback recorded (or already recorded — the endpoint is idempotent on `(subject, outcome)`).

- `ok` (true, required) — Always `true`. The endpoint has no failure mode that returns 200.

## Errors

### 400 Bad Request Error

The body failed contract validation — including any subject kind outside the closed enum. `details.issues[]` carries `{ path, message }` per failure.

- `error` (string, required) — **This is an open set.** New values ship without a new SDK release and without a breaking change, so this is documented as a plain string rather than a closed enum. Treat an unrecognised value as informational: log it, display it, never crash on it, and never map "unknown" to a punitive action. Publishing a closed enum here would make our next additive release break every generated client that validates responses. The values defined today (also available machine-readably in `x-portreeve-known-values`) are: `invalid_request`, `unauthorized`, `rate_limited`, `idempotency_conflict`, `conflict`, `not_found`, `internal`.
- `message` (string, required) — Human-readable, for your logs. Never show it to an end user — on a `block` it can describe what tripped.
- `details` (map from string to any, optional) — Optional structured context. On a 400 it carries `issues[]`, an array of `{ path, message }` — one entry per validation failure.

### 401 Unauthorized Error

Missing, malformed, unknown, revoked, or wrong-kind API key. A secret key is required.

- `error` (string, required) — **This is an open set.** New values ship without a new SDK release and without a breaking change, so this is documented as a plain string rather than a closed enum. Treat an unrecognised value as informational: log it, display it, never crash on it, and never map "unknown" to a punitive action. Publishing a closed enum here would make our next additive release break every generated client that validates responses. The values defined today (also available machine-readably in `x-portreeve-known-values`) are: `invalid_request`, `unauthorized`, `rate_limited`, `idempotency_conflict`, `conflict`, `not_found`, `internal`.
- `message` (string, required) — Human-readable, for your logs. Never show it to an end user — on a `block` it can describe what tripped.
- `details` (map from string to any, optional) — Optional structured context. On a 400 it carries `issues[]`, an array of `{ path, message }` — one entry per validation failure.

### 404 Not Found Error

The subject resolves to nothing in this tenant and mode — no such event, no account with that external_user_id, no identity built from that email.

- `error` (string, required) — **This is an open set.** New values ship without a new SDK release and without a breaking change, so this is documented as a plain string rather than a closed enum. Treat an unrecognised value as informational: log it, display it, never crash on it, and never map "unknown" to a punitive action. Publishing a closed enum here would make our next additive release break every generated client that validates responses. The values defined today (also available machine-readably in `x-portreeve-known-values`) are: `invalid_request`, `unauthorized`, `rate_limited`, `idempotency_conflict`, `conflict`, `not_found`, `internal`.
- `message` (string, required) — Human-readable, for your logs. Never show it to an end user — on a `block` it can describe what tripped.
- `details` (map from string to any, optional) — Optional structured context. On a 400 it carries `issues[]`, an array of `{ path, message }` — one entry per validation failure.

### 429 Too Many Requests Error

Per-key rate limit exceeded (default 600 requests/minute per API key; requests with an unparseable key are bucketed per source IP). `retry-after` and `x-ratelimit-*` headers accompany the response. Retry after the window; do not treat this as a verdict.

- `error` (string, required) — **This is an open set.** New values ship without a new SDK release and without a breaking change, so this is documented as a plain string rather than a closed enum. Treat an unrecognised value as informational: log it, display it, never crash on it, and never map "unknown" to a punitive action. Publishing a closed enum here would make our next additive release break every generated client that validates responses. The values defined today (also available machine-readably in `x-portreeve-known-values`) are: `invalid_request`, `unauthorized`, `rate_limited`, `idempotency_conflict`, `conflict`, `not_found`, `internal`.
- `message` (string, required) — Human-readable, for your logs. Never show it to an end user — on a `block` it can describe what tripped.
- `details` (map from string to any, optional) — Optional structured context. On a 400 it carries `issues[]`, an array of `{ path, message }` — one entry per validation failure.

### 500 Internal Server Error

Unhandled server error. The verdict engine itself fails open rather than 500ing — a rules bug returns `allow` with no reasons — so a 500 here means the request never reached a decision. Treat as retryable.

- `error` (string, required) — **This is an open set.** New values ship without a new SDK release and without a breaking change, so this is documented as a plain string rather than a closed enum. Treat an unrecognised value as informational: log it, display it, never crash on it, and never map "unknown" to a punitive action. Publishing a closed enum here would make our next additive release break every generated client that validates responses. The values defined today (also available machine-readably in `x-portreeve-known-values`) are: `invalid_request`, `unauthorized`, `rate_limited`, `idempotency_conflict`, `conflict`, `not_found`, `internal`.
- `message` (string, required) — Human-readable, for your logs. Never show it to an end user — on a `block` it can describe what tripped.
- `details` (map from string to any, optional) — Optional structured context. On a 400 it carries `issues[]`, an array of `{ path, message }` — one entry per validation failure.

## Types

### FeedbackSubject

What a feedback report is about — a closed kind set, because feedback convicts.

- `kind` (enum, required) — One of `event_id`, `external_user_id`, `email`. Closed on purpose: identifiers that cannot safely convict (IPs) will never be accepted; new safe kinds become enum values, not new routes.
  - Allowed values: `event_id`, `external_user_id`, `email`
- `value` (string, required) — The identifier itself, 1–256 characters. Rides in the body, never a URL path, so PII stays out of access logs.

## Examples

### we allowed them and we were wrong

**Request**

```json
{
  "subject": {
    "kind": "event_id",
    "value": "evt_01J8Z3K9V2M4X8Q1TPRD5WNYFC"
  },
  "outcome": "confirmed_abuse",
  "note": "abuse ring confirmed by support"
}
```

**Response**

```json
{
  "ok": true
}
```

**SDK Code**

```python we allowed them and we were wrong
import requests

url = "https://api.portreeve.com/v1/feedback"

payload = {
    "subject": {
        "kind": "event_id",
        "value": "evt_01J8Z3K9V2M4X8Q1TPRD5WNYFC"
    },
    "outcome": "confirmed_abuse",
    "note": "abuse ring confirmed by support"
}
headers = {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())
```

```javascript we allowed them and we were wrong
const url = 'https://api.portreeve.com/v1/feedback';
const options = {
  method: 'POST',
  headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
  body: '{"subject":{"kind":"event_id","value":"evt_01J8Z3K9V2M4X8Q1TPRD5WNYFC"},"outcome":"confirmed_abuse","note":"abuse ring confirmed by support"}'
};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

```go we allowed them and we were wrong
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://api.portreeve.com/v1/feedback"

	payload := strings.NewReader("{\n  \"subject\": {\n    \"kind\": \"event_id\",\n    \"value\": \"evt_01J8Z3K9V2M4X8Q1TPRD5WNYFC\"\n  },\n  \"outcome\": \"confirmed_abuse\",\n  \"note\": \"abuse ring confirmed by support\"\n}")

	req, _ := http.NewRequest("POST", url, payload)

	req.Header.Add("Authorization", "Bearer <token>")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

```ruby we allowed them and we were wrong
require 'uri'
require 'net/http'

url = URI("https://api.portreeve.com/v1/feedback")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n  \"subject\": {\n    \"kind\": \"event_id\",\n    \"value\": \"evt_01J8Z3K9V2M4X8Q1TPRD5WNYFC\"\n  },\n  \"outcome\": \"confirmed_abuse\",\n  \"note\": \"abuse ring confirmed by support\"\n}"

response = http.request(request)
puts response.read_body
```

```java we allowed them and we were wrong
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://api.portreeve.com/v1/feedback")
  .header("Authorization", "Bearer <token>")
  .header("Content-Type", "application/json")
  .body("{\n  \"subject\": {\n    \"kind\": \"event_id\",\n    \"value\": \"evt_01J8Z3K9V2M4X8Q1TPRD5WNYFC\"\n  },\n  \"outcome\": \"confirmed_abuse\",\n  \"note\": \"abuse ring confirmed by support\"\n}")
  .asString();
```

```php we allowed them and we were wrong
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://api.portreeve.com/v1/feedback', [
  'body' => '{
  "subject": {
    "kind": "event_id",
    "value": "evt_01J8Z3K9V2M4X8Q1TPRD5WNYFC"
  },
  "outcome": "confirmed_abuse",
  "note": "abuse ring confirmed by support"
}',
  'headers' => [
    'Authorization' => 'Bearer <token>',
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();
```

```csharp we allowed them and we were wrong
using RestSharp;

var client = new RestClient("https://api.portreeve.com/v1/feedback");
var request = new RestRequest(Method.POST);
request.AddHeader("Authorization", "Bearer <token>");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"subject\": {\n    \"kind\": \"event_id\",\n    \"value\": \"evt_01J8Z3K9V2M4X8Q1TPRD5WNYFC\"\n  },\n  \"outcome\": \"confirmed_abuse\",\n  \"note\": \"abuse ring confirmed by support\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

```swift we allowed them and we were wrong
import Foundation

let headers = [
  "Authorization": "Bearer <token>",
  "Content-Type": "application/json"
]
let parameters = [
  "subject": [
    "kind": "event_id",
    "value": "evt_01J8Z3K9V2M4X8Q1TPRD5WNYFC"
  ],
  "outcome": "confirmed_abuse",
  "note": "abuse ring confirmed by support"
] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://api.portreeve.com/v1/feedback")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```

### dispute webhook — this account charged back

**Request**

```json
{
  "subject": {
    "kind": "external_user_id",
    "value": "usr_9f2c"
  },
  "outcome": "chargeback",
  "note": "stripe dispute du_1QxT"
}
```

**Response**

```json
{
  "ok": true
}
```

**SDK Code**

```python dispute webhook — this account charged back
import requests

url = "https://api.portreeve.com/v1/feedback"

payload = {
    "subject": {
        "kind": "external_user_id",
        "value": "usr_9f2c"
    },
    "outcome": "chargeback",
    "note": "stripe dispute du_1QxT"
}
headers = {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())
```

```javascript dispute webhook — this account charged back
const url = 'https://api.portreeve.com/v1/feedback';
const options = {
  method: 'POST',
  headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
  body: '{"subject":{"kind":"external_user_id","value":"usr_9f2c"},"outcome":"chargeback","note":"stripe dispute du_1QxT"}'
};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

```go dispute webhook — this account charged back
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://api.portreeve.com/v1/feedback"

	payload := strings.NewReader("{\n  \"subject\": {\n    \"kind\": \"external_user_id\",\n    \"value\": \"usr_9f2c\"\n  },\n  \"outcome\": \"chargeback\",\n  \"note\": \"stripe dispute du_1QxT\"\n}")

	req, _ := http.NewRequest("POST", url, payload)

	req.Header.Add("Authorization", "Bearer <token>")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

```ruby dispute webhook — this account charged back
require 'uri'
require 'net/http'

url = URI("https://api.portreeve.com/v1/feedback")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n  \"subject\": {\n    \"kind\": \"external_user_id\",\n    \"value\": \"usr_9f2c\"\n  },\n  \"outcome\": \"chargeback\",\n  \"note\": \"stripe dispute du_1QxT\"\n}"

response = http.request(request)
puts response.read_body
```

```java dispute webhook — this account charged back
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://api.portreeve.com/v1/feedback")
  .header("Authorization", "Bearer <token>")
  .header("Content-Type", "application/json")
  .body("{\n  \"subject\": {\n    \"kind\": \"external_user_id\",\n    \"value\": \"usr_9f2c\"\n  },\n  \"outcome\": \"chargeback\",\n  \"note\": \"stripe dispute du_1QxT\"\n}")
  .asString();
```

```php dispute webhook — this account charged back
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://api.portreeve.com/v1/feedback', [
  'body' => '{
  "subject": {
    "kind": "external_user_id",
    "value": "usr_9f2c"
  },
  "outcome": "chargeback",
  "note": "stripe dispute du_1QxT"
}',
  'headers' => [
    'Authorization' => 'Bearer <token>',
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();
```

```csharp dispute webhook — this account charged back
using RestSharp;

var client = new RestClient("https://api.portreeve.com/v1/feedback");
var request = new RestRequest(Method.POST);
request.AddHeader("Authorization", "Bearer <token>");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"subject\": {\n    \"kind\": \"external_user_id\",\n    \"value\": \"usr_9f2c\"\n  },\n  \"outcome\": \"chargeback\",\n  \"note\": \"stripe dispute du_1QxT\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

```swift dispute webhook — this account charged back
import Foundation

let headers = [
  "Authorization": "Bearer <token>",
  "Content-Type": "application/json"
]
let parameters = [
  "subject": [
    "kind": "external_user_id",
    "value": "usr_9f2c"
  ],
  "outcome": "chargeback",
  "note": "stripe dispute du_1QxT"
] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://api.portreeve.com/v1/feedback")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```

### we flagged them and we were wrong

**Request**

```json
{
  "subject": {
    "kind": "event_id",
    "value": "evt_01J8Z3KAY7N2C6R9SWXD4QMVEH"
  },
  "outcome": "false_positive"
}
```

**Response**

```json
{
  "ok": true
}
```

**SDK Code**

```python we flagged them and we were wrong
import requests

url = "https://api.portreeve.com/v1/feedback"

payload = {
    "subject": {
        "kind": "event_id",
        "value": "evt_01J8Z3KAY7N2C6R9SWXD4QMVEH"
    },
    "outcome": "false_positive"
}
headers = {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())
```

```javascript we flagged them and we were wrong
const url = 'https://api.portreeve.com/v1/feedback';
const options = {
  method: 'POST',
  headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
  body: '{"subject":{"kind":"event_id","value":"evt_01J8Z3KAY7N2C6R9SWXD4QMVEH"},"outcome":"false_positive"}'
};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

```go we flagged them and we were wrong
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://api.portreeve.com/v1/feedback"

	payload := strings.NewReader("{\n  \"subject\": {\n    \"kind\": \"event_id\",\n    \"value\": \"evt_01J8Z3KAY7N2C6R9SWXD4QMVEH\"\n  },\n  \"outcome\": \"false_positive\"\n}")

	req, _ := http.NewRequest("POST", url, payload)

	req.Header.Add("Authorization", "Bearer <token>")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

```ruby we flagged them and we were wrong
require 'uri'
require 'net/http'

url = URI("https://api.portreeve.com/v1/feedback")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n  \"subject\": {\n    \"kind\": \"event_id\",\n    \"value\": \"evt_01J8Z3KAY7N2C6R9SWXD4QMVEH\"\n  },\n  \"outcome\": \"false_positive\"\n}"

response = http.request(request)
puts response.read_body
```

```java we flagged them and we were wrong
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://api.portreeve.com/v1/feedback")
  .header("Authorization", "Bearer <token>")
  .header("Content-Type", "application/json")
  .body("{\n  \"subject\": {\n    \"kind\": \"event_id\",\n    \"value\": \"evt_01J8Z3KAY7N2C6R9SWXD4QMVEH\"\n  },\n  \"outcome\": \"false_positive\"\n}")
  .asString();
```

```php we flagged them and we were wrong
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://api.portreeve.com/v1/feedback', [
  'body' => '{
  "subject": {
    "kind": "event_id",
    "value": "evt_01J8Z3KAY7N2C6R9SWXD4QMVEH"
  },
  "outcome": "false_positive"
}',
  'headers' => [
    'Authorization' => 'Bearer <token>',
    'Content-Type' => 'application/json',
  ],
]);

echo $response->getBody();
```

```csharp we flagged them and we were wrong
using RestSharp;

var client = new RestClient("https://api.portreeve.com/v1/feedback");
var request = new RestRequest(Method.POST);
request.AddHeader("Authorization", "Bearer <token>");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"subject\": {\n    \"kind\": \"event_id\",\n    \"value\": \"evt_01J8Z3KAY7N2C6R9SWXD4QMVEH\"\n  },\n  \"outcome\": \"false_positive\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

```swift we flagged them and we were wrong
import Foundation

let headers = [
  "Authorization": "Bearer <token>",
  "Content-Type": "application/json"
]
let parameters = [
  "subject": [
    "kind": "event_id",
    "value": "evt_01J8Z3KAY7N2C6R9SWXD4QMVEH"
  ],
  "outcome": "false_positive"
] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://api.portreeve.com/v1/feedback")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```