The Peerbeta API, for your scripts.
Create a closed test from your release pipeline, follow it day by day and fetch its report as JSON, Markdown or PDF. The same actions work through webhooks and an MCP server.
Getting started
A key belongs to an organization. Its owner or an admin creates it in Settings › API keys, with the scopes the script needs; it is shown once. Pass it in the Authorization header of every request.
curl https://api.peerbeta.com/api/v1/organization \
-H "Authorization: Bearer pbk_…"| Scope | Allows |
|---|---|
workspaces.read | Read the organization |
tests.read | Read tests and their status |
tests.write | Create tests |
reports.read | Read reports (JSON, Markdown, PDF) |
mcp.read | MCP server: read tools |
mcp.write | MCP server: write tools |
Base address: https://api.peerbeta.com/api/v1. The organization and the member always come from the key, never from the request.
Limits
- 60 requests a minute per key. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; past it, the answer is a 429 with Retry-After.
- 50 tests created a day per organization.
- A request body of 64 KB at most.
- Each call is written to the key’s journal, shown in Settings › API keys.
Create a test
POST /tests creates the app (or reuses the organization’s app with this package) and a draft test. With funding, the test is paid at once: a paid order not attached yet, the credits queue (your credits or the organization’s), or the circle. Without funding, the test waits for its payment on the web.
{
"app": {
"name": "Carnet",
"packageName": "com.example.carnet",
"category": "productivity",
"countries": ["FR", "BE"],
"playConsoleAccountId": "<id from GET /organization>"
},
"features": [
{ "title": "Search", "instruction": "Search a note with two words." }
],
"optInUrl": "https://play.google.com/apps/testing/com.example.carnet",
"funding": { "type": "credits", "wallet": "organization" }
}Send an Idempotency-Key header (1 to 100 characters): the same key with the same body returns the same test for 24 hours, without creating a second one; with another body, the answer is a 409. The answer is a 201 with a Location header, or a 200 when the idempotency key was already used.
The Play Console steps (closed track, testers’ group, countries, release) are still confirmed by the developer on the test’s page: its address is in links.dashboard.
Follow a test
GET /tests/{testId}/status tells where the test stands: the day out of 14, the testers (installed, active today, at risk, lost), the day 1, day 7 and day 14 feedback, and whether the report is ready. Rather than polling the API, subscribe to the webhooks.
curl https://api.peerbeta.com/api/v1/tests/<testId>/status \
-H "Authorization: Bearer pbk_…"Fetch the report
GET /tests/{testId}/report returns the report as JSON: numbers, synthesis, recommendations, the organization’s brand, and a signed PDF link valid for 15 minutes, without a key. With format=markdown, the answer is the report’s Markdown.
An organization’s report carries its name and logo, with a discreet mention of Peerbeta. Peerbeta prepares the production access request; Google alone decides.
Webhooks
Add an HTTPS destination in Settings › Webhooks and pick its events. Each send is signed with the destination’s secret. An answer outside 2xx is tried up to 7 times in all (after 1 min, 5 min, 30 min, 2 h, 8 h, then 24 h); the attempt journal is in the settings.
| Event | Sent when |
|---|---|
test.started | Day 1 starts: enough testers have a verified install. |
test.paused | The test is paused (by you, by Peerbeta, or a broken link). |
tester.installed | A tester installed the app, verified by the tester app. |
checkpoint.completed | A tester sent their day 1, day 7 or day 14 feedback. |
test.completed | The 14 days are over. |
report.ready | The report is written: JSON, Markdown and PDF. |
The body is a JSON envelope: id, type, created (milliseconds since 1970) and data, which always carries the test. Testers are numbers there, never a name or an address.
{
"id": "evt_3f9a2c7d1e0b4a5c6d7e8f90",
"type": "tester.installed",
"created": 1762700000000,
"data": {
"test": {
"id": "<testId>",
"status": "recruiting",
"offer": "pro",
"mode": "offer",
"app": { "id": "<appId>", "name": "Carnet", "packageName": "com.example.carnet" },
"day1At": null,
"endsAt": null,
"eligibleAt": null
},
"tester": { "number": 7, "installedAt": "2026-11-09T14:02:11.000Z", "proof": "open" },
"installs": 9
}
}Check the signature before reading the body: an HMAC SHA-256 of “timestamp.body” with the secret, compared with x-peerbeta-signature; refuse a timestamp older than five minutes. x-peerbeta-event-id lets you skip a send already handled.
import { createHmac, timingSafeEqual } from "node:crypto";
// body: the raw request body, as a string, before any JSON.parse.
export function isFromPeerbeta(body, headers, secret) {
const timestamp = headers["x-peerbeta-timestamp"];
const signature = headers["x-peerbeta-signature"] ?? "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}MCP server
An AI agent (Claude, Cursor…) does the same actions through the MCP server, over HTTP transport, with an organization key. Read tools need mcp.read; create_test needs mcp.write and tests.write.
https://api.peerbeta.com/mcpTools: list_workspaces, get_organization, list_tests, create_test, get_test_status, get_test_report.
Errors
An error returns JSON with a stable code in error, a message and, for a validation, the field at fault.
| HTTP | Code | When |
|---|---|---|
| 400 | VALIDATION | A field is missing or malformed; `field` names it. |
| 401 | UNAUTHENTICATED | No key in the Authorization header. |
| 401 | API_KEY_INVALID | Unknown, expired or revoked key. |
| 403 | FORBIDDEN | The key lacks the scope, or the API is not turned on. |
| 404 | NOT_FOUND | This test or report is not in the key’s organization. |
| 409 | CONFLICT | A test is already under way for this app, or the idempotency key was used with another body. |
| 429 | RATE_LIMITED | Too many requests; `Retry-After` says when to try again. |
Reference
| Request | Scope | Returns |
|---|---|---|
GET /organization | workspaces.read | The key’s organization: billing identity, Play Console accounts, shared credits. |
GET /tests | tests.read | The organization’s tests, newest first (`status`, `limit`, `cursor`). |
POST /tests | tests.write | Create a test (and its app when the package is new). |
GET /tests/{testId} | tests.read | One test: mode, offer, setup, dates, links. |
GET /tests/{testId}/status | tests.read | The test’s state now: day, testers, feedback, report. |
GET /tests/{testId}/report | reports.read | The report as JSON (with a signed PDF link) or Markdown (`format=markdown`). |
GET /reports/{reportId}/pdf | none | The report’s PDF, by signed link, without a key. |
The full OpenAPI 3.1 specification, with the schemas and the webhooks: https://api.peerbeta.com/api/v1/openapi.json.