Product Guide

Guide

Everything you need to know about Signal shoot

Troubleshooting

When something goes wrong, the API returns a JSON error with an error code and a human-readable message. This section lists every error you may encounter, what causes it, and how to fix it.

400invalid_body
SymptomRequest returns 400. Response body: {"error":"invalid_body"}.
CauseThe request body is not valid JSON. Common reasons: missing Content-Type: application/json header, malformed JSON (trailing commas, unquoted keys), or the body is empty.
FixSet the Content-Type header to application/json. Validate your JSON with a linter before sending. Must use JSON.stringify() or equivalent — do not build JSON strings manually.
400invalid_type / invalid_message / message_too_long
SymptomRequest returns 400. Response body: {"error":"invalid_type"} or {"error":"invalid_message"} or {"error":"message_too_long"} or {"error":"invalid_channel"}.
CauseA required field is missing, empty, or exceeds the character limit. type: required, 1-50 chars. message: required, 1-5,000 chars. channel: optional, max 100 chars.
FixVerify that type and message are present and non-empty in your JSON body. Trim whitespace before validation. Must truncate user-generated message to 5,000 characters before sending.
400invalid_channel
SymptomRequest returns 400. Response body: {"error":"invalid_channel"}.
CauseThe channel field was present but not a string, or the string is longer than 100 characters. Empty strings and null are accepted and normalized to "default".
FixSend channel as a string of up to 100 characters, or omit it to use "default".
400invalid_user_id
SymptomRequest returns 400. Response body: {"error":"invalid_user_id"}.
Causeuser_id is present but not a non-empty string, or longer than 255 characters.
FixSend user_id as a non-empty string of 1–255 characters. Use an opaque identifier (UUID/ULID/random token).
400invalid_parent_id
SymptomRequest returns 400. Response body: {"error":"invalid_parent_id"}.
Causeparent_id is present but not a non-empty string, or longer than 50 characters.
FixOnly use feedback IDs returned by a previous ingest response (they look like fb_xxxx and are always ≤ 50 chars). Never accept arbitrary client input.
400invalid_metadata
SymptomRequest returns 400. Response body: {"error":"invalid_metadata"}.
Causemetadata is not a plain JSON object (arrays and primitives are rejected), contains values that fail JSON.stringify (circular references, BigInt), or serializes to more than 8,192 bytes.
FixSend metadata as a plain object with string/number/boolean/array leaf values. Keep the serialized size under 8 KB — prefer short keys and numeric values over long prose.
400missing_user_id
SymptomRequest returns 400. Response body: {"error":"missing_user_id"}.
CauseGET /v1/feedback/{app_id}/replies was called without a user_id query parameter, or DELETE /v1/feedback/{app_id}/user/{user_id} was called with an empty path segment.
FixInclude the user_id in the query (for /replies) or path (for /user-data delete).
400user_id_required_for_followup
SymptomRequest returns 400. Response body: {"error":"user_id_required_for_followup"}.
CauseFollow-up write (parent_id present) without a user_id in the body. user_id is required so that the parent's user_id can be compared against the caller's user_id.
FixInclude the same user_id that created the parent feedback, plus the X-Signal-User-Id-Signature header.
401missing_api_key
SymptomRequest returns 401. Response body: {"error":"missing_api_key"}.
CauseThe X-API-Key header is missing from your request.
FixAdd the X-API-Key header to every request. Use the prefix that matches the call site: fb_live_ for backend, fb_test_ for development, or fb_pub_ for browser-side ingest writes. Verify that proxies and CORS preflight are not stripping custom headers.
401invalid_api_key
SymptomRequest returns 401. Response body: {"error":"invalid_api_key"}.
CauseThe key is present but doesn't match any key on record. Common reasons: the key was regenerated and the old one is no longer valid, there is a typo or extra whitespace, or you are using a key from a different app.
FixGo to Settings and verify your current keys. Must copy-paste exactly — keys are case-sensitive. If recently regenerated, must update your app to use the new key.
401test_key_expired
SymptomRequest returns 401. Response body: {"error":"test_key_expired"}.
CauseThe test key (fb_test_...) has expired. Test keys are valid for 1 hour after generation.
FixRegenerate the test key from Settings. The new key is valid for 1 hour. For production, use the live key (fb_live_...).
401signature_required
SymptomRequest returns 401. Response body: {"error":"signature_required"}.
CauseStrict-mode app and the request carries user_id (or parent_id, or is a /replies or /user-data call) but the X-Signal-User-Id-Signature header is missing.
FixCompute HMAC-SHA256(signing_secret, user_id_utf8_bytes) and send it as a 64-character hex string (case-insensitive) in X-Signal-User-Id-Signature. Get the signing secret from dashboard Settings → Signing Key.
401invalid_signature
SymptomRequest returns 401. Response body: {"error":"invalid_signature"}.
CauseThe signature header is present but does not match HMAC-SHA256(current_signing_secret, user_id). Causes include: URL-encoding the user_id before signing (you must sign the URL-decoded logical value), signing with a rotated secret after the 7-day grace expired, mixing secrets from different apps, or corrupted hex.
FixRe-derive the signature from the current signing secret and the raw UTF-8 bytes of the logical user_id. If you just rotated the key, wait a moment for both versions to be active during grace, or switch to the new secret.
403origin_not_allowed
SymptomRequest returns 403. Response body: {"error":"origin_not_allowed"}.
Causefb_pub_* request whose Origin header is missing, malformed, or does not match any entry in the app's Origin allowlist. An empty allowlist disables the publishable key entirely.
FixAdd the exact scheme + host (and non-default port) of your browser app to the allowlist in dashboard Settings. Use an explicit origin like https://example.com — wildcards and paths are rejected.
403pub_key_user_features_forbidden
SymptomRequest returns 403. Response body: {"error":"pub_key_user_features_forbidden"}.
Causefb_pub_* request on /v1/ingest included user_id or parent_id in the body. Publishable keys are write-only and do not support user_id or parent_id.
FixDrop user_id and parent_id from publishable-key writes, or switch to a live key from your backend when you need user-scoped flows.
403pub_key_forbidden
SymptomRequest returns 403. Response body: {"error":"pub_key_forbidden"}.
Causefb_pub_* was used on GET /v1/feedback/{app_id}/replies or DELETE /v1/feedback/{app_id}/user/{user_id}. Publishable keys cannot read replies or delete user data.
FixCall these endpoints from your backend with a live API key (not from the browser).
403test_key_forbidden
SymptomRequest returns 403. Response body: {"error":"test_key_forbidden"}.
Causefb_test_* was used on DELETE /v1/feedback/{app_id}/user/{user_id}. Test keys cannot perform destructive operations.
FixUse a live API key from your backend for user data deletion.
403parent_user_mismatch
SymptomRequest returns 403. Response body: {"error":"parent_user_mismatch"}.
CauseFollow-up where body.user_id does not match the parent feedback's stored user_id.
FixOnly send follow-ups from the same user who created the parent feedback. Do not allow clients to supply arbitrary parent_id values.
404app_not_found
SymptomRequest returns 404. Response body: {"error":"app_not_found"}.
CauseThe app_id in the URL does not match any app in the system. You may have a typo in the URL, or you are using an app ID from a different account.
FixCopy the App ID from Settings exactly. Must use the format: POST https://api.signalshoot.com/v1/ingest/{app_id}. Do not construct the ID manually.
404parent_not_found
SymptomRequest returns 404. Response body: {"error":"parent_not_found"}.
CauseYou sent a parent_id that doesn't exist or belongs to a different app. The original feedback may have been deleted, or the ID is incorrect.
FixVerify that parent_id matches an existing feedback ID from the same app. Must only use IDs from the replies endpoint or from the 201 response of the original submission.
413payload_too_large
SymptomRequest returns 413. Response body: {"error":"payload_too_large"}.
CausePOST /v1/ingest with a missing or invalid Content-Length header, or a body exceeding the 64 KB cap. Enforced before the body is parsed.
FixTrim the payload below 64 KB. If your HTTP client does not set Content-Length automatically, set it explicitly.
429too_many_requests
SymptomRequest returns 429. Response body: {"error":"too_many_requests"}.
CauseOne of three limits: (a) Per-app burst limit on writes — more than 10 submissions in 60 seconds for the same app. Applies to live, test, and publishable keys. (b) Per-(app, IP) publishable-key ingest limit — fb_pub_* requests are capped at 30/min per source IP per app. Live and test keys are unaffected. (c) Per-(app, IP) replies-read limit — GET /replies is capped at 120/min per source IP per app. The monthly limit is a separate error code (see monthly_limit_exceeded below).
FixWait 60 seconds and retry.
429monthly_limit_exceeded
SymptomRequest returns 429. Response body: {"error":"monthly_limit_exceeded","limit":N}.
CauseThe monthly limit has been reached (Free: 200, Pro: 1,000). Once the counter reaches the limit, further writes are refused until the next billing period. The counter is not advanced past the limit, so the first write in the next period starts fresh.
FixDo NOT retry until the next billing period. Show an upgrade prompt to the user. The response includes limit so you can display the relevant number.
500internal_error
SymptomRequest returns 500. Response body: {"error":"internal_error"}.
CauseAn unexpected server error occurred. No internal details are included in the response.
FixSafe to retry with a short backoff. If persistent, capture the request (app_id, timestamp, endpoint) and report it.
500signing_master_not_configured
SymptomRequest returns 500. Response body: {"error":"signing_master_not_configured"}.
CauseThe server's signing configuration is missing. This is a server-side issue, not a client-side problem.
FixNot fixable from the client. Report it — retries will not succeed until the server is reconfigured.

429 Response examples

too_many_requests (burst limit)
{
  "error": "too_many_requests",
  "message": "Too many requests. Maximum 10 submissions per minute per app. Please wait and try again."
}
monthly_limit_exceeded (monthly limit)
{
  "error": "monthly_limit_exceeded",
  "message": "Monthly limit reached for the free plan. Upgrade or wait for the next period.",
  "limit": 200
}

Duplicate submissions

The ingest API does not deduplicate. Every POST creates a new feedback entry (or reply), even if the content is identical. Network retries, double-taps, or client-side retry logic can create duplicates. To prevent this: (1) Disable the submit button after the first tap until you receive a response. (2) If you implement retry logic, use it only for network errors (no response received) — not for 4xx errors. (3) On the server side, check if feedback with the same message, user_id, and type was created in the last few seconds before sending. Signal shoot does not reject duplicates, so prevention must happen on your side.