DocsFeatures
Feedback API reference
The HTTP API behind in-app feedback - endpoints, errors, attachment rules, identity verification and signed webhooks, with request and response examples.
This is the reference for the API your app calls to send feedback, read replies and handle review requests. For what the feature does and how to turn it on, start with In-app feedback.
Full copy-paste examples with your key filled in (SwiftUI, Kotlin, Flutter, React Native and curl) are on the setup page under Feedback. This page explains what those examples do, so you can adapt them or write your own.
Base URL and headers
Every endpoint lives under:
https://asoatlas.com/api/v1/feedback
| Header | Sent on | Value |
|---|---|---|
X-Feedback-Key |
Every request | Your publishable key (fk_...) from the setup page |
X-Feedback-Thread |
The /thread endpoints |
The conversation token (ft_...) you got when it was created |
Accept |
Every request | application/json |
The publishable key can only send feedback for your app and read conversations it holds a token for, so it is safe inside your app. CORS is open, so a website can call the API straight from the browser. No cookies are involved.
The conversation token is the user's private access to their conversation. It travels in a header, never in a URL, and it is returned only once, when the conversation is created. Store it securely (the Keychain on iOS, EncryptedSharedPreferences on Android).
Create a conversation
POST /threads opens a conversation with its first message. Send JSON, or multipart/form-data when you attach files.
| Field | Required | Notes |
|---|---|---|
message |
Yes | Up to 10,000 characters |
category |
No | bug, idea, question, praise or other (default) |
email |
No | Where the developer can reach the user. ASO Atlas never emails it; replies show up in the app and on the conversation page |
name |
No | Up to 120 characters |
install_id |
No | A random id your app generates once per install; recommended |
external_user_id |
No | Your own user id |
user_hash |
No | Identity hash of external_user_id, see below |
platform |
No | ios, android, web or other |
app_version, build, os_version |
No | Up to 32 characters each. os_version is the release users know (18.1, 16), not the Android API level |
device_model |
No | Up to 64 characters |
locale |
No | The user's preferred language tag (BCP 47, for example pl-PL; pl_PL also works), up to 16 characters. Send the device language, not the app's UI locale: it picks the language of the default review request message, so an English-only app should still report the user's own language |
attachments[] |
No | Files, see the attachment rules |
{
"message": "The export button does nothing on the share sheet.",
"category": "bug",
"email": "[email protected]",
"install_id": "6f1c2b7e-0d3a-4f7b-9a51-2c8e5d4b1a90",
"platform": "ios",
"app_version": "1.4.0",
"build": "120",
"os_version": "18.1",
"device_model": "iPhone16,2",
"locale": "en-US"
}
The answer is 201 Created:
{
"mode": "conversation",
"thread": {
"id": 42,
"status": "open",
"category": "bug",
"created_at": "2026-10-20T09:14:03+00:00",
"app": { "name": "Your App", "icon_url": "https://..." },
"messages": [
{
"id": 101,
"author": "end_user",
"kind": "text",
"body": "The export button does nothing on the share sheet.",
"created_at": "2026-10-20T09:14:03+00:00",
"attachments": []
}
],
"unread_count": 0,
"token": "ft_...",
"url": "https://asoatlas.com/f/t/ft_..."
}
}
url is the hosted conversation page, handy if you would rather open replies in a browser view than build a screen for them. app.name and app.icon_url are null until the inbox is verified.
mode is the app's mode: conversation, or collect when the app is in feedback only mode. A collect app answers with "token": null and "url": null, because there is no conversation to come back to: show a thank-you and store nothing. Since you can switch modes after release, decode token and url as optional in every build; a 201 with a null token is a successful send, never a failure to show or retry. In both modes email only means the developer may contact the user.
Read and continue a conversation
These endpoints take both X-Feedback-Key and X-Feedback-Thread.
GET /threadreturns{"mode": "conversation", "thread": {...}}in the shape above, withouttokenandurl. Reading it marks your replies as read for the user.POST /thread/messagesadds a follow-up. Fields:message(required) andattachments[]. It reopens a resolved conversation and answers201with the updated thread.DELETE /threaddeletes the conversation with its files. Answers204.POST /thread/review-clickedtells ASO Atlas the latest review request reached the user: your app asked for the review prompt or opened the store link. The system decides whether its prompt actually appears, so this counts as Reached on the setup page, not as a review. Answers204.
Each message has author (end_user or developer) and kind (text or review_request). Attachments look like this, and their url works for at least an hour, so fetch the thread again rather than storing it:
{ "id": 7, "name": "screenshot.jpg", "mime": "image/jpeg", "size": 184220, "width": 1179, "height": 2556, "url": "https://asoatlas.com/feedback/files/7?..." }
Review requests in your app
When you resolve a conversation with a review request, it arrives as a message with "kind": "review_request", the text you set, and a store_url (your App Store write-a-review page or your Google Play listing). A good flow:
- Show the message in your conversation screen with a button.
- On tap, call the native prompt (
requestReviewon iOS, the In-App Review API on Android). Both are rate-limited by the system and may show nothing, so offerstore_urlas the fallback. - Call
POST /thread/review-clicked, so the setup page counts the request as reached. It means the prompt was shown or the store opened, never that a review was written: the system may skip its prompt, and stores never reveal who rated.
Never change what you show based on whether the user seemed happy: ask everyone you resolved, or no one.
Unread badge and a user's conversations
Store each conversation as an {id, token} pair (the token in the Keychain or other secure storage). Three calls then cover the screens, and only the third marks anything read:
- Badge:
POST /unreadwith the stored tokens. - "My conversations" list:
POST /summarieswith the same tokens. - One conversation, when the user opens it:
GET /thread.
POST /unread takes the tokens your app has stored (up to 50) and answers the unread reply count per conversation id. Unknown tokens are left out.
{ "tokens": ["ft_...", "ft_..."] }
{ "mode": "conversation", "unread": { "42": 1, "57": 0 } }
POST /summaries takes the same body (up to 50 tokens) and answers one row per conversation, newest activity first, with a preview of the latest message (up to about 140 characters) and its unread reply count. It never marks replies read, so refreshing the list does not clear the badge. Unknown tokens and tokens of other apps are left out; drop those from your storage.
{
"mode": "conversation",
"conversations": [
{
"id": 42,
"status": "open",
"category": "bug",
"created_at": "2026-10-20T09:14:03+00:00",
"last_message_at": "2026-10-20T10:02:41+00:00",
"preview": "Thanks, fixed in 2.1. Could you update and try again?",
"unread": 1
}
]
}
GET /conversations lists every conversation of one of your signed-in users across devices, newest first (up to 50), each with its token. It needs identity verification: send X-Feedback-User (your user id) and X-Feedback-User-Hash. Only conversations opened with a valid hash are listed. Without an identity secret on the inbox, or with a wrong hash, it answers 404.
{ "mode": "conversation", "threads": [ { "id": 42, "status": "resolved", "...": "...", "token": "ft_..." } ] }
Errors
Errors are JSON with a message. Validation errors also carry errors per field; the others carry an error code. The message is English text for you, the developer: show users your app's own localized copy, chosen by status and error.
| Status | error |
Meaning |
|---|---|---|
| 404 | not_found |
Unknown key, unknown or deleted conversation, or a failed identity check. Deliberately the same answer for all of them. |
| 422 | (none) | A field is invalid or an attachment was refused; see errors |
| 409 | replies_disabled |
The app is in feedback only mode, so there are no conversations to read or continue. GET /thread, POST /thread/messages, POST /thread/review-clicked, POST /unread, POST /summaries and GET /conversations answer this; creating feedback keeps working, and so does DELETE /thread for a conversation opened before the switch (feedback sent in feedback only mode has no token, so only you can delete it). Hide your conversation screens and badge. |
| 423 | inbox_unavailable |
The app is not accepting feedback right now. Only creating a conversation and posting a message answer this; reading, deleting and the other endpoints keep working. Show a friendly message. |
| 429 | daily_cap |
The app received its daily maximum of new conversations (2,000). Try again tomorrow. |
| 429 | rate_limited |
Too many requests. Wait for the number of seconds in the Retry-After header. |
An app can be switched to feedback only mode at any time from the setup page, so a conversation build should handle 409 by hiding its conversation screens rather than treating it as a failure. The message is always "This app collects feedback without replies."
The rate limits are generous for real use. Every endpoint allows 120 requests a minute per key and IP address, 3,000 a minute per key across all your users, and 300 a minute per IP address. On top of that, per IP address: 10 new conversations and 30 messages a minute, and 5 new conversations an hour per install_id. Without an install_id the hourly limit counts per IP address instead, so send one and users behind a shared network are not counted together.
Attachments
Up to 5 files per message, sent as attachments[] in a multipart body:
- Images: JPEG, PNG or WebP, up to 10 MB and 20 megapixels. Animated images and HEIC are refused, so convert to JPEG on the device first (
jpegDataon iOS,Bitmap.compresson Android). Images are scaled down to at most 4096 px on the long side and re-encoded, which also strips metadata such as location. - Logs:
.txtor.logplain text, up to 1 MB. - All files of one message together: up to 24 MB. A bigger request is refused before it reaches the API (HTTP 413), so scale photos down on the device: a 2048 px JPEG is usually well under 1 MB.
A refused file answers 422 with the reason in errors, and nothing is stored. If your account's storage is full, the message is still accepted without its files and notes that they were not stored.
Identity verification
If your app has its own accounts, your backend can vouch for a user id, which turns on GET /conversations for that user. Generate an identity secret on the setup page, keep it on your server, and compute the lowercase hex HMAC-SHA256 of the user id with it. Your app then sends the id and hash as external_user_id and user_hash when it creates a conversation, or as X-Feedback-User and X-Feedback-User-Hash on /conversations.
$userId = (string) $user->id;
$userHash = hash_hmac('sha256', $userId, getenv('ASO_FEEDBACK_IDENTITY_SECRET'));
import { createHmac } from 'node:crypto';
const userId = String(user.id);
const userHash = createHmac('sha256', process.env.ASO_FEEDBACK_IDENTITY_SECRET)
.update(userId)
.digest('hex');
Never put the secret in your app. An id sent without a valid hash is kept as a plain label on the conversation and never used to list anything. Replacing the secret makes old hashes stop verifying at once, so update your backend right after.
Webhooks
Add a webhook URL on the setup page to hear about new feedback. It must be HTTPS on a public address. Choose a format:
- JSON: the full event, signed, for your own server.
- Slack or Discord: a short readable message with a link to the conversation, never the user's email.
Events: thread.created (new conversation), message.created (a user follow-up), review_request.clicked (a review request reached the user: the review prompt was shown or the store opened) and test (the Send test button). JSON deliveries carry an X-AsoAtlas-Event header with the event name and look like this:
{
"event": "thread.created",
"id": "evt_3f9a...",
"created_at": "2026-10-20T09:14:03+00:00",
"inbox": { "id": 3, "app_id": 12, "app_name": "Your App", "platform": "ios" },
"thread": {
"id": 42,
"url": "https://asoatlas.com/feedback?thread=42",
"category": "bug",
"status": "open",
"source": "api",
"created_at": "2026-10-20T09:14:03+00:00",
"end_user": { "name": null, "email": "[email protected]", "external_user_id": null, "verified": false },
"context": { "platform": "ios", "app_version": "1.4.0", "build": "120", "os_version": "18.1", "device_model": "iPhone16,2", "locale": "en-US" }
},
"message": { "id": 101, "author": "end_user", "kind": "text", "body": "...", "created_at": "...", "review_clicked_at": null, "attachments": [] }
}
Attachment links in a webhook stay valid for 24 hours. Answer with any 2xx within 10 seconds; the response body is ignored and redirects are not followed. A failed delivery is retried 4 more times over about three minutes, and retries carry the same id, so deduplicate on it. After 20 failed deliveries in a row the webhook is switched off and you get an email; saving the URL again turns it back on.
Verifying the signature
The first time you save a webhook URL, in any format, the setup page shows a signing secret once. Only json deliveries are signed; Slack and Discord messages carry no signature. Every JSON delivery carries X-AsoAtlas-Signature: t=<unix time>,v1=<signature>, where the signature is the hex HMAC-SHA256 of the timestamp, a dot and the raw request body. Compute it over the exact bytes you received, before parsing, and reject old timestamps to stop replays. Lost the secret? Remove the webhook and add it again for a new one.
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_ASOATLAS_SIGNATURE'] ?? '';
if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m)
|| abs(time() - (int) $m[1]) > 300
|| ! hash_equals(hash_hmac('sha256', $m[1].'.'.$body, getenv('ASO_FEEDBACK_WEBHOOK_SECRET')), $m[2])) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body as a Buffer, e.g. from express.raw({ type: 'application/json' })
export function isValidAsoAtlasSignature(rawBody, header, secret) {
const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header ?? '');
if (!match || Math.abs(Date.now() / 1000 - Number(match[1])) > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${match[1]}.`)
.update(rawBody)
.digest();
return timingSafeEqual(expected, Buffer.from(match[2], 'hex'));
}