Quick Start

The Attention Factory API is rentals-and-actions first. You rent a real, verified social account for a whole month, then submit actions (posts, comments, likes, follows, branding changes) against it. This guide walks through your first rental in five steps.

1. Sign up and get your API key

Create your account on the dashboard, then generate an API key from the API Keys page. Every request below requires it in theAuthorization header.

2. Create a rental

Pick a platform and a duration (in whole months). Optionally supply filters, a branding preset, or an opt-in warming niche. The response is always 202 Accepted — the rental may be active immediately, orwarming while the account is prepared.

POST /v1/rentals
curl -X POST https://api.attentionfactory.com/v1/rentals \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "instagram",
    "months": 1,
    "auto_renew": true
  }'
Response
{
  "id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
  "anonymized_id": null,
  "platform": "instagram",
  "rental_start": null,
  "rental_end": null,
  "monthly_rate": "99.00",
  "credits_charged": "99.00",
  "auto_renew": true,
  "link_analytics": true,
  "status": "warming",
  "expires_pending_at": "2026-05-02T12:00:00Z",
  "branding_preset": null,
  "created_at": "2026-04-18T12:00:00Z",
  "profile": null
}

3. Wait for activation

Poll the rental or register a webhook for rental.active. Once status flips toactive with a non-null anonymized_id, the account is yours for the rental window. On active rentals, the response includes a live profile object with the account's real username, display name, avatar, and follower counts.

GET /v1/rentals/{id}
curl https://api.attentionfactory.com/v1/rentals/4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3 \
  -H "Authorization: Bearer af_live_..."
Response (active rental)
{
  "id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
  "anonymized_id": "7c2d9f5b-e48a-4c11-8b3f-91c2e4d8a6b7",
  "platform": "instagram",
  "rental_start": "2026-04-18T12:30:00Z",
  "rental_end": "2026-05-18T12:30:00Z",
  "monthly_rate": "99.00",
  "credits_charged": "99.00",
  "auto_renew": true,
  "link_analytics": true,
  "status": "active",
  "expires_pending_at": null,
  "branding_preset": null,
  "created_at": "2026-04-18T12:00:00Z",
  "profile": {
    "username": "launchpad_labs",
    "display_name": "Launchpad",
    "avatar_url": "https://cdn.example.com/avatars/launchpad.jpg",
    "follower_count": 2340,
    "following_count": 180,
    "post_count": 47
  }
}

4. Submit an action

Actions are scoped to a rental + platform. The body carries atype (e.g.post.image) and action-specificparams. The rental must beactive.

POST /v1/rentals/{rental_id}/{platform}/actions
curl -X POST https://api.attentionfactory.com/v1/rentals/$RENTAL_ID/instagram/actions \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: launch-post-001" \
  -d '{
    "type": "post.image",
    "params": {
      "caption": "Our new launch is live.",
      "media_url": "https://cdn.example.com/launch.jpg"
    }
  }'

5. Track completion via webhook

Register a webhook once and receive action status transitions (action.assigned, action.submitted, action.verified, etc.) in near-real-time.

POST /v1/webhooks
curl -X POST https://api.attentionfactory.com/v1/webhooks \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://acme.com/webhooks/af",
    "events": ["rental.active", "action.verified", "action.failed"]
  }'

Authentication

All API requests require an API key passed in the Authorization header.

Authorization: Bearer af_live_...

API Key Format

API keys have the form af_live_<random> and are shown in full only once, at creation. Keys are generated and revoked from the API Keys page in the dashboard.

Platforms

Discovery endpoints for supported platforms, the actions they expose, and the niches available for opt-in warming. Call these before creating a rental to validate input client-side.

Endpoints

GET
/v1/platforms

List supported platforms

GET
/v1/platforms/{platform}/capabilities

Action types and per-rental rate caps for a platform

GET
/v1/platforms/{platform}/niches

Valid warming_niche values for opt-in warming

List Platforms

GET /v1/platforms
curl https://api.attentionfactory.com/v1/platforms \
  -H "Authorization: Bearer af_live_..."
Response
{
  "platforms": ["instagram", "tiktok", "reddit", "facebook", "linkedin"]
}

Platform Capabilities

Returns the action types available on a platform and the rate caps applied per rental. Use this to know which type values are valid inPOST .../actions.

GET /v1/platforms/{platform}/capabilities
curl https://api.attentionfactory.com/v1/platforms/instagram/capabilities \
  -H "Authorization: Bearer af_live_..."

Niche Catalog

Opt-in warming pre-primes a rented account for a specific audience over a 5-day pre-activation window. Use one of these values as warming_niche when creating a rental. Unknown values are rejected with a 400 before any credits are charged.

GET /v1/platforms/{platform}/niches
curl https://api.attentionfactory.com/v1/platforms/instagram/niches \
  -H "Authorization: Bearer af_live_..."
Response
{
  "platform": "instagram",
  "niches": ["fitness", "food", "tech-reviews", "travel"]
}

Rent an Account

Reserve a verified social account for a whole month. Pricing is monthly; actions incur no per-call charges. Every rental is charged upfront; if warming doesn't complete within 14 days the rental auto-refunds.

Endpoints

POST
/v1/rentals

Create a rental (returns 202)

GET
/v1/rentals

List your rentals (supports ?status=: warming · active · expired · refunded · cancelled · released)

GET
/v1/rentals/{id}

Get rental detail

PATCH
/v1/rentals/{id}

Update auto_renew (and other mutable fields)

DELETE
/v1/rentals/{id}

Release early or cancel a pending rental

POST
/v1/rentals/{id}/extend

Extend an active rental by N months

Create Rental

POST /v1/rentals
curl -X POST https://api.attentionfactory.com/v1/rentals \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-rental-001" \
  -d '{
    "platform": "instagram",
    "months": 1,
    "auto_renew": true,
    "warming_niche": "tech-reviews",
    "branding": {
      "bio": "Builder. Coffee. Code.",
      "display_name": "Launchpad"
    }
  }'

Request Body

FieldTypeRequiredDescription
platformstringYesOne of: instagram, tiktok, reddit, facebook, linkedin
monthsintegerNo1–12. Defaults to 1
auto_renewboolNoAuto-renew when the rental window closes. Defaults to true
link_analyticsboolNoWhether this account may be linked to the analytics provider. Defaults to true
warming_nichestringNoOpt in to 7-day pre-warming on this niche. Must be in the platform's niche catalog
brandingobjectNoBranding preset applied automatically on activation
labelstringNoYour own nickname for the account, max 60 chars. Never sent to the platform — distinct from branding display_name. Rename later with POST /v1/rentals/{id}/label

The branding object takes the same field set as the branding endpoint — the five core fields (bio, display_name, avatar_url, banner_url, username) plus every platform-specific one listed under . Each supplied field is applied as a queued action the moment the rental activates. Fields the platform does not support are rejected at request time, not silently dropped.

Rental Status Values

StatusMeaning
warmingPre-activation. The account is being prepared and actions cannot be submitted yet. expires_pending_at indicates when the rental auto-refunds if warming doesn't complete
activeRental is live. Submit actions against the rental
expiredRental window closed naturally (auto-renew disabled or failed)
refundedCredits returned. Warming did not complete within the 14-day guarantee window
releasedCustomer released the rental early. No refund (month-committal)
cancelledCustomer cancelled while still pre-activation. Credits refunded

Extend / Renew

POST /v1/rentals/{id}/extend
curl -X POST https://api.attentionfactory.com/v1/rentals/$RENTAL_ID/extend \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -d '{"months": 2}'

Toggle Auto-Renew

PATCH /v1/rentals/{id} updates mutable rental fields. Today the only customer-controllable field is auto_renew; flip it any time before the rental window closes. Returns the updated rental.

PATCH /v1/rentals/{rental_id}
curl -X PATCH https://api.attentionfactory.com/v1/rentals/$RENTAL_ID \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -d '{"auto_renew": false}'

Brand an Account

Change your rented account's bio, display name, avatar, banner, and username in one request. The server fans out to one queued action per field you provide. Rate caps and review modes apply per sub-action.

Endpoint

POST
/v1/rentals/{id}/branding

Apply bio / display name / avatar / banner / username in one batch

Field support differs by platform — the sample below changes as you switch platforms. Fields not supported on the selected platform are simply omitted from the example.

Known limitation — six fields are preset-only today. pronouns, gender, category, links, content_visibility and website apply correctly when sent before the rental activates (the preset_stored path). Sent against an already-active rental they come back in results with action_id: null and status: "failed" — note the HTTP response is still 200, so check per-field status, not just the status code. Set them at rental creation via the branding object, or while the rental is still warming. A fix is in progress.

Platform

POST /v1/rentals/{rental_id}/branding (instagram)
curl -X POST https://api.attentionfactory.com/v1/rentals/$RENTAL_ID/branding \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -d '{
  "bio": "Builder. Coffee. Code.",
  "display_name": "Launchpad",
  "avatar_url": "https://cdn.example.com/avatar.jpg",
  "username": "launchpad_labs",
  "pronouns": "she/her",
  "category": "Artist"
}'
Response (rental is active — mode: applied)
{
  "rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
  "platform": "instagram",
  "mode": "applied",
  "results": [
    {"field": "bio", "action_type": "branding.bio", "action_id": "9ab72c01-...", "status": "queued"},
    {"field": "display_name", "action_type": "branding.display_name", "action_id": "f2c91e45-...", "status": "queued"}
  ],
  "preset": null
}

Each field becomes its own action, so every entry in results carries a distinct action_id you can join your action.* webhooks onto. At least one field must be provided (empty bodies return 400).

If the rental has not activated yet (still warming), the request returns mode: "preset_stored" instead. The batch is saved and applied automatically when the rental activates. Re-posting merges into the stored preset — fields you omit keep their previous value, and sending a field as null clears it.

Response (rental not yet active — mode: preset_stored)
{
  "rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
  "platform": "instagram",
  "mode": "preset_stored",
  "results": null,
  "preset": {
    "bio": "Builder. Coffee. Code.",
    "display_name": "Launchpad"
  }
}
On preset_stored, there are no action ids — and there may never be any. results is null because no actions exist yet. When the rental activates, branding is sometimes applied by the account provider as part of provisioning rather than as a tracked action — in that case no action.* webhooks fire for it and nothing appears under GET /v1/rentals/{id}/actions. Do not block on those webhooks. To confirm branding landed, read profile on GET /v1/rentals/{id} once the rental is active — it reflects the account's live username, display name and avatar.

Warm an Account

Enroll your rented account in a warming schedule — the app gradually builds authentic engagement in the niche you name, over a 5-day window. Pace and duration are managed server-side — the request takes a niche only. You can pre-warm at rental creation via the warming_niche field, or enroll post-creation via this endpoint.

Endpoints

POST
/v1/rentals/{id}/warming

Enroll the rental in a warming schedule

DELETE
/v1/rentals/{id}/warming

Cancel a pending warming enrolment

Request Body

FieldTypeDefaultDescription
nichestringOptional, max 50 chars. See GET /v1/platforms/{platform}/niches for the platform's catalog. Pace and duration are managed server-side

Enroll

POST /v1/rentals/{rental_id}/warming
curl -X POST https://api.attentionfactory.com/v1/rentals/$RENTAL_ID/warming \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -d '{"niche": "tech-reviews"}'
Response
{
  "rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
  "action_id": "9ab72c01-5d62-4f0b-a8cc-1d4a7b2fc309",
  "status": "queued"
}

Cancel

DELETE /v1/rentals/{rental_id}/warming
curl -X DELETE https://api.attentionfactory.com/v1/rentals/$RENTAL_ID/warming \
  -H "Authorization: Bearer af_live_..."

Cancels the most recent warming enrolment on the rental if it's still queued. 404 if there's no matching active enrolment.

Submit Actions

Actions are the unit of work performed on a rented account. They run asynchronously on real devices: the request returns 202 Accepted with a queued status, and the final state arrives via webhook or poll.

Endpoints

POST
/v1/rentals/{rental_id}/{platform}/actions

Submit a new action

GET
/v1/rentals/{rental_id}/actions

List actions on a rental (supports ?status=: queued · assigned · in_progress · submitted · verified · failed · cancelled)

GET
/v1/rentals/{rental_id}/actions/{action_id}

Get action detail

DELETE
/v1/rentals/{rental_id}/actions/{action_id}

Cancel a still-queued action

GET
/v1/rentals/{rental_id}/actions/{action_id}/analytics

Post-level analytics for post-type actions

Submit Action

The URL carries both the rental and platform; the body carries the actiontype and typedparams. Supply anIdempotency-Key header for retries — submitting twice with the same key returns the existing action instead of creating a duplicate. Availability varies by platform; pick a platform and an action below to see the exact request and response. Rate limits also vary per platform and action — call GET /v1/platforms/{platform}/capabilities for the definitive live list with caps.

Platform

Action

Publish a single image with optional caption. Supported on Instagram, Facebook, X, LinkedIn. All TikTok/IG composition options from the shared PostOptionsMixin apply: sound_name, location, link_url, allow_comments, allow_duet, allow_stitch, ai_generated, allow_visual_search, audience_control. LinkedIn composer options also accepted (ignored on other platforms): `visibility` (anyone / connections_only / group), `group_url` (required when visibility="group"; must be an https://linkedin.com/... URL), `comment_control` (anyone / connections_only / no_one), `brand_partner_url` (LinkedIn company URL — presence enables the Paid Partnership label). Include only the fields you want to set — omit the rest to keep the platform default.

POST /v1/rentals/{rental_id}/instagram/actions
curl -X POST https://api.attentionfactory.com/v1/rentals/$RENTAL_ID/instagram/actions \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-action-001" \
  -d '{
  "type": "post.image",
  "params": {
    "media_url": "https://cdn.example.com/hero.jpg",
    "caption": "Launching today.",
    "location": "San Francisco, CA",
    "allow_comments": true,
    "audience_control": "everyone"
  }
}'
Response
{
  "id": "9ab72c01-5d62-4f0b-a8cc-1d4a7b2fc309",
  "rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
  "platform": "instagram",
  "action_type": "post.image",
  "status": "queued",
  "result_url": null,
  "platform_result_id": null,
  "created_at": "2026-04-19T12:05:00Z"
}

Replying to a Comment

reply needs to say which comment, and the platforms genuinely differ on what they let you point at. There are two addressing modes, and you must complete one of them.

ModeFieldsPlatforms
Permalinktarget_comment_urlReddit, LinkedIn, X, Facebook
Locate on the posttarget_post_url + target_comment_author + target_comment_excerptRequired on Instagram and TikTok; works everywhere
Instagram and TikTok publish no per-comment permalink at all. There is no URL to send, so mode 1 cannot be used on those two platforms and a reply that omits the mode-2 fields is rejected with a 422. This is the same reason item_url is always null on Instagram and TikTok entries in a monitor's item ledger.
Mode 2 — Instagram / TikTok
{
  "type": "reply",
  "params": {
    "target_post_url": "https://www.instagram.com/p/CxYzAbC123/",
    "target_comment_author": "dsmooth425",
    "target_comment_excerpt": "how long did the edit take you?",
    "text": "About two hours, most of it colour grading."
  }
}

The excerpt is what disambiguates one handle that left several comments on the same post — the common case on a busy reel — so send enough of the text to be unique. target_comment_author is a bare handle with no leading @. Sending both modes is accepted and the permalink wins, but prefer sending one: two locators that disagree can produce a reply to the wrong person.

Operator Notes

note is a reserved key you may include in params on any action type. It is free text (max 2000 characters) shown to the person executing the action — use it for context a schema field has no slot for. It is not validated against the action's schema, it is stored on the action and covered by the idempotency body hash, and it is never published to the platform.

note on any action
{
  "type": "post.image",
  "params": {
    "media_url": "https://cdn.example.com/launch.jpg",
    "caption": "Shipping today.",
    "note": "Post between 9am and 11am ET if you can — that is when this audience is on."
  }
}

Action Status Values

StatusMeaning
queuedAccepted and queued for dispatch
assignedDispatched to the device running the account
in_progressExecuting on the device
submittedExecution complete; awaiting verification
verifiedTerminal: action completed successfully
failedTerminal: action failed or did not pass verification
Reviews are automatic. You don't approve actions by hand. Status transitions arrive via your existing action.* webhooks andGET /v1/rentals/{id}/actions — wait forverified or failed.

Finding What the Action Produced

Actions that publish something carry two extra fields once the operator submits the work — typically alongside status: submitted, before verified. Both appear on the action detail/list routes and in the action.* webhook payload, so you never need to poll just to obtain them.

FieldDescription
result_urlLive permalink to the published post or comment. Always an https:// URL when present.
platform_result_idThe platform's own id for the same artifact — TikTok video id, Reddit base36 id, tweet id, LinkedIn activity id, Instagram shortcode. A stable join key, where result_url is a string the platform may re-slug.
Response (verified post-type action)
{
  "id": "9ab72c01-5d62-4f0b-a8cc-1d4a7b2fc309",
  "rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
  "platform": "reddit",
  "action_type": "post.text",
  "status": "verified",
  "result_url": "https://www.reddit.com/r/python/comments/abc123/my_post/",
  "platform_result_id": "abc123",
  "created_at": "2026-04-19T12:05:00Z"
}
Both are nullable forever — never poll waiting for them. result_url is null for action types that produce nothing linkable (likes, follows, settings and branding changes, and stories, which expire), and for any action created before the field shipped. It is also cleared when work is sent back for rework or rejected.

platform_result_id is null in all of those cases and in one more: when the operator's link is a mobile share-sheet URL (vm.tiktok.com, t.co, lnkd.in), it carries an opaque redirect token rather than the id, and we will not follow the redirect to resolve it. So expect a null platform_result_id next to a perfectly good result_url — most visibly on TikTok.

Monitors

A monitor is a standing instruction: watch a post, decide which comments deserve an answer, draft one in your voice, and post it from a rented account. It polls the post's comments on a schedule, screens them with filters you set, judges the survivors with your prompts, and submits each approved reply as an ordinary action on the rental.

The model calls run on your provider key, which you connect first as a credential. The replies are ordinary actions, so they consume the rental's normal per-hour and per-day reply caps, shared with anything else you submit on that account.

Monitors start in dry run. policy.dry_run defaults to true. A dry-run monitor ingests comments, decides and drafts, and records each one in the item ledger — but publishes nothing. Read GET /v1/monitors/{id}/items, tune goal, persona, policy.qualify_prompt and your action prompts against what you see there, and only then set dry_run: false.

Endpoints

POST
/v1/credentials

Connect your LLM provider key (prerequisite)

POST
/v1/monitors

Start watching a post

GET
/v1/monitors

List your monitors

GET
/v1/monitors/{id}

Get one monitor, with health and counters

PATCH
/v1/monitors/{id}

Retune prompts, or pause and resume

DELETE
/v1/monitors/{id}

Stop the monitor, keep the ledger

GET
/v1/monitors/{id}/items

Everything it saw and what it decided

POST
/v1/optouts

Never contact this person again

GET
/v1/optouts

List opt-outs

DELETE
/v1/optouts

Remove an opt-out

Step 1 — Connect a provider key

A monitor needs a credential_id, so store your key first. Note the direction: your af_live_ key is how you authenticate to us; a credential is how we authenticate to Anthropic, OpenAI, Google or xAI on your behalf. One credential per provider.

We check the key against the provider before storing it, using an endpoint that consumes no inference tokens — so validating costs you nothing. A key the provider rejects is not stored and returns 400. A key we could not check because the provider was unreachable is stored with verified: false; promote it later with POST /v1/credentials/{id}/verify. The key itself is never returned by any route, on any status code.

POST /v1/credentials
curl -X POST https://api.attentionfactory.com/v1/credentials \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "anthropic",
    "api_key": "sk-ant-...",
    "label": "Monitors",
    "default_model": "claude-opus-5"
  }'
Response
{
  "id": "b2f0c7d4-9e13-4a55-8c21-6f7e9a0b1c2d",
  "provider": "anthropic",
  "label": "Monitors",
  "key_last4": "9f2a",
  "default_model": "claude-opus-5",
  "is_active": true,
  "verified": true,
  "last_verified_at": "2026-08-23T09:41:12Z",
  "last_error_code": null,
  "created_at": "2026-08-23T09:41:12Z",
  "updated_at": null
}

provider is one of anthropic, openai, google, xai. Only Anthropic has a default model; name one explicitly for the others, either here as default_model or per-monitor as model.

Step 2 — Create the monitor

The body is nested rather than flat, because one field constrains another — target.type decides which actions and which qualifying options are even legal. Unknown fields are rejected with a 422 rather than ignored, so a misspelled prompt field fails loudly instead of presenting as “the model is ignoring my instructions”.

POST /v1/monitors
curl -X POST https://api.attentionfactory.com/v1/monitors \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
    "credential_id": "b2f0c7d4-9e13-4a55-8c21-6f7e9a0b1c2d",
    "target": {
      "url": "https://www.reddit.com/r/explainlikeimfive/comments/1vw984r/eli5_why_is_stiil_water_dangerous/"
    },
    "persona": "A water treatment engineer. Explains things plainly, never pitches.",
    "goal": "Get people trying the filter.",
    "policy": {
      "dry_run": true,
      "guardrail_prompt": "Reject anything naming a competitor or promising a health outcome.",
      "qualify_prompt": "Someone asking a concrete question about water safety or treatment.",
      "min_length": 80,
      "max_age_hours": 48,
      "skip_if_contains": ["giveaway", "promo code"]
    },
    "actions": [
      {
        "type": "reply",
        "prompt": "Answer the specific question in two or three sentences. Plain prose.",
        "limits": { "total": 20, "per_author": 1, "per_day": 5 }
      }
    ],
    "schedule": { "poll_interval_hours": 6 }
  }'
201 Created
{
  "id": "7c3d1e08-4b6a-4f21-9d0e-5a8c3b1f7e42",
  "rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
  "credential_id": "b2f0c7d4-9e13-4a55-8c21-6f7e9a0b1c2d",
  "platform": "reddit",
  "label": null,
  "target": {
    "url": "https://www.reddit.com/r/explainlikeimfive/comments/1vw984r",
    "type": "post",
    "comment_ref": null
  },
  "status": "active",
  "status_reason": null,
  "persona": "A water treatment engineer. Explains things plainly, never pitches.",
  "goal": "Get people trying the filter.",
  "policy": {
    "dry_run": true,
    "guardrail_prompt": "Reject anything naming a competitor or promising a health outcome.",
    "qualify_prompt": "Someone asking a concrete question about water safety or treatment.",
    "min_length": 80,
    "skip_authors": [],
    "skip_if_contains": ["giveaway", "promo code"],
    "max_age_hours": 48,
    "top_level_only": false
  },
  "actions": [
    {
      "type": "reply",
      "prompt": "Answer the specific question in two or three sentences. Plain prose.",
      "limits": { "total": 20, "per_author": 1, "per_thread": null, "per_day": 5 }
    }
  ],

  "schedule": { "poll_interval_hours": 6, "expires_at": null },
  "context": { "read_comment_media": true },
  "seed": { "seeded_at": null, "external_ref": null },
  "model": null,
  "ingestion": {
    "mode": "pull",
    "consecutive_failures": 0,
    "last_polled_at": null,
    "next_poll_at": "2026-08-23T09:44:18Z",
    "coverage": "complete",
    "reported_total": null,
    "last_poll_new_items": null
  },
  "counters": {
    "items_seen": 0,
    "items_screened_out": 0,
    "items_decided": 0,
    "replies_submitted": 0
  },
  "created_at": "2026-08-23T09:41:33Z",
  "updated_at": null
}

The URL is canonicalised on the way in — a trailing slash, a tracking parameter or a different Reddit slug all collapse to one string, which is what makes “one live monitor per post” mean what it says. A second live monitor on the same rental and post returns 409: two would answer every comment twice from the same account. Note ingestion.coverage on the create response — it is populated from the platform's known behaviour before the first poll runs, so you learn up front how much of the post is readable.

Request Body

FieldTypeRequiredDescription
rental_iduuidYesThe rented account that will reply. Must be active, and on the same platform as the target URL
credential_iduuidYesWhich stored provider key pays for the model calls
target.urlstringYes10–1024 chars, https only. Reddit, TikTok or Instagram. Always the POST URL, even for a comment target
target.typeenumNopost (default) or comment to watch one comment's subtree
target.comment_refstringCond.Required when type is comment, rejected otherwise. A vendor comment id — take it from an item's external_id in the ledger. On Reddit you may instead paste a comment permalink as the URL and the id is read from it
personastringNoMax 2000. Who the account is — voice, disposition, background. Reaches every prompt. Omit it and prompts are built exactly as they were before this field existed
goalstringYesMax 2000. What the account is trying to achieve. Keep it separate from persona: put a voice in here and the filter starts judging relevance against a tone
policy.dry_runbooleanNoDefault true. The highest-consequence field in the body: the difference between drafting and publishing under a rented identity
policy.guardrail_promptstringNoMax 4000. Judged on the drafted text in its own call, so an action’s prompt cannot argue with it. Never shown the post, the thread or the persona
policy.qualify_promptstringYesMax 4000. Which comments deserve an answer
policy.min_lengthintegerNo0–10000, default 0. The cheapest filter there is, and it runs before a single token is spent
policy.skip_authorsstring[]NoUp to 200 handles. Matched case- and width-insensitively
policy.skip_if_containsstring[]NoUp to 200 terms. Plain values, never patterns
policy.max_age_hoursintegerNo1–8760. Null means no limit. Without it, a poll on an evergreen post will answer a two-year-old comment as though it just arrived
policy.top_level_onlybooleanNoDefault false. Reply only to comments that root their own thread. Not valid with a comment target
actions[]arrayYes1–4 entries, at most one per type. Two types exist today, so in practice 1–2
actions[].typeenumYesreply or comment. See below
actions[].promptstringYes1–4000 chars. How this action should be written. Separate from persona, which says who the account is
actions[].limitsobjectNoPer-action volume caps. See below
schedule.poll_interval_hoursintegerNo1–168, default 6. Polling slows automatically once a post goes quiet, and speeds back up the moment anything new appears
schedule.expires_atdatetimeNoStop the monitor at this time. Null means it runs until you stop it or the rental ends
context.read_comment_mediabooleanNoDefault true. Describe images attached to individual comments. Costs a vision call on your key, once per image ever. The watched post's own text and cover image are always used regardless
modelstringNoOverrides the credential's default_model for this monitor
labelstringNoMax 80. Your own name for it, echoed back in lifecycle webhooks

Every Field

The example above is a realistic starting point, not a complete one. Below is every field the endpoint accepts, with nothing omitted. There is no single body that can carry all of them, because a few combinations are mutually exclusive — so this is split into a post target and a comment target, and between the two every field appears.

Post target — every field that is legal here
{
  "rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
  "credential_id": "b2f0c7d4-9e13-4a55-8c21-6f7e9a0b1c2d",

  "target": {
    "url": "https://www.reddit.com/r/explainlikeimfive/comments/1vw984r",
    "type": "post"
  },

  "persona": "A water treatment engineer. Explains things plainly, never pitches.",
  "goal": "Get people trying the filter.",

  "policy": {
    "dry_run": true,
    "guardrail_prompt": "Reject anything naming a competitor or promising a health outcome.",
    "qualify_prompt": "Someone asking a concrete question about water safety or treatment.",
    "min_length": 80,
    "skip_authors": ["automoderator", "some_competitor"],
    "skip_if_contains": ["giveaway", "promo code"],
    "max_age_hours": 48,
    "top_level_only": false
  },

  "actions": [
    {
      "type": "reply",
      "prompt": "Answer the specific question in two or three sentences. Plain prose.",
      "limits": {
        "total": 20,
        "per_author": 1,
        "per_thread": 2,
        "per_day": 5
      }
    },
    {
      "type": "comment",
      "prompt": "Open with the single most useful thing you know about this topic.",
      "limits": {
        "total": 1,
        "per_author": 1,
        "per_thread": null,
        "per_day": null
      }
    }
  ],



  "schedule": {
    "poll_interval_hours": 6,
    "expires_at": "2026-09-30T00:00:00Z"
  },

  "context": {
    "read_comment_media": true
  },

  "model": "claude-opus-5",
  "label": "eli5 water safety"
}

The only field absent here is target.comment_ref, which a post target rejects. It appears in the comment-target body below.

Comment target — the remaining field, and what it forbids
{
  "rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
  "credential_id": "b2f0c7d4-9e13-4a55-8c21-6f7e9a0b1c2d",

  "target": {
    "url": "https://www.instagram.com/p/CxYzAbC123/",
    "type": "comment",
    "comment_ref": "17925301948123456"
  },

  "persona": "...",
  "goal": "...",

  "policy": {
    "dry_run": false,
    "guardrail_prompt": "...",
    "qualify_prompt": "...",
    "min_length": 40,
    "skip_authors": ["bot_account"],
    "skip_if_contains": ["spam"],
    "max_age_hours": 72
  },

  "actions": [
    {
      "type": "reply",
      "prompt": "...",
      "limits": { "total": 10, "per_author": 1, "per_day": 3 }
    }
  ],

  "schedule": { "poll_interval_hours": 12, "expires_at": "2026-09-30T00:00:00Z" },
  "context": { "read_comment_media": false },
  "model": "gpt-5",
  "label": "reel subtree"
}

Rejected Combinations

These are refused at request time with a 422 rather than accepted and quietly ignored, because each one can only fail silently — the monitor would sit active, ingest forever, and do nothing anyone could explain.

CombinationWhy
comment_ref on a post targetOnly meaningful when type is comment
a comment action on a comment targetA comment target has no post to comment on. Use a post target to seed
top_level_only on a comment targetEvery comment inside a subtree is a reply, so nothing would ever qualify
limits.per_thread on a comment targetThe monitor already is one thread. Use limits.total
two actions of the same typeDoubles every item's model spend on your own key for nothing one entry cannot express
an empty actions arrayA monitor with no actions watches a post and never does anything
any unrecognised fieldA misspelled filter_promt would otherwise present as “the model is ignoring my instructions”

Actions and Limits

Each entry in actions is one verb, with its own prompt and its own budget. There are two:

TypeDescription
replyAnswers an ingested comment. The common case
commentPosts the account's own top-level comment on the watched post — seeding. Happens at most once per monitor, ever, and is not valid on a comment target

limits are guarantees, where the prompts are only preferences. Anything countable belongs here — a model cannot count reliably across calls and has no memory of yesterday.

FieldRangeDescription
total1–10000Lifetime cap for this action. Null means uncapped
per_author1–100Defaults to 1. Answering one person repeatedly is the most visible automation tell there is
per_thread1–100Null means uncapped. Not valid with a comment target — the monitor already is one thread
per_day1–1000The only cap with a time dimension. Without it the others bound lifetime output but not rate, so one poll can publish the whole budget at once

Step 3 — Read the ledger

This is where you find out why a comment did or did not get a reply, and it is the whole of the tuning workflow. Every comment the monitor ingested appears here with the decision it was given.

GET /v1/monitors/{monitor_id}/items
curl "https://api.attentionfactory.com/v1/monitors/$MONITOR_ID/items?state=decided&limit=50" \
  -H "Authorization: Bearer af_live_..."
Response
{
  "status": true,
  "items": [
    {
      "id": "e91c7b40-2f5d-4a18-b6c3-8d0a2e1f4b77",
      "monitor_id": "7c3d1e08-4b6a-4f21-9d0e-5a8c3b1f7e42",
      "item_type": "comment",
      "external_id": "p54bwlk",
      "thread_ref": "p54bwlk",
      "item_url": "https://www.reddit.com/r/explainlikeimfive/comments/1vw984r/_/p54bwlk/",
      "author_handle": "DSmooth425",
      "content": "Our pipeline takes 40 minutes and most of it is reinstalling deps...",
      "posted_at": "2026-08-23T08:12:44Z",
      "state": "decided",
      "reason": null,
      "draft_text": "Caching the dependency directory keyed on the lockfile hash usually...",
      "rental_action_id": null,
      "created_at": "2026-08-23T09:44:20Z"
    }
  ]
}

Filter with ?state=, and page with limit (1–500, default 100) and offset. Once a reply is published, rental_action_id joins to GET /v1/rentals/{id}/actions/{action_id} for delivery status and the permalink. item_url is null forever on platforms with no per-comment permalink (Instagram and TikTok) — not “null until ready”.

Item States

StateMeaning
pendingIngested, not yet evaluated
screened_outYour deterministic filters or volume caps caught it. No model call was made
skippedA model saw it and declined it
decidedQualified and drafted. Terminal while dry_run is on
submittedReply submitted as an action on the rental
failedRepeated provider errors, or the submission was refused

The split between screened_out and skipped is the one that matters when tuning: it tells you whether your filters or your prompts are doing the rejecting.

Item Reasons

ReasonWhat to adjust
below_min_lengthpolicy.min_length
author_blockedpolicy.skip_authors
term_blockedpolicy.skip_if_contains
too_oldpolicy.max_age_hours
out_of_scopeOutside the watched comment's subtree, or not top-level when top_level_only is set
opted_outThe author is on your opt-out list. Honoured across every monitor and every account you hold
filtered_by_modelpolicy.qualify_prompt — the model judged it did not meet your criteria
vetoed_by_guardrailpolicy.guardrail_prompt rejected the draft
hygiene_rejectedThe draft carried a link, tagged a third party, or repeated your instructions back. Never published
author_cap_reachedlimits.per_author (defaults to 1)
thread_cap_reachedlimits.per_thread
target_cap_reachedlimits.total or limits.per_day
rate_limitedThe rental's own reply cap, shared with actions you submit by hand. Retried later
provider_errorYour provider failed repeatedly on this item
submit_failedThe reply was refused when submitted to the rental
suppressedAn opaque catch-all for decisions whose detail is not customer-visible

Step 4 — Go live

When the ledger looks right, turn dry run off. Editing prompts changes what happens next, not what already happened — items already decided keep the decision they were given, and turning dry run off does not go back and publish them.

PATCH /v1/monitors/{monitor_id}
curl -X PATCH https://api.attentionfactory.com/v1/monitors/$MONITOR_ID \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -d '{"policy": {"dry_run": false}}'
Send only what you want to change. Objects are merged field by field, so the example above flips dry_run and touches nothing else — your guardrail, filters and limits are all left exactly as they were. A field you omit is left alone.

An explicit null clears a field, but only three of them have anything to clear: policy.guardrail_prompt (stop vetoing), policy.max_age_hours (no age limit) and schedule.expires_at (no expiry). Every other field has a value at all times — to change one, send the new value rather than a null.

Two exceptions. actions is a list and is replaced whole — send every action you want the monitor to keep. And target cannot be changed at all: repointing a live monitor would leave a ledger belonging to a different post. Stop it and create another.

status accepts only active and paused — terminal states are set by the system. Resuming polls immediately rather than waiting out the interval that elapsed while paused.

Monitor Status Values

StatusMeaning
activePolling and deciding normally
pausedYou paused it. Nothing is ingested or published
degradedIngestion is failing but the monitor is still live and still processing what it already has. Deliberately distinct from paused, so a scraper blip does not look like a cancelled monitor. Clears on the next good poll
completedYou stopped it, or expires_at passed. The ledger is kept
failedTerminal. See status_reason

status_reason is one of stopped_by_customer, window_closed, rental_inactive, rental_window_closed, credential_invalid, platform_unsupported, target_not_found, target_private, target_too_large or ingestion_unavailable. The last one is ours and self-heals; the target_* values are properties of the watched post and will not resolve without changing it.

Coverage

ingestion.coverage tells you how much of the post's comments we can actually read. It is not a fault state, and partial does not mean anything is broken — some platforms return only a slice of a busy post's comments and expose no way to ask for the rest. Instagram is the extreme case: a post with several thousand comments typically yields a few dozen. Compare with counters.items_seen and ingestion.reported_total for the real ratio. Reddit and TikTok generally read complete; unknown means the platform reports no total, so no ratio can be computed.

A 0 in ingestion.last_poll_new_items next to a distant next_poll_at means the post has gone quiet and polling has slowed — not that ingestion is failing.

Stop a Monitor

DELETE /v1/monitors/{monitor_id}
curl -X DELETE https://api.attentionfactory.com/v1/monitors/$MONITOR_ID \
  -H "Authorization: Bearer af_live_..."

Returns the monitor in its completed state rather than an empty body. The item ledger is kept — it is the record of what your account said and why — and the post is freed for a new monitor.

Opt-outs

When someone asks not to be contacted again, record it. An opt-out applies across every monitor and every rented account you hold — reaching the same person next week from a different account is the same thing from their side, and is what gets accounts reported. Handles are matched case- and width-insensitively, so @Hannah, hannah and a fullwidth variant are all the same person. The call is idempotent.

POST /v1/optouts
curl -X POST "https://api.attentionfactory.com/v1/optouts?platform=reddit&handle=DSmooth425" \
  -H "Authorization: Bearer af_live_..."

platform and handle are query parameters on all three verbs. Opt-outs are accepted whether or not you currently have any monitors running — refusing a “do not contact me” is wrong even when nothing is doing the contacting.

Webhooks

A monitor is unattended by design, so four lifecycle events tell you when one needs attention. They are lifecycle-only — a monitor's replies are ordinary actions and already emit action.*, so nothing here fires per comment or per reply. Subscribe to them like any other event; see .

EventWhen
monitor.degradedIngestion started failing. Still live, still retrying
monitor.recoveredA degraded monitor polled successfully again. This is what closes the alert monitor.degraded opened
monitor.failedTerminal. The post is gone or private, the rental lapsed, or your credential was rejected
monitor.completedStopped normally — by you, or by expires_at
monitor.degraded payload
{
  "event": "monitor.degraded",
  "event_id": "b1f0a7c2-3d84-4e15-9c6b-2a7d8e0f4315",
  "timestamp": 1787654400,
  "data": {
    "monitor_id": "7c3d1e08-4b6a-4f21-9d0e-5a8c3b1f7e42",
    "rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
    "platform": "reddit",
    "status": "degraded",
    "reason": "ingestion_unavailable",
    "label": null,
    "target_url": "https://www.reddit.com/r/explainlikeimfive/comments/1vw984r",
    "occurred_at": "2026-08-23T11:02:19Z"
  }
}

The payload deliberately carries no counters and no ledger contents — a webhook is stale the moment it is queued, so fetch GET /v1/monitors/{id} if you need current numbers. It also keeps third parties' comment text out of your log pipeline.

503 on every /v1/monitors route means monitors are not enabled on this deployment. /v1/optouts is deliberately exempt and keeps working either way.

Analytics

Read account-level and post-level metrics.

Endpoints

GET
/v1/rentals/{id}/analytics

Account-level analytics from the upstream provider (active rentals only)

GET
/v1/rentals/{id}/actions/{action_id}/analytics

Post-level analytics for a single post-type action

GET
/v1/rentals/{id}/capabilities

Action types available for this rental's platform

Account Analytics

GET /v1/rentals/{id}/analytics returns account-level metrics. Only available while the rental is active; other statuses return 400.

Always branch on source. bundle means live data from the provider and includes the full metric set. snapshot means the provider was unreachable or reported nothing for this account, and you are seeing stored last-known counts — the extra metric keys are absent, not null. Both are HTTP 200. Treat a snapshot as a last-known state, not a time-series point.

GET /v1/rentals/{rental_id}/analytics
curl https://api.attentionfactory.com/v1/rentals/$RENTAL_ID/analytics \
  -H "Authorization: Bearer af_live_..."
Response — source: bundle
{
  "analytics": {
    "follower_count": 1240,
    "following_count": 310,
    "post_count": 18,
    "video_views": 20194,
    "unique_video_views": 15877,
    "comments": 143,
    "engaged_audience": 2480,
    "as_of": "2026-08-01"
  },
  "fetched_at": "2026-08-03T01:35:38.899549Z",
  "source": "bundle"
}
Response — source: snapshot
{
  "analytics": {
    "follower_count": 1240,
    "following_count": 310,
    "post_count": 18
  },
  "fetched_at": "2026-08-03T01:35:38.899549Z",
  "source": "snapshot"
}
FieldTypePresentDescription
follower_countint | nullAlwaysLive under bundle; stored under snapshot
following_countint | nullAlwaysStored value. Not refreshed live even under bundle
post_countint | nullAlwaysStored value. Not refreshed live even under bundle
video_viewsint | nullbundle onlyTotal views as of as_of
unique_video_viewsint | nullbundle onlyDeduplicated viewers
commentsint | nullbundle onlyAccount-level comment total
engaged_audienceint | nullbundle onlyAbsolute count of engaged accounts — not a rate
as_ofdate | nullbundle onlyDate the provider measured these metrics
fetched_atdatetimeAlwaysWhen we served the response — not when the metrics were measured
sourcestringAlwaysbundle or snapshot

Metric availability depends on what the upstream provider reports for each platform. Any metric a platform does not report comes backnull. Coverage can change without an API version bump.

Post Analytics

Per-post metrics for a single action. Supported action types are post.image, post.video, post.vertical_video and post.carousel. Every other type — including post.text, post.link and post.story — returns 400 permanently, because those formats have no measurable post-level metrics.

Availability is not immediate. Metrics are collected in batches after the platform has had time to index the post, so expect them roughly 24–48 hours after publication, and never before the action reaches verified. Until then the endpoint returns 400 with a retryable message (see below). Poll hourly with backoff — retrying every few seconds will not make data appear sooner.

GET /v1/rentals/{rental_id}/actions/{action_id}/analytics
curl https://api.attentionfactory.com/v1/rentals/$RENTAL_ID/actions/$ACTION_ID/analytics \
  -H "Authorization: Bearer af_live_..."
Response
{
  "analytics": {
    "views": 1843,
    "impressions": 2410,
    "likes": 96,
    "comments": 7,
    "shares": 3,
    "saves": 12,
    "impression_sources": null,
    "audience_countries": null,
    "audience_genders": null
  },
  "fetched_at": "2026-08-03T01:35:38Z",
  "source": "collected"
}

Any metric the platform does not report for a given post comes backnull. impression_sources, audience_countries and audience_genders are reserved and currently always null — do not build against them yet.

Analytics Errors

Both endpoints signal every failure as 400 with a detail string. Use the table to decide whether to retry.

detailRetry?Meaning
Analytics are available only for active rentals…NoRental is not active
Analytics are only available for post-type actionsNoAction type is outside the supported set
Analytics not yet available for this actionYesPost not yet correlated upstream
Analytics not yet available; please retry shortlyYesCollection in progress
Analytics are not available for this actionNoNo record of this post — permanent
Analytics temporarily unavailable; please retryYesTransient upstream failure

Note the two similar strings: “not yet available” is retryable, “not available” is permanent.

Profile

GET /v1/rentals/{id} includes a live profile object on active rentals: username,display_name,avatar_url, and follower / following / post counts. Count fields report null if the platform doesn't supply them. The profile object itself is nullbefore activation, on terminal statuses, and on transient fetch failure — the rental itself never fails because the profile fetch did. Not returned on GET /v1/rentals (list); fetch each rental individually for profile data.

Capabilities

GET /v1/rentals/{id}/capabilities is a convenience for your UI — same shape as GET /v1/platforms/{platform}/capabilities, scoped to the rental's platform. Lets you check "what can I do with this rental right now" without mapping rental → platform yourself.

Billing

Track your credit balance and transaction history. Rentals are charged upfront at the monthly rate for each whole month requested.

Endpoints

GET
/v1/billing/balance

Get current credit balance

GET
/v1/billing/transactions

List transaction history

Balance

GET /v1/billing/balance
curl https://api.attentionfactory.com/v1/billing/balance \
  -H "Authorization: Bearer af_live_..."

Transactions

GET /v1/billing/transactions
curl https://api.attentionfactory.com/v1/billing/transactions \
  -H "Authorization: Bearer af_live_..."

Webhooks

Register HTTPS endpoints to receive real-time notifications when rentals change state and actions progress. Deliveries are signed with HMAC-SHA256.

Endpoints

POST
/v1/webhooks

Register a webhook endpoint

GET
/v1/webhooks

List registered webhooks

DELETE
/v1/webhooks/{id}

Remove a webhook

POST
/v1/webhooks/{id}/rotate-secret

Rotate the HMAC signing secret

GET
/v1/webhooks/{id}/deliveries

View delivery log for a webhook

Register Webhook

POST /v1/webhooks
curl -X POST https://api.attentionfactory.com/v1/webhooks \
  -H "Authorization: Bearer af_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://acme.com/webhooks/af",
    "events": ["rental.active", "action.verified", "action.failed"]
  }'

The signing secret is generated by the server and returned once in the create response — you don't supply your own. Store it securely; it is encrypted at rest and cannot be retrieved again. If it leaks, rotate it viaPOST /v1/webhooks/{id}/rotate-secret.

Response (secret shown once)
{
  "id": "b7c1f0a2-9e54-4d3a-8c21-6f0a1b2c3d4e",
  "url": "https://acme.com/webhooks/af",
  "events": ["rental.active", "action.verified", "action.failed"],
  "is_active": true,
  "created_at": "2026-04-18T12:00:00Z",
  "secret": "9f8e7d6c5b4a39281706f5e4d3c2b1a0"
}

Event Types

Every delivery wraps the event data in an envelope containing the event name, a unique event_id, a timestamp (integer Unix seconds), and the event-specificdata object.

Rental events

EventWhen it fires
rental.activeRental transitions from warming to active and can accept actions
rental.cancelledCustomer cancelled a still-pending rental; credits refunded
rental.refundedPending window elapsed or pre-warming could not complete; credits refunded
rental.expiredRental window closed naturally (auto-renew off or failed)
rental.releasedCustomer released an active rental early; no refund

Action events

Emitted as action.{status} for each status transition. Subscribe only to the ones you need.

EventMeaning
action.assignedDispatched to the device (first action webhook)
action.in_progressExecution started
action.submittedExecution complete; awaiting verification
action.verifiedTerminal: succeeded
action.failedTerminal: failed or rejected

Billing events fire only for auto top-up — the card charge that covers a rental your credit balance cannot. Checkout purchases and subscription invoices emit nothing, because you completed those in a browser and already saw the result.

EventMeaning
billing.topup_succeededYour card was charged and the credits landed. {amount, credit_balance, payment_intent_id}
billing.topup_failedCard declined. Auto top-up is switched off by this event and the request that triggered it failed with 402. No retry is attempted. {reason}

billing.topup_failed is the one to alert on: a decline such as authentication_required can never succeed on an automatic charge, so nothing is retried. Update the card, then re-enable auto top-up from the billing page.

queued is the status returned synchronously byPOST .../actions — it is not delivered as a webhook; the first action webhook isaction.assigned. Intermediate events are best-effort and may arrive out of order or be skipped (stale transitions are dropped server-side), so drive your logic off the terminalaction.verified / action.failed.

Example action.verified payload
{
  "event": "action.verified",
  "event_id": "c0ffee00-1d23-4c9e-b3a7-0fdbc2a6e1b3",
  "timestamp": 1745064600,
  "data": {
    "action_id": "9ab72c01-5d62-4f0b-a8cc-1d4a7b2fc309",
    "rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
    "platform": "instagram",
    "action_type": "post.image",
    "status": "verified",
    "result_url": "https://www.instagram.com/p/CxYzAbC123/",
    "platform_result_id": "CxYzAbC123"
  }
}

result_url and platform_result_id are always present on action.* events and frequently null — see Finding What the Action Produced for when. They typically first appear on action.submitted, so you get the link at submit time rather than waiting for action.verified.

Monitor events

Lifecycle only — a monitor's replies are ordinary actions and already emit action.*, so nothing here fires per comment or per reply. See Monitors for the payload.

EventWhen it fires
monitor.degradedIngestion started failing; the monitor is still live and still retrying
monitor.recoveredA degraded monitor polled successfully again — this is what closes the alert
monitor.failedTerminal: post gone or private, rental lapsed, or credential rejected
monitor.completedStopped normally, by you or by expires_at

Credential events

EventWhen it fires
credential.invalidatedA provider authoritatively rejected your stored key and we disabled it. Nothing else would tell you — a revoked key otherwise fails silently overnight and every monitor using it stalls

Signature Verification

Each delivery includes an X-Webhook-Signature header of the form t=<unix>,v1=<hex>. The signature is an HMAC-SHA256, keyed with your webhook secret, over "<timestamp>." + rawBody — the raw received bytes, not a re-serialized object. Verify the signature and reject timestamps outside a ~5 minute window (replay protection) before trusting the payload.

Signature verification (Node.js)
const crypto = require('crypto');

// X-Webhook-Signature: t=<unix>,v1=<hex>
// rawBody MUST be the exact bytes you received (do not re-stringify the JSON).
function verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((kv) => kv.trim().split('='))
  );
  const timestamp = parseInt(parts.t, 10);
  const provided = parts.v1 || '';
  if (!timestamp || !provided) return false;

  // Reject replays outside the freshness window.
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSec) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.` + rawBody)
    .digest('hex');

  if (provided.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
}

A legacy X-Webhook-Signature-Legacy header (sha256=<hex>) is sent alongside during the transition window; new integrations should verify X-Webhook-Signature and ignore it.

Idempotent handlers recommended. Deliveries are retried on non-2xx responses, so the same event may arrive more than once. De-dupe on the envelope's event_id on your side.

Errors

The API uses standard HTTP status codes. All error responses include a detail field with a human-readable message.

Error Response Format

Error response
{
  "detail": "Insufficient credits. Need 99.00, have 12.00"
}

Status Codes

CodeMeaningCommon Cause
400Bad RequestMissing or invalid fields (e.g. unknown warming_niche)
401UnauthorizedMissing or invalid API key
402Payment RequiredInsufficient credit balance, or auto top-up tried to cover it and your card was declined. Read detail — it says which.
403ForbiddenAction not allowed for your subscription tier
404Not FoundResource does not exist or not owned by your business
409ConflictInvalid state transition (e.g. extending an expired rental)
422Unprocessable EntityAction params failed schema validation
429Too Many RequestsPer-rental rate cap exceeded (see Retry-After header)
500Internal Server ErrorSomething went wrong on our end