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.
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
}'{
"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.
curl https://api.attentionfactory.com/v1/rentals/4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3 \
-H "Authorization: Bearer af_live_..."{
"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.
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.
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
/v1/platformsList supported platforms
/v1/platforms/{platform}/capabilitiesAction types and per-rental rate caps for a platform
/v1/platforms/{platform}/nichesValid warming_niche values for opt-in warming
List Platforms
curl https://api.attentionfactory.com/v1/platforms \
-H "Authorization: Bearer af_live_..."{
"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.
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.
curl https://api.attentionfactory.com/v1/platforms/instagram/niches \
-H "Authorization: Bearer af_live_..."{
"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
/v1/rentalsCreate a rental (returns 202)
/v1/rentalsList your rentals (supports ?status=: warming · active · expired · refunded · cancelled · released)
/v1/rentals/{id}Get rental detail
/v1/rentals/{id}Update auto_renew (and other mutable fields)
/v1/rentals/{id}Release early or cancel a pending rental
/v1/rentals/{id}/extendExtend an active rental by N months
Create Rental
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
| Field | Type | Required | Description |
|---|---|---|---|
| platform | string | Yes | One of: instagram, tiktok, reddit, facebook, linkedin |
| months | integer | No | 1–12. Defaults to 1 |
| auto_renew | bool | No | Auto-renew when the rental window closes. Defaults to true |
| link_analytics | bool | No | Whether this account may be linked to the analytics provider. Defaults to true |
| warming_niche | string | No | Opt in to 7-day pre-warming on this niche. Must be in the platform's niche catalog |
| branding | object | No | Branding preset applied automatically on activation |
| label | string | No | Your 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
| Status | Meaning |
|---|---|
| warming | Pre-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 |
| active | Rental is live. Submit actions against the rental |
| expired | Rental window closed naturally (auto-renew disabled or failed) |
| refunded | Credits returned. Warming did not complete within the 14-day guarantee window |
| released | Customer released the rental early. No refund (month-committal) |
| cancelled | Customer cancelled while still pre-activation. Credits refunded |
Extend / Renew
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.
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
/v1/rentals/{id}/brandingApply 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.
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
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"
}'{
"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.
{
"rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
"platform": "instagram",
"mode": "preset_stored",
"results": null,
"preset": {
"bio": "Builder. Coffee. Code.",
"display_name": "Launchpad"
}
}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
/v1/rentals/{id}/warmingEnroll the rental in a warming schedule
/v1/rentals/{id}/warmingCancel a pending warming enrolment
Request Body
| Field | Type | Default | Description |
|---|---|---|---|
| niche | string | — | Optional, max 50 chars. See GET /v1/platforms/{platform}/niches for the platform's catalog. Pace and duration are managed server-side |
Enroll
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"}'{
"rental_id": "4e1a5c98-1d23-4c9e-b3a7-0fdbc2a6e1b3",
"action_id": "9ab72c01-5d62-4f0b-a8cc-1d4a7b2fc309",
"status": "queued"
}Cancel
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
/v1/rentals/{rental_id}/{platform}/actionsSubmit a new action
/v1/rentals/{rental_id}/actionsList actions on a rental (supports ?status=: queued · assigned · in_progress · submitted · verified · failed · cancelled)
/v1/rentals/{rental_id}/actions/{action_id}Get action detail
/v1/rentals/{rental_id}/actions/{action_id}Cancel a still-queued action
/v1/rentals/{rental_id}/actions/{action_id}/analyticsPost-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.
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"
}
}'{
"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.
| Mode | Fields | Platforms |
|---|---|---|
| Permalink | target_comment_url | Reddit, LinkedIn, X, Facebook |
| Locate on the post | target_post_url + target_comment_author + target_comment_excerpt | Required on Instagram and TikTok; works everywhere |
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.{
"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.
{
"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
| Status | Meaning |
|---|---|
| queued | Accepted and queued for dispatch |
| assigned | Dispatched to the device running the account |
| in_progress | Executing on the device |
| submitted | Execution complete; awaiting verification |
| verified | Terminal: action completed successfully |
| failed | Terminal: action failed or did not pass verification |
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.
| Field | Description |
|---|---|
| result_url | Live permalink to the published post or comment. Always an https:// URL when present. |
| platform_result_id | The 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. |
{
"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"
}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.
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
/v1/credentialsConnect your LLM provider key (prerequisite)
/v1/monitorsStart watching a post
/v1/monitorsList your monitors
/v1/monitors/{id}Get one monitor, with health and counters
/v1/monitors/{id}Retune prompts, or pause and resume
/v1/monitors/{id}Stop the monitor, keep the ledger
/v1/monitors/{id}/itemsEverything it saw and what it decided
/v1/optoutsNever contact this person again
/v1/optoutsList opt-outs
/v1/optoutsRemove 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.
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"
}'{
"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”.
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 }
}'{
"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
| Field | Type | Required | Description |
|---|---|---|---|
| rental_id | uuid | Yes | The rented account that will reply. Must be active, and on the same platform as the target URL |
| credential_id | uuid | Yes | Which stored provider key pays for the model calls |
| target.url | string | Yes | 10–1024 chars, https only. Reddit, TikTok or Instagram. Always the POST URL, even for a comment target |
| target.type | enum | No | post (default) or comment to watch one comment's subtree |
| target.comment_ref | string | Cond. | 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 |
| persona | string | No | Max 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 |
| goal | string | Yes | Max 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_run | boolean | No | Default true. The highest-consequence field in the body: the difference between drafting and publishing under a rented identity |
| policy.guardrail_prompt | string | No | Max 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_prompt | string | Yes | Max 4000. Which comments deserve an answer |
| policy.min_length | integer | No | 0–10000, default 0. The cheapest filter there is, and it runs before a single token is spent |
| policy.skip_authors | string[] | No | Up to 200 handles. Matched case- and width-insensitively |
| policy.skip_if_contains | string[] | No | Up to 200 terms. Plain values, never patterns |
| policy.max_age_hours | integer | No | 1–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_only | boolean | No | Default false. Reply only to comments that root their own thread. Not valid with a comment target |
| actions[] | array | Yes | 1–4 entries, at most one per type. Two types exist today, so in practice 1–2 |
| actions[].type | enum | Yes | reply or comment. See below |
| actions[].prompt | string | Yes | 1–4000 chars. How this action should be written. Separate from persona, which says who the account is |
| actions[].limits | object | No | Per-action volume caps. See below |
| schedule.poll_interval_hours | integer | No | 1–168, default 6. Polling slows automatically once a post goes quiet, and speeds back up the moment anything new appears |
| schedule.expires_at | datetime | No | Stop the monitor at this time. Null means it runs until you stop it or the rental ends |
| context.read_comment_media | boolean | No | Default 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 |
| model | string | No | Overrides the credential's default_model for this monitor |
| label | string | No | Max 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.
{
"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.
{
"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.
| Combination | Why |
|---|---|
| comment_ref on a post target | Only meaningful when type is comment |
| a comment action on a comment target | A comment target has no post to comment on. Use a post target to seed |
| top_level_only on a comment target | Every comment inside a subtree is a reply, so nothing would ever qualify |
| limits.per_thread on a comment target | The monitor already is one thread. Use limits.total |
| two actions of the same type | Doubles every item's model spend on your own key for nothing one entry cannot express |
| an empty actions array | A monitor with no actions watches a post and never does anything |
| any unrecognised field | A 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:
| Type | Description |
|---|---|
| reply | Answers an ingested comment. The common case |
| comment | Posts 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.
| Field | Range | Description |
|---|---|---|
| total | 1–10000 | Lifetime cap for this action. Null means uncapped |
| per_author | 1–100 | Defaults to 1. Answering one person repeatedly is the most visible automation tell there is |
| per_thread | 1–100 | Null means uncapped. Not valid with a comment target — the monitor already is one thread |
| per_day | 1–1000 | The 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.
curl "https://api.attentionfactory.com/v1/monitors/$MONITOR_ID/items?state=decided&limit=50" \
-H "Authorization: Bearer af_live_..."{
"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
| State | Meaning |
|---|---|
| pending | Ingested, not yet evaluated |
| screened_out | Your deterministic filters or volume caps caught it. No model call was made |
| skipped | A model saw it and declined it |
| decided | Qualified and drafted. Terminal while dry_run is on |
| submitted | Reply submitted as an action on the rental |
| failed | Repeated 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
| Reason | What to adjust |
|---|---|
| below_min_length | policy.min_length |
| author_blocked | policy.skip_authors |
| term_blocked | policy.skip_if_contains |
| too_old | policy.max_age_hours |
| out_of_scope | Outside the watched comment's subtree, or not top-level when top_level_only is set |
| opted_out | The author is on your opt-out list. Honoured across every monitor and every account you hold |
| filtered_by_model | policy.qualify_prompt — the model judged it did not meet your criteria |
| vetoed_by_guardrail | policy.guardrail_prompt rejected the draft |
| hygiene_rejected | The draft carried a link, tagged a third party, or repeated your instructions back. Never published |
| author_cap_reached | limits.per_author (defaults to 1) |
| thread_cap_reached | limits.per_thread |
| target_cap_reached | limits.total or limits.per_day |
| rate_limited | The rental's own reply cap, shared with actions you submit by hand. Retried later |
| provider_error | Your provider failed repeatedly on this item |
| submit_failed | The reply was refused when submitted to the rental |
| suppressed | An 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.
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}}'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
| Status | Meaning |
|---|---|
| active | Polling and deciding normally |
| paused | You paused it. Nothing is ingested or published |
| degraded | Ingestion 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 |
| completed | You stopped it, or expires_at passed. The ledger is kept |
| failed | Terminal. 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
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.
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 .
| Event | When |
|---|---|
| monitor.degraded | Ingestion started failing. Still live, still retrying |
| monitor.recovered | A degraded monitor polled successfully again. This is what closes the alert monitor.degraded opened |
| monitor.failed | Terminal. The post is gone or private, the rental lapsed, or your credential was rejected |
| monitor.completed | Stopped normally — by you, or by expires_at |
{
"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.
/v1/optouts is deliberately exempt and keeps working either way.Analytics
Read account-level and post-level metrics.
Endpoints
/v1/rentals/{id}/analyticsAccount-level analytics from the upstream provider (active rentals only)
/v1/rentals/{id}/actions/{action_id}/analyticsPost-level analytics for a single post-type action
/v1/rentals/{id}/capabilitiesAction 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.
curl https://api.attentionfactory.com/v1/rentals/$RENTAL_ID/analytics \
-H "Authorization: Bearer af_live_..."{
"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"
}{
"analytics": {
"follower_count": 1240,
"following_count": 310,
"post_count": 18
},
"fetched_at": "2026-08-03T01:35:38.899549Z",
"source": "snapshot"
}| Field | Type | Present | Description |
|---|---|---|---|
| follower_count | int | null | Always | Live under bundle; stored under snapshot |
| following_count | int | null | Always | Stored value. Not refreshed live even under bundle |
| post_count | int | null | Always | Stored value. Not refreshed live even under bundle |
| video_views | int | null | bundle only | Total views as of as_of |
| unique_video_views | int | null | bundle only | Deduplicated viewers |
| comments | int | null | bundle only | Account-level comment total |
| engaged_audience | int | null | bundle only | Absolute count of engaged accounts — not a rate |
| as_of | date | null | bundle only | Date the provider measured these metrics |
| fetched_at | datetime | Always | When we served the response — not when the metrics were measured |
| source | string | Always | bundle 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.
curl https://api.attentionfactory.com/v1/rentals/$RENTAL_ID/actions/$ACTION_ID/analytics \
-H "Authorization: Bearer af_live_..."{
"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.
| detail | Retry? | Meaning |
|---|---|---|
| Analytics are available only for active rentals… | No | Rental is not active |
| Analytics are only available for post-type actions | No | Action type is outside the supported set |
| Analytics not yet available for this action | Yes | Post not yet correlated upstream |
| Analytics not yet available; please retry shortly | Yes | Collection in progress |
| Analytics are not available for this action | No | No record of this post — permanent |
| Analytics temporarily unavailable; please retry | Yes | Transient 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
/v1/billing/balanceGet current credit balance
/v1/billing/transactionsList transaction history
Balance
curl https://api.attentionfactory.com/v1/billing/balance \
-H "Authorization: Bearer af_live_..."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
/v1/webhooksRegister a webhook endpoint
/v1/webhooksList registered webhooks
/v1/webhooks/{id}Remove a webhook
/v1/webhooks/{id}/rotate-secretRotate the HMAC signing secret
/v1/webhooks/{id}/deliveriesView delivery log for a webhook
Register Webhook
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.
{
"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
| Event | When it fires |
|---|---|
| rental.active | Rental transitions from warming to active and can accept actions |
| rental.cancelled | Customer cancelled a still-pending rental; credits refunded |
| rental.refunded | Pending window elapsed or pre-warming could not complete; credits refunded |
| rental.expired | Rental window closed naturally (auto-renew off or failed) |
| rental.released | Customer released an active rental early; no refund |
Action events
Emitted as action.{status} for each status transition. Subscribe only to the ones you need.
| Event | Meaning |
|---|---|
| action.assigned | Dispatched to the device (first action webhook) |
| action.in_progress | Execution started |
| action.submitted | Execution complete; awaiting verification |
| action.verified | Terminal: succeeded |
| action.failed | Terminal: 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.
| Event | Meaning |
|---|---|
| billing.topup_succeeded | Your card was charged and the credits landed. {amount, credit_balance, payment_intent_id} |
| billing.topup_failed | Card 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.
{
"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.
| Event | When it fires |
|---|---|
| monitor.degraded | Ingestion started failing; the monitor is still live and still retrying |
| monitor.recovered | A degraded monitor polled successfully again — this is what closes the alert |
| monitor.failed | Terminal: post gone or private, rental lapsed, or credential rejected |
| monitor.completed | Stopped normally, by you or by expires_at |
Credential events
| Event | When it fires |
|---|---|
| credential.invalidated | A 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.
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.
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
{
"detail": "Insufficient credits. Need 99.00, have 12.00"
}Status Codes
| Code | Meaning | Common Cause |
|---|---|---|
| 400 | Bad Request | Missing or invalid fields (e.g. unknown warming_niche) |
| 401 | Unauthorized | Missing or invalid API key |
| 402 | Payment Required | Insufficient credit balance, or auto top-up tried to cover it and your card was declined. Read detail — it says which. |
| 403 | Forbidden | Action not allowed for your subscription tier |
| 404 | Not Found | Resource does not exist or not owned by your business |
| 409 | Conflict | Invalid state transition (e.g. extending an expired rental) |
| 422 | Unprocessable Entity | Action params failed schema validation |
| 429 | Too Many Requests | Per-rental rate cap exceeded (see Retry-After header) |
| 500 | Internal Server Error | Something went wrong on our end |