Product Guide

Guide

Everything you need to know about Signal shoot

API Reference

Base URL: https://api.signalshoot.com

Endpoint

Endpoint
POST https://api.signalshoot.com/v1/ingest/{app_id}

Headers

Content-Typeapplication/json (required on POST requests)
Content-LengthRequired on POST /v1/ingest only. Body cap: 64 KB.
X-API-KeySee Authentication & Keys → Key types below.
X-Signal-User-Id-SignatureSee Authentication & Keys → HMAC signing below.
OriginSee Authentication & Keys → Publishable key below.

Request Body

typerequiredAny string up to 50 characters that categorizes the feedback. Common values include "bug", "feedback", "inquiry", "crash", and "praise", but you can use any string that fits your service. The exact string you send is displayed as-is in dashboard filter buttons and badges — no automatic translation is applied, so send the label you want to see (any language works). No pre-registration needed; the dashboard adapts as you send.
messagerequiredThe actual feedback text from the user. Max 5,000 characters. This is the main content displayed in the inbox and detail view. A good feedback message is whatever the user typed — do not filter or truncate it on the client side before sending. If your app has a multi-field form (e.g. separate fields for "what happened" and "steps to reproduce"), concatenate them into one message string. The message is searchable in the inbox, translatable via one-click translation, and included in data exports.
channeloptionalA string (max 100 chars) that identifies where in your service the feedback was sent from, defaulting to "default" if omitted. Examples: "contact-form", "settings-feedback", "onboarding-survey", "shake-report", or "checkout-support". Like type, the exact string you send is displayed as-is in dashboard filters. Use consistent naming — "contact" and "Contact" are treated as separate channels.
user_idoptional / required for repliesOptional in the API shape, but required for conversational mode. 1–255 characters; longer values return 400 invalid_user_id. Must be an opaque identifier (UUID, ULID, or random string) — do not send email addresses, real names, or sequential integers. Including user_id enables replies from the dashboard and lets the user's app fetch responses via the replies endpoint. If you only need to receive feedback without replying, this field can be omitted.
parent_idoptional / required for threadsUsed in two-way replies. Set this to the feedback ID returned by a previous API response (e.g. "fb_xyz789") to add a follow-up message to an existing conversation instead of creating new feedback. While user_id identifies "who", parent_id identifies "which conversation". The parent must exist under the same app_id; a non-existent ID or an ID from a different app_id returns a 404. If the parent was resolved or archived, its status is automatically reopened to "in_progress". Follow-ups with parent_id do not count toward the monthly limit. See the Setup Guide Step 4 for the full implementation flow.
metadataoptionalA JSON object containing any additional context you want to attach — no fixed schema, include whatever helps you understand the report. Must be a plain object (arrays and primitives return 400 invalid_metadata), and the serialized form is capped at 8,192 bytes. Common fields include app_version, os, device, screen, and locale, but you can add any key that fits your app. There are no reserved metadata keys — the dashboard displays metadata as a key-value table in the feedback detail view and includes it in exports as-is (as a raw JSON string in CSV).

Response (201 Created)

Response example — 201 Created
{
  "id": "fb_omcizyzblfu2",
  "status": "received",
  "created_at": "2026-04-06T12:00:00Z"
}

Successful ingest requests return id, status, and created_at. The status is always "received". The API does not echo the original request body back. For parent_id submissions, the id is a reply ID (rp_...) and the parent_id field is also included.

Common error body

All 4xx responses use the same JSON shape: error (machine-readable code) and message (human-readable explanation).

Response example — 4xx
{
  "error": "invalid_type",
  "message": "type is required and must be a string (max 50 characters)"
}

Replies Endpoint

Example — Replies request
GET https://api.signalshoot.com/v1/feedback/{app_id}/replies?user_id=a1b2c3d4-e5f6-4789-a0b1-c2d3e4f56789
X-API-Key: fb_live_YOUR_KEY
// Required: HMAC over the URL-decoded user_id:
X-Signal-User-Id-Signature: <64-char hex HMAC-SHA256(signing_secret, "a1b2c3d4-e5f6-4789-a0b1-c2d3e4f56789")>

GET /v1/feedback/{app_id}/replies?user_id=xxx — returns all feedback entries for the specified user (including entries with no replies yet). Requires the same X-API-Key header as the ingest endpoint (both live and test keys work). X-Signal-User-Id-Signature is also required — see "HMAC signing" below. The response is { feedbacks: [...] }. Each feedback contains id, type, channel, message, status, created_at, and a replies array. Each reply has id (rp_...), sender ("developer" or "user"), message, and created_at (ISO timestamp). The replies array may be empty. Polling only — there is no webhook or push callback. All entries are returned in one response; pagination is not yet implemented. Rate limit: this endpoint is capped at 120 requests per minute per (app, IP). Legitimate polling (every 30 seconds = 2 per minute) is well under the limit. If you hit 429 too_many_requests here, slow your poll interval.

Response example

Response example — Replies
{
  "feedbacks": [
    {
      "id": "fb_omcizyzblfu2",
      "type": "bug",
      "channel": "contact",
      "message": "The map doesn't load after the update",
      "status": "in_progress",
      "created_at": "2026-04-06T12:00:00Z",
      "replies": [
        {
          "id": "rp_abc123def456",
          "sender": "developer",
          "message": "Thanks for reporting. We're looking into it.",
          "created_at": "2026-04-06T14:30:00Z"
        },
        {
          "id": "rp_xyz789ghi012",
          "sender": "user",
          "message": "Still broken on v2.1.1",
          "created_at": "2026-04-07T09:00:00Z"
        }
      ]
    }
  ]
}

User Data Deletion

Use this endpoint to permanently delete all data for a specific user — required for GDPR (right to erasure) and Apple Guideline 5.1.1(v) compliance.

DELETE — User data removal
DELETE https://api.signalshoot.com/v1/feedback/{app_id}/user/{user_id}
X-API-Key: fb_live_YOUR_KEY
X-Signal-User-Id-Signature: <HMAC hex>

Permanently delete all feedback and replies for a specific user_id. Required for Apple Guideline 5.1.1(v) and GDPR Article 17 compliance. Must be called from your backend server with a live API key — test keys are rejected with 403 test_key_forbidden. The X-Signal-User-Id-Signature header is required — see "HMAC signing" in the API Reference.

Response example — 200
{
  "deleted_feedbacks": 3,
  "deleted_replies": 5
}

Monthly submission limits

Free200/month (total across all apps), 1 app. Once the limit is reached, new writes are refused with 429 monthly_limit_exceeded. Test-key submissions, follow-ups (with parent_id), and developer replies do not count.
Pro1,000/month (total across all apps), 5 apps. Once the limit is reached, new writes are refused with 429 monthly_limit_exceeded. Test-key submissions, follow-ups (with parent_id), and developer replies do not count.
InfiniteUnlimited

Error Responses

400Invalid request body or parameter. Returned when: the JSON cannot be parsed (invalid_body); the required "type" field is missing, empty, or > 50 characters (invalid_type); the required "message" field is missing or empty (invalid_message), or > 5,000 characters (message_too_long); the "channel" field is > 100 characters (invalid_channel); "user_id" is present but not a non-empty string of 1–255 characters (invalid_user_id); "parent_id" is present but not a 1–50 character string (invalid_parent_id); "metadata" is not a plain JSON object or serializes to > 8192 bytes (invalid_metadata); a follow-up write (parent_id present) has no user_id on a strict app (user_id_required_for_followup); the replies or user-data route is called without a user_id query/path parameter (missing_user_id). The response body includes the error code listed in parentheses and a human-readable message.
401Missing, invalid, or expired API key, or a signature failure. Returned when: the X-API-Key header is not present (missing_api_key), the provided key does not match any valid key for this app (invalid_api_key), the test key has expired (test_key_expired) — test keys are valid for 1 hour after generation, or signature verification fails (signature_required / invalid_signature). If you recently regenerated your key, make sure you are using the new one — the old key is rejected immediately. Note: using a publishable key on a forbidden endpoint (GET /replies, DELETE /user-data) returns 403 pub_key_forbidden, NOT 401, because the key itself is valid — only the operation is not. Likewise, ingest with a publishable key but a missing or mismatched Origin returns 403 origin_not_allowed.
403Forbidden. Returned by several distinct scopes: (1) "origin_not_allowed" — fb_pub_* request whose Origin header is missing, malformed, or not in the app's allowlist; also returned when the allowlist is empty; (2) "pub_key_user_features_forbidden" — fb_pub_* on /v1/ingest with user_id or parent_id in the body (publishable keys are write-only and cannot participate in user-scoped flows); (3) "pub_key_forbidden" — fb_pub_* on GET /replies or DELETE /user-data; (4) "test_key_forbidden" — fb_test_* on DELETE /user-data (destructive operations require a live key); (5) "parent_user_mismatch" — strict-mode follow-up whose body.user_id does not match the parent feedback's user_id, i.e. a leaked key trying to inject replies into another user's thread.
404App not found. Returned when the app_id in the URL does not match any registered app in the database. Double-check that you are using the correct App ID from your Settings page (it looks like fb_a1b2c3d4e5). This error is also returned when using parent_id if the referenced parent feedback does not exist or does not belong to the specified app_id.
413Payload too large. The request body on POST /v1/ingest must include a valid Content-Length header and the total body is capped at 64 KB. Missing or malformed Content-Length on body-bearing requests, or oversized bodies, are rejected with 413 before the body is parsed. GET and DELETE endpoints are not affected.
429Rate limit exceeded. Four distinct conditions return 429: - too_many_requests (burst): more than 10 submissions per minute for the same app. Applies to live, test, and publishable keys. - too_many_requests (pub key): fb_pub_* requests are capped at 30/min per (app, IP). Live and test keys are unaffected. - too_many_requests (replies): GET /replies is capped at 120/min per (app, IP). - monthly_limit_exceeded: monthly limit reached. Writes are refused until the next billing period. For burst 429s, wait 60 seconds and retry. For monthly_limit_exceeded, do NOT retry until the next period.
500Internal server error. Two error codes: (1) "internal_error" — an unexpected server error occurred. No internal details are exposed in the response. Safe to retry with a short backoff; if persistent, report it with the app_id and timestamp. (2) "signing_master_not_configured" — a server-side configuration issue. Not fixable from the client; retries will not succeed until the server is reconfigured. Neither code is ever used to disguise authentication failures — a bad signature always returns 401.

Rate limit behavior

Signal shoot does not send a Retry-After header. Published limits: 10 submissions per minute per app (burst, all keys), 30 fb_pub_* requests per minute per (app, IP), 120 GET /replies requests per minute per (app, IP). For too_many_requests wait at least 60 seconds before retrying. Once the monthly limit is reached (Free: 200, Pro: 1,000), writes are refused with 429 monthly_limit_exceeded. The count resets on the first day of each month (UTC).

Authentication & Keys

Key types

TypePrefixPurpose
Live keyfb_live_*Production use. Grants access to all endpoints. Store on your backend server — never embed in client-side JavaScript.
Test keyfb_test_*Development use. Expires 1 hour after generation. Test data does not count toward the monthly limit. DELETE /user-data returns 403.
Publishable keyfb_pub_*For browser-side ingest. Write-only (user_id / parent_id forbidden, /replies and /user-data forbidden). Protected by Origin allowlist + per-IP rate limit. See Publishable key section below.

Authentication matrix

One table summarizing which key types reach which endpoint and whether HMAC signing is required.

EndpointLiveTestPublishableHMAC
POST /ingest (no user_id)✓ (1h TTL)✓ (Origin required, 30/min)no
POST /ingest + user_id✓ (1h TTL)✗ user_id forbiddenyes
POST /ingest + parent_id✓ (1h TTL)✗ parent_id forbiddenyes
GET /replies✓ (120/min)✓ (1h, 120/min)✗ read forbiddenyes
DELETE /user-data✗ delete forbidden✗ delete forbiddenyes

HMAC signing

All apps require an HMAC-SHA256 signature over the user_id on every request that references one. This includes POST /v1/ingest with user_id (or parent_id), GET /v1/feedback/{app_id}/replies, and DELETE /v1/feedback/{app_id}/user/{user_id}. The signing key for your app is shown in dashboard Settings under the "Signing Key" panel.

Canonical signing rules: the value passed to HMAC is the raw UTF-8 bytes of user_id, with no trim and no normalization. For routes where user_id appears in a URL (replies query, user-data delete path), sign the URL-decoded logical value, not the URL-encoded form. The signature is sent in the X-Signal-User-Id-Signature header as 64 hex characters (case-insensitive). When you rotate the signing key, the previous version is also accepted for a 7-day grace window.

Code examples for computing the HMAC signature in each language. Get your signing_secret from Settings → Signing Key.

Node.js
HMAC sign user_id (Node.js)
import { createHmac } from "node:crypto";

// Hex string from the "Signing Key" panel in dashboard Settings.
const SIGNING_SECRET = process.env.SIGNAL_SHOOT_SIGNING_SECRET;

function signUserId(secretHex, userId) {
  const key = Buffer.from(secretHex, "hex");
  return createHmac("sha256", key).update(userId, "utf8").digest("hex");
}

const signature = signUserId(SIGNING_SECRET, "u_a1b2c3");

// Send on every request that references this user_id:
//   X-Signal-User-Id-Signature: <signature>
Python
HMAC sign user_id (Python)
import hmac
import hashlib
import os

SIGNING_SECRET = os.environ["SIGNAL_SHOOT_SIGNING_SECRET"]  # hex from dashboard

def sign_user_id(secret_hex: str, user_id: str) -> str:
    key = bytes.fromhex(secret_hex)
    return hmac.new(key, user_id.encode("utf-8"), hashlib.sha256).hexdigest()

signature = sign_user_id(SIGNING_SECRET, "u_a1b2c3")

# Pass it as request header:
#   X-Signal-User-Id-Signature: <signature>
cURL (openssl)
HMAC sign user_id (shell)
SIGNING_SECRET="..."   # hex from dashboard Settings
USER_ID="u_a1b2c3"

SIG=$(printf "%s" "$USER_ID" | openssl dgst -sha256 -mac HMAC \
  -macopt hexkey:"$SIGNING_SECRET" -hex | awk '{print $NF}')

curl -X GET \
  "https://api.signalshoot.com/v1/feedback/MY_APP_ID/replies?user_id=$USER_ID" \
  -H "X-API-Key: fb_live_YOUR_KEY" \
  -H "X-Signal-User-Id-Signature: $SIG"

Publishable key (browser-side)

Publishable keys (`fb_pub_*`) let a browser-side SPA send feedback directly to /v1/ingest without a backend proxy. They are write-only and strictly scoped: a request with user_id or parent_id is refused with 403 pub_key_user_features_forbidden, and any call to GET /replies or DELETE /user-data is refused with 403 pub_key_forbidden. Pub keys are rate-limited at 30 requests per minute per (app, IP). Generate one from dashboard Settings → "Publishable Key (Web SPA)". The plaintext value is shown exactly once at generation time.

Every fb_pub_* request must include an Origin header that exactly matches one entry in the app's Origin allowlist. Comparison is performed against the canonical form `scheme://host[:port]` — scheme and host are lowercased, default ports (http:80 / https:443) are stripped, and path / query / wildcard / userinfo are not allowed. An empty allowlist disables the publishable key entirely. Note: the Origin check is a browser-oriented guard; non-browser clients can forge the Origin header.

Example: sending feedback directly from browser JavaScript with a publishable key. The browser sets the Origin header automatically; it must match an entry in the app's Origin allowlist.

Browser fetch — publishable key
// Generated once from dashboard Settings → "Publishable Key".
// Safe to embed in client-side JavaScript.
const PUB_KEY = "fb_pub_YOUR_KEY";

await fetch("https://api.signalshoot.com/v1/ingest/MY_APP_ID", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-API-Key": PUB_KEY,
  },
  // The browser sets Origin automatically; it must exactly match
  // one entry in the app's Origin allowlist (scheme + host + port).
  body: JSON.stringify({
    type: "feedback",
    channel: "in-app",
    message: "Loving the redesign!",
    // user_id and parent_id are NOT allowed with a publishable key.
  }),
});