Propose a write to a connected account (reply to a review, update a listing, set keywords, upload screenshots, change Apple Ads bids / keywords / negatives); always start with dry_run=true.
Computes the before → after diff from the live account and runs guardrails (policy / tone / claims
probabilities for text, plus deterministic checks like character limits and bid jumps). Free.
dry_run: true→ adraft(24 h) with the diff: show it to the user. To go ahead, callPOST /v1/actions/{id}/approve.- otherwise →
pending_approval(a person approves it in the dashboard, link inapprove_url), orappliedimmediately when the org has an auto-apply rule for this type that the guardrail score clears (Studio, 1 credit). Blocking check failures (over a character limit, nothing to change, not editable) return 400 withchecks(a dry run returns them inguardrail.checks_failedinstead).
Params per type (app = a linked App Store id or package name, locale like en-US):
review.reply(asc, play_console):{ app, review_id, text }— review ids from GET /v1/connections/{id}/reviews. Play replies ≤ 350 chars.listing.update(asc, play_console):{ app, locale, fields: { name|title, subtitle, description, promotional_text, whats_new, marketing_url, support_url, privacy_policy_url } }(Play: title, short_description, full_description, video).keywords.set(asc):{ app, locale, keywords: [..] | "a,b,c", mode?: replace|add|remove }— 100 bytes comma-joined.screenshots.upload(asc, play_console):{ app, locale, display_type: APP_IPHONE_67 … (asc) | image_type: phoneScreenshots … (play), images: [{ url }], replace?: true }.ads.keyword.bid(apple_ads):{ campaign_id, ad_group_id, keywords: [{ id, bid?, status?: ENABLED|PAUSED }], max_increase?: 1 }.ads.keyword.add(apple_ads):{ campaign_id, ad_group_id, keywords: [{ text, match_type?: EXACT|BROAD, bid? }] }.ads.negative.add(apple_ads):{ campaign_id, ad_group_id?, keywords: [{ text, match_type? }] }.
Request
curl -X POST "https://api.appgoat.ai/v1/actions" \
-H "x-api-key: $APPGOAT_API_KEY" \
-H "content-type: application/json" \
-d '{"type":"review.reply","dry_run":true,"source":"playbook:reply-to-every-review","params":{"app":"1234567890","review_id":"00000043-a2fa-4f01-0000-000000000000","text":"Thanks for the detailed report! Version 2.4.1 fixes the login loop."}}'const res = await fetch("https://api.appgoat.ai/v1/actions", {
method: 'POST',
headers: {
"x-api-key": process.env.APPGOAT_API_KEY,
"content-type": "application/json",
},
body: JSON.stringify({"type":"review.reply","dry_run":true,"source":"playbook:reply-to-every-review","params":{"app":"1234567890","review_id":"00000043-a2fa-4f01-0000-000000000000","text":"Thanks for the detailed report! Version 2.4.1 fixes the login loop."}}),
});
const { data, meta } = await res.json();import os, requests
res = requests.post(
"https://api.appgoat.ai/v1/actions",
headers={"x-api-key": os.environ["APPGOAT_API_KEY"]},
json={"type":"review.reply","dry_run":True,"source":"playbook:reply-to-every-review","params":{"app":"1234567890","review_id":"00000043-a2fa-4f01-0000-000000000000","text":"Thanks for the detailed report! Version 2.4.1 fixes the login loop."}}
)
body = res.json()Request body
JSON, required.
typeActionTypeconnectionstringConnection id; optional when exactly one active connection fits the type (and params.app).
paramsobjectdry_runbooleansourcestringWho proposes it: api, mcp or playbook:<name> (shown in the approvals list).
Response
201 with data: Action. See the envelope for meta.
idstringtypeActionTypeconnection_idstringstatus"draft" | "pending_approval" | "approved" | "applied" | "failed" | "rejected"paramsobjectdiffDiffguardrailGuardrailresultanyWhat the provider returned when applied.
errorstring | nullcreated_bystringapi, mcp, playbook:<name> or dashboard.
approved_bystring | nulluser:<id>, auto (rule) or api.
rejected_reasonstring | nullcreditsintegercreated_atstringapplied_atstring | nullexpires_atstring | nullapprove_urlstring | nullDashboard page where a person approves it.
auditobject[]Only on GET /actions/{id}.
audit[].eventstringaudit[].actorstringaudit[].atstringaudit[].detailobject | nullExample
{
"data": {
"id": "act_Qm3xXk2Lw9PaZr0cVb",
"type": "review.reply",
"connection_id": "string",
"status": "draft",
"params": {},
"diff": {
"target": {},
"changes": [
{}
],
"summary": "string",
"notes": [
"string"
]
},
"guardrail": {
"status": "passed",
"score": 4.5,
"checks": {},
"warnings": [
"string"
],
"checks_failed": [
{}
],
"model": "string"
},
"result": null,
"error": "string",
"created_by": "string",
"approved_by": "string",
"rejected_reason": "string",
"credits": 1,
"created_at": "2026-10-22T10:00:00Z",
"applied_at": "2026-10-22T10:00:00Z",
"expires_at": "2026-10-22T10:00:00Z",
"approve_url": "string",
"audit": [
{
"event": "string",
"actor": "string",
"at": "2026-10-22T10:00:00Z",
"detail": {}
}
]
},
"meta": {
"credits_used": 1,
"credits_remaining": 9981,
"request_id": "req_123"
}
}Status codes
201
The action (draft, pending_approval or applied).
A parameter is missing or invalid (
error.param names it).Missing or invalid API key.
insufficient_credits (the call costs more than you have left; nothing was done) or plan_limit (a feature of a higher plan).404
not_foundNot found.
409
The state does not allow this (already applied, expired, connection read-only or invalid, account changed since the dry run).
429
rate_limitedToo many requests per second for your plan.
502
App Store Connect / Google Play / Apple Ads answered with an error (message passed through).