Developers
PoppyPR API
Read your mentions, alerts and insights from your own tools, and have new alerts pushed to you. The API is read-only: a key can never change or delete anything.
Getting started
API access is part of the Agency & Enterprise plan. An owner or admin creates a key in the app under Integrations. The key is shown once, so store it somewhere safe.
Every address below starts with your PoppyPR site followed by /api/v1. Responses are JSON.
curl https://YOUR-POPPYPR-SITE/api/v1/mentions \
-H "Authorization: Bearer ppr_live_your_key"Authentication
Send your key in the Authorization header on every request.
Authorization: Bearer ppr_live_your_keyKeys belong to one organisation and only ever return that organisation's data. Keys can only make GET requests; anything else is refused with 403. Revoke a key in the app and it stops working immediately. Treat a key like a password: keep it on your server, never in a web page or mobile app.
Mentions
Everything PoppyPR has matched to your brands, newest first, 30 at a time.
| brand | Only this brand's mentions. Use an id from /brands. |
| scope | Leave out for your own brands. competitors for tracked competitors, all for both. |
| sentiment | positive, neutral, mixed, negative, or unrated for mentions that haven't been analysed. |
| platform | news, reddit, x, youtube or reviews. |
| product | Only mentions tagged with this product name. |
| status | new, reviewed or dismissed. Leave out for everything not dismissed; all for everything. |
| q | Search headlines and publishers. |
| before | For the next page: the next_before value from the previous response. |
{
"mentions": [
{
"id": "1fa0d8e0-f77a-45cb-bcd9-9d4fca407d52",
"brand_id": "8ce6304a-fd69-47f2-a0b6-104dfbb334bf",
"brand_name": "Loomwell",
"title": "Loomwell issues recall of heated blankets",
"url": "https://news.example.com/loomwell-recall",
"publisher": "Example Wire",
"author": "Dana Reporter",
"platform": "news",
"published_at": "2026-11-10T14:02:00Z",
"created_at": "2026-11-10T14:09:31Z",
"status": "new",
"analysis_state": "done",
"sentiment": "negative",
"severity": "high",
"summary": "Loomwell is recalling its heated blankets after overheating reports.",
"topics": ["recall", "product safety"],
"products": ["Heated Blanket"],
"recommended_action": "Publish clear recall instructions.",
"competitor": false
}
],
"next_before": "2026-11-10T14:09:31.414705Z"
}next_before is null on the last page. analysis_state is done when AI analysis ran; otherwise sentiment is empty and the mention was kept on its name match alone. severity runs none, low, elevated, high, critical.
To download up to 5,000 mentions as a spreadsheet, call /mentions/export with the same filters. It returns CSV.
Alerts
Issues PoppyPR has flagged, open ones first.
| status | open, acknowledged or resolved. Leave out for all. |
One alert, with the mentions behind it in mentions and the drafted response plan in response_plan if one exists.
{
"alerts": [
{
"id": "0b6c1d2e-93a4-4f0e-8d5b-2a1f7c9e4b10",
"brand_id": "8ce6304a-fd69-47f2-a0b6-104dfbb334bf",
"brand_name": "Loomwell",
"kind": "crisis",
"title": "Loomwell issues recall of heated blankets",
"summary": "A safety recall is being widely reported.",
"detected_by": "ai",
"status": "open",
"created_at": "2026-11-10T14:09:32Z",
"mention_ids": ["1fa0d8e0-f77a-45cb-bcd9-9d4fca407d52"],
"response_plan": null
}
]
}kind is crisis, rising or notable (good news). detected_by is ai, keywords (alarming words in a headline while AI was unavailable) or volume (negative coverage arriving much faster than usual).
Insights
The figures behind the Insights page for a period: totals, sentiment, sources, topics, products, a day-by-day series, and every brand side by side including competitors.
| days | 7, 30 or 90. Defaults to 30. |
| brand | Limit the figures to one of your brands. |
{
"days": 30,
"stats": {
"total": 131,
"sentiment": { "positive": 40, "neutral": 61, "mixed": 8, "negative": 22, "unrated": 0 },
"previous_total": 97,
"previous_negative": 9,
"alerts": { "crisis": 1, "rising": 4 },
"platforms": [{ "name": "news", "count": 104 }, { "name": "reddit", "count": 27 }],
"publishers": [{ "name": "Example Wire", "count": 12 }],
"topics": [{ "name": "recall", "count": 18 }],
"products": [{ "name": "Heated Blanket", "total": 19, "positive": 3, "negative": 11 }],
"series": [{ "date": "2026-11-10", "own": 14, "negative": 6, "positive": 2, "competitors": 9 }],
"brands": [
{ "id": "8ce6…", "name": "Loomwell", "competitor": false, "total": 131,
"positive": 40, "neutral": 61, "negative": 22, "mixed": 8, "open_alerts": 2 }
]
}
}Brands
Your brand profiles and the competitors you track, with their keywords and products.
{
"brands": [
{
"id": "8ce6304a-fd69-47f2-a0b6-104dfbb334bf",
"name": "Loomwell",
"kind": "business",
"website": "loomwell.com",
"handle": "",
"terms": ["Loomwell", "Loom Well"],
"exclude_terms": ["hotel"],
"products": ["Heated Blanket", "Cloud Throw"],
"is_competitor": false,
"mentions_30d": 131
}
]
}RSS feeds
The feeds your organisation has added under RSS feeds, and the publications PoppyPR already reads for everyone. Feeds are added and removed in the app.
{
"feeds": [
{
"id": "5d1c2a77-0c1e-4c58-9a5e-3f0a8d1b6e42",
"name": "Home Textiles Today",
"url": "https://www.hometextilestoday.com/feed/",
"added_at": "2026-11-02T09:15:00Z",
"last_checked_at": "2026-11-11T08:30:12Z",
"status": "ok",
"items_30d": 84,
"mentions_30d": 3
}
],
"limit": 25,
"built_in": ["Ars Technica", "BBC News — Business", "TechCrunch"],
"built_in_sources": [
{
"id": "cf141817-c62f-4dc5-a60d-d2eaef8b0811",
"name": "TechCrunch",
"url": "https://techcrunch.com/feed/",
"website_url": "https://techcrunch.com",
"category": "Tech",
"last_checked_at": "2026-11-11T08:30:12Z",
"status": "ok"
}
]
}status is waiting until the first check, then ok, or failing when the feed can't be read. Mentions found through a feed appear in /mentions like any other.
built_in_sources lists active shared feeds with publisher websites, topics and feed health. The app uses the publisher website to load its favicon. built_in keeps the names-only list for existing clients. Shared news is kept only when its headline or summary matches a monitored brand's keywords or website.
Errors
Errors use ordinary HTTP status codes and always have the same shape. message is written to be shown to a person.
{ "error": { "code": "bad_api_key", "message": "That API key is not valid." } }| 401 | The key is missing, wrong or revoked, or the plan no longer includes API access. |
| 403 | The request tried to change something. Keys are read-only. |
| 402 | The feature isn't part of the organisation's plan. |
| 404 | Nothing with that id belongs to your organisation. |
| 429 | Too many requests. Your keys share 120 requests a minute; wait a moment and try again. |
Webhooks
Add an https:// address under Integrations and PoppyPR sends a POST to it the moment an alert is raised. You are shown a signing secret once when you add it.
POST /your/endpoint
Content-Type: application/json
X-PoppyPR-Signature: t=1794362235,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"event": "alert.created",
"alert": {
"id": "0b6c1d2e-93a4-4f0e-8d5b-2a1f7c9e4b10",
"kind": "crisis",
"title": "Loomwell issues recall of heated blankets",
"summary": "A safety recall is being widely reported.",
"brand": "Loomwell",
"created_at": "2026-11-10T14:09:32Z",
"url": "https://YOUR-POPPYPR-SITE/app/alerts?id=0b6c1d2e-93a4-4f0e-8d5b-2a1f7c9e4b10"
}
}Answer with any 2xx status within 10 seconds. If your endpoint errors or times out, we retry a few times with increasing gaps; a 4xx answer (other than 429) is not retried.
Check the signature before trusting a delivery. Take the t value, a full stop, and the exact request body; compute an HMAC-SHA256 of that with your signing secret; compare it to v1. Reject deliveries whose t is more than a few minutes old.
import crypto from "node:crypto";
export function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts["v1"], expected)Slack: paste a Slack incoming-webhook address (it starts with https://hooks.slack.com/) and alerts arrive as formatted messages in that channel. Nothing else to set up.