Everything you need to know about Signal shoot
POST https://api.signalshoot.com/v1/ingest/{app_id}{
"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.
All 4xx responses use the same JSON shape: error (machine-readable code) and message (human-readable explanation).
{
"error": "invalid_type",
"message": "type is required and must be a string (max 50 characters)"
}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.
{
"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"
}
]
}
]
}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 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.
{
"deleted_feedbacks": 3,
"deleted_replies": 5
}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).
| Type | Prefix | Purpose |
|---|---|---|
| Live key | fb_live_* | Production use. Grants access to all endpoints. Store on your backend server — never embed in client-side JavaScript. |
| Test key | fb_test_* | Development use. Expires 1 hour after generation. Test data does not count toward the monthly limit. DELETE /user-data returns 403. |
| Publishable key | fb_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. |
One table summarizing which key types reach which endpoint and whether HMAC signing is required.
| Endpoint | Live | Test | Publishable | HMAC |
|---|---|---|---|---|
| POST /ingest (no user_id) | ✓ | ✓ (1h TTL) | ✓ (Origin required, 30/min) | no |
| POST /ingest + user_id | ✓ | ✓ (1h TTL) | ✗ user_id forbidden | yes |
| POST /ingest + parent_id | ✓ | ✓ (1h TTL) | ✗ parent_id forbidden | yes |
| GET /replies | ✓ (120/min) | ✓ (1h, 120/min) | ✗ read forbidden | yes |
| DELETE /user-data | ✓ | ✗ delete forbidden | ✗ delete forbidden | yes |
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.
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>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>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 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.
// 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.
}),
});