Send feedback with the API
The widget isn't the only way in. Anything that can make an HTTP request can send feedback to Opini, with your own tags (bug, idea, billing) and any context you want to see next to the message.
What it's for
- Your own feedback form. You already have a form or an in-app screen and want the messages in Opini.
- Your backend and support tools. Forward messages from a support inbox, a chat bot, app store reviews or a cancellation survey.
- Games, mobile and desktop apps. Send feedback straight from the app, with the platform, build or level attached.
- Scripts and imports. Bring in feedback you collected somewhere else, or send it from a command-line tool.
Which key to use
- From your own server or a script: a private key (
sk_live_…), sent in theAuthorizationheader. It may set any of the project's tags. Keep it on the server; never put it in an app people download or in a web page. - From an app people install (game, mobile, desktop): a live key (
pk_live_…) with Apps and games turned on, sent in the body. Live keys are meant to ship inside your app, and you decide which tags they may set. - On a website: the widget with a live key and your site's address in its allowed origins.
Create the key
First, create the tags you'll send in Project settings → Tags, for example bug, idea and billing. Then open Project settings → API keys and click Create key.
For your server or a script:
- Choose Private and give it a label, like support-inbox.
- Copy the key when it's shown. It's shown once; rotate the key if you lose it.
For an app people install:
- Choose Public and give it a label, like ios-app or steam-build.
- Turn on Apps and games. You can leave allowed origins empty if the key is only used by your app.
- Under Tags this key can set, pick the tags people may send. Leave them all off if the app shouldn't set tags.
- Set a daily cap that fits how many people use the app.
Use one key per app, platform or integration. You can then rotate or revoke one without touching the others, and the keys table shows when each was last used.
Send feedback
Send a POST to https://opini.dev/api/v1/ingest with a JSON body. A private key goes in the Authorization: Bearer sk_live_… header; a live key goes in the body as project_key.
{
"project_key": "pk_live_…", // live keys only; private keys go in the header
"text": "What the person wrote", // required: 1 to 4,000 characters
"email": "[email protected]", // optional: so you can reply
"name": "Sam", // optional
"tags": ["bug", "crash"], // optional: up to 10 tag names
"context": { "platform": "steam" } // optional: up to 16 KB of JSON
}A full request:
curl https://opini.dev/api/v1/ingest \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"text": "Exports to CSV keep timing out for our larger workspaces.",
"email": "[email protected]",
"tags": ["bug"],
"context": {
"source": "support-inbox",
"plan": "business",
"ticket": "4821"
}
}'A 200 means the feedback is saved and is already in your Feedback list:
{ "feedback_public_id": "7k2m9qxa4f", "attachments": { "enabled": true } }Examples
From your backend, with a private key (Node):
// Node, from your backend. The private key stays on the server.
await fetch("https://opini.dev/api/v1/ingest", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPINI_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
text: survey.reason,
email: user.email,
tags: ["churn"],
context: { source: "cancel-survey", plan: user.plan },
}),
})From a desktop or mobile app, with a live key (Electron, React Native):
// Any JavaScript runtime without a browser origin: Electron's main
// process, React Native, Node.
await fetch("https://opini.dev/api/v1/ingest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
project_key: "pk_live_…",
text: "The export button does nothing on Linux.",
tags: ["bug"],
context: { platform: process.platform, version: app.getVersion() },
}),
})From a game, with a live key (Unity, C#):
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class OpiniFeedback : MonoBehaviour
{
const string Url = "https://opini.dev/api/v1/ingest";
const string Key = "pk_live_…"; // a key with "Apps and games" on
[System.Serializable] class Context { public string platform; public string build; public string level; }
[System.Serializable] class Body { public string project_key; public string text; public string[] tags; public Context context; }
public IEnumerator Send(string text, string[] tags, string level)
{
var body = new Body {
project_key = Key,
text = text,
tags = tags,
context = new Context {
platform = Application.platform.ToString(),
build = Application.version,
level = level,
},
};
var json = JsonUtility.ToJson(body);
using var req = new UnityWebRequest(Url, "POST");
req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
Debug.LogWarning($"Opini: {req.responseCode} {req.downloadHandler.text}");
}
}From a game, with a live key (Godot, GDScript):
# Godot 4. Add an HTTPRequest node as a child named "Http".
const URL = "https://opini.dev/api/v1/ingest"
const KEY = "pk_live_…" # a key with "Apps and games" on
func send_feedback(text: String, tags: Array, level: String) -> void:
var body = {
"project_key": KEY,
"text": text,
"tags": tags,
"context": {
"platform": OS.get_name(),
"build": ProjectSettings.get_setting("application/config/version"),
"level": level,
},
}
$Http.request(URL, ["Content-Type: application/json"],
HTTPClient.METHOD_POST, JSON.stringify(body))Context
context is any JSON object up to 16 KB. Opini doesn't need a fixed shape: every key shows up under Context when you open the message, and you can filter by any of them.
- From a backend:
source,plan,account_id,ticket. - From an app or game:
platform,version,build,device,level. - Keep it flat where you can (
{"platform": "steam"}). Nested objects work too; you filter them with dots, likedevice.gpu. - Use the same key names everywhere you send from, so one filter finds all the messages.
- Don't send passwords, tokens or anything people wouldn't expect you to keep. Your team can read every value.
Find it in Opini
In the Feedback list, use the Tag filter to show messages with any of the chosen tags, and the Context filter to show messages whose context has a key with a value, such as platform = steam or level = 3. Values are compared as text, so 3 matches the number 3. Filters combine with each other and with the state and date filters, and they're kept in the address so you can share the view.
The same filters work in the API and in your AI tool. The MCP tool list_feedback takes tags, context_key and context_value, so you can ask "what bugs did business customers report this week?".
GET /api/v1/projects/{project_id}/feedback?tag=bug,crash&context_key=platform&context_value=steamResponses and errors
| Status | Error | What to do |
|---|---|---|
200 | none | Saved. |
400 | missing_origin | A live key was used from outside a website, and it doesn't have Apps and games on. Turn it on, or send from your server with a private key. |
400 | invalid_text, invalid_email, context_too_large | Text must be 1 to 4,000 characters, email must be a real, non-throwaway address, and context must be under 16 KB. |
401 | invalid_key | The key is wrong or was revoked. |
403 | origin_not_allowed | The request came from a website that isn't in the key's allowed origins. |
422 | tags_not_allowed | Add the listed tags to the project or to the key's allowed tags, or stop sending them. |
429 | rate_limited, daily_cap_exceeded | Too many messages. Wait and retry; for the daily cap, Retry-After says when it resets (midnight UTC). |
If a request fails because the device is offline, keep the message and send it the next time the app starts.
Keeping keys safe
A private key can send feedback with any tag, so keep it on your server, in an environment variable or secret store, and rotate it if it leaks.
Anything shipped inside an app can be read by someone determined, so treat a live key as public. That's why it can only send feedback, and only with the tags you allowed. To keep abuse small:
- Set a daily cap close to what you expect. Extra messages get a 429 instead of filling your list.
- Only allow the tags people should choose. Keep internal tags off the key.
- If a key is abused, rotate it and ship the new one in your next update. Revoking stops it at once.
- Use a separate key per app or platform, so one leak doesn't affect the rest.