Custom Web Integration
Send subscription lifecycle events to Botsi from your own server. Use this API when your billing runs outside the platforms Botsi integrates natively. Every event you send powers the same analytics, LTV, retention, and AI Pricing pipelines that mobile SDK events do.
Which integration path do I use?
Pick the path that matches where your billing actually runs:
Billing on Apple or Google: install the mobile SDK. Store events flow automatically.
Billing on Stripe: connect the Stripe integration. Stripe notifies Botsi directly, no code needed.
Billing on Web2Wave: connect the Web2Wave integration.
Billing on anything else, like Paddle or your own system: send events yourself with this API.
Pick one path per transaction. Never report the same subscriptions through two paths, for example the Stripe integration and this API. Botsi would count the revenue twice.
Looking for the AI Pricing endpoints (create profile, get paywall, validate purchase)? Those live in the API Reference. Most web integrations use both: the API Reference to serve paywalls, this API to report what happened afterwards.
Endpoint
Each request carries exactly one event as a single JSON object. The event_type field identifies the event. Arrays are rejected.
Authentication
Send your app Secret key as the Authorization header value. Find it in the Botsi dashboard under App Settings > General > API Keys.
Authorization: {{secret_key}}
Content-Type: application/json
Send the key raw. Do not add a Bearer prefix. Bearer {{secret_key}} fails with 403.
Before you send events
Set these up in the Botsi dashboard first. The API validates all of them and rejects events that reference unknown values:
Placement: create a placement and copy its Placement ID. Chain-starting events require it.
Paywall: identify the paywall with
paywall_external_id(recommended, set the External ID on the paywall) or the numericpaywall_id. Provide exactly one of the two.Profile: create user profiles with Create Profile and send the returned
profileIdasprofile_id.Products: if you send
product_id, the product must exist in Botsi with a matching store product ID, and you must setstoreso Botsi checks the right one. For your own billing usestore: "custom"and the product's Custom Product ID.
profile_id accepts any non-empty string, but only events carrying a real Botsi profile ID join segments, attribution, and AI Pricing strategy analytics. Create the profile first, then send events for it.
Supported event types
| event_type | Description |
|---|---|
subscription_paid | First paid purchase (not a renewal) |
trial_started | Free trial activation |
subscription_renewal | Successful auto-renewal charge |
subscription_expired | Subscription lapsed, access ended |
subscription_renewal_cancelled | Auto-renewal disabled, still active until expiry |
billing_issue_detected | Billing failure or payment hold |
subscription_refunded | Refund processed for a charge |
trial_renewal_cancelled | Trial auto-renew cancelled before conversion |
trial_expired | Trial ended without converting to paid |
Required fields by event
Every event requires event_type and profile_id. On top of that:
| event_type | Additional required fields |
|---|---|
subscription_paid | transaction_id, placement_id, one of paywall_external_id / paywall_id |
trial_started | placement_id, one of paywall_external_id / paywall_id |
subscription_renewal | transaction_id, original_transaction_id |
| All other events | original_transaction_id |
Subscription chains
Botsi models every subscription as a chain of events tied together by original_transaction_id:
Chain-starting events (
subscription_paid,trial_started) carry the paywall and placement, the source of the conversion.All later events in the same chain inherit the paywall, placement, and AI Pricing context automatically through the
original_transaction_idlookup. Do not send those fields again.Use the stable ID your billing system assigns to the whole subscription as
original_transaction_id. It must not change on renewals.transaction_idchanges with every charge.A follow-up event whose chain head cannot be found is rejected with
400. Send the chain-starting event first.A chain can have at most one chain-starting event. A second one with the same
original_transaction_idis rejected as a duplicate.
Trial flow
trial_started ──────────────────────────────── chain start
│ (placement_id + paywall identifier required)
├─► trial_renewal_cancelled (auto-renew turned off during trial)
│ └─► trial_expired (trial ends, no conversion)
├─► trial_expired (trial ends without any action)
└─► subscription_renewal (trial converts, stored as trial_converted)
└─► ... (continues as the paid flow below)
Paid subscription flow
subscription_paid ──────────────────────────── chain start
│ (transaction_id + placement_id + paywall identifier required)
├─► subscription_renewal (each successful renewal charge)
├─► subscription_renewal_cancelled (auto-renew disabled)
│ └─► subscription_expired (access ends)
├─► billing_issue_detected (payment failed, retry in progress)
│ ├─► subscription_renewal (billing recovered)
│ └─► subscription_expired (billing not recovered)
└─► subscription_refunded (a charge in the chain is reversed)
AI Pricing fields
Send these on chain-starting events when the subscription came through a Botsi AI Pricing model. Without them the event cannot be attributed to a pricing experiment:
| Parameter | Type | Required | Description |
|---|---|---|---|
ai_pricing_model_id | integer | Recommended | The AI pricing model that served the price. Returned by Get Paywall as aiPricingModelId. |
is_experiment | boolean | Recommended | true if the user was part of an AI pricing experiment, false for control or non-experiment traffic. Set it explicitly, omitting it is treated as unknown. |
ai_strategy | enum | Optional | exploit for users served the current best price, explore for users on a price being tested. |
Later events in the chain inherit all three automatically. Never send them again after the chain start.
Field reference
application/json
| Parameter | Type | Description |
|---|---|---|
event_type | enum | One of the 9 supported values above. Required on every event. |
profile_id | string | Botsi profile ID of the subscriber. Required on every event. |
timestamp | ISO 8601 | When the event occurred, UTC. Auto-populated when omitted. |
original_transaction_id | string | Stable ID tying all events of one subscription together. |
transaction_id | string | ID of this specific charge. Changes on every renewal or refund. |
store | enum | app_store, play_store, stripe, web2wave, or custom. Use custom for your own billing. |
product_id | string | Store product identifier. Must match an existing Botsi product for the given store. |
product_duration | enum | consumable, non_subscription, lifetime, annual, 6_months, 3_months, 2_months, monthly, weekly |
country | string (2) | ISO 3166-1 alpha-2 subscriber country. Stored uppercase. |
paywall_external_id | string | External ID set on the paywall in the dashboard. Preferred paywall identifier on chain-starting events. |
paywall_id | integer | Numeric Botsi paywall ID. Alternative to paywall_external_id, provide exactly one. |
placement_id | string | Placement ID as defined in the dashboard. |
offer_type | enum | pay_as_you_go, pay_up_front, free_trial, unknown |
offer_category | enum | introductory, promotional, code, win_back, unknown |
offer_id | string | Store offer identifier. |
ab_test_id | integer | Botsi A/B test identifier. |
is_family_share | boolean | Whether the purchase is a family share. |
is_restore_entry | boolean | Whether this event is a restore. |
revenue_usd | number | Gross revenue in USD. Negative for refunds. |
proceeds_usd | number | Net revenue after store fees in USD. |
revenue_local | number | Gross revenue in the buyer's local currency. |
proceeds_local | number | Net revenue in the buyer's local currency. |
purchase_currency | string (3) | ISO 4217 code for the local revenue fields. Stored uppercase. |
cancellation_reason | string | Free-form cancellation reason. |
subscription_expires_at | ISO 8601 | When the current subscription period ends. |
Event normalization
When a chain started with a trial that has not yet converted, Botsi automatically stores these events as their trial equivalents so trial analytics stay accurate:
| Sent event_type | Stored as (if trial in chain) |
|---|---|
subscription_renewal | trial_converted |
subscription_expired | trial_expired |
subscription_renewal_cancelled | trial_cancelled |
Example requests
subscription_paid
{
"event_type": "subscription_paid",
"profile_id": "0072102a-c00c-4ea5-9271-1b6e975f2d63",
"transaction_id": "txn_001",
"original_transaction_id": "txn_orig_001",
"timestamp": "2026-07-05T10:00:00Z",
"store": "custom",
"product_id": "premium_monthly",
"product_duration": "monthly",
"paywall_external_id": "main-paywall",
"placement_id": "onboarding-placement",
"ai_pricing_model_id": 1001,
"is_experiment": true,
"ai_strategy": "explore",
"revenue_usd": 9.99,
"proceeds_usd": 9.40,
"country": "US",
"purchase_currency": "USD",
"subscription_expires_at": "2026-08-05T10:00:00Z"
}
cURL Example
curl -X POST "https://app.botsi.com/api/v1/webhooks/custom" \
-H "Authorization: {{secret_key}}" \
-H "Content-Type: application/json" \
-d '{
"event_type": "trial_started",
"profile_id": "0072102a-c00c-4ea5-9271-1b6e975f2d63",
"original_transaction_id": "txn_orig_001",
"store": "custom",
"product_id": "premium_monthly",
"paywall_external_id": "main-paywall",
"placement_id": "onboarding-placement",
"ai_pricing_model_id": 1001,
"is_experiment": false,
"subscription_expires_at": "2026-07-12T09:00:00Z"
}'
Follow-up events
Later events in the chain stay small. Paywall, placement, and AI Pricing context are inherited:
{
"event_type": "subscription_renewal",
"profile_id": "0072102a-c00c-4ea5-9271-1b6e975f2d63",
"transaction_id": "txn_002",
"original_transaction_id": "txn_orig_001",
"store": "custom",
"product_id": "premium_monthly",
"revenue_usd": 9.99,
"proceeds_usd": 9.40,
"purchase_currency": "USD",
"subscription_expires_at": "2026-09-05T10:00:00Z"
}
{
"event_type": "subscription_renewal_cancelled",
"profile_id": "0072102a-c00c-4ea5-9271-1b6e975f2d63",
"original_transaction_id": "txn_orig_001",
"store": "custom",
"product_id": "premium_monthly",
"cancellation_reason": "user_cancelled",
"subscription_expires_at": "2026-09-05T10:00:00Z"
}
{
"event_type": "subscription_expired",
"profile_id": "0072102a-c00c-4ea5-9271-1b6e975f2d63",
"original_transaction_id": "txn_orig_001",
"store": "custom",
"product_id": "premium_monthly"
}
{
"event_type": "subscription_refunded",
"profile_id": "0072102a-c00c-4ea5-9271-1b6e975f2d63",
"transaction_id": "txn_002",
"original_transaction_id": "txn_orig_001",
"store": "custom",
"product_id": "premium_monthly",
"revenue_usd": -9.99,
"proceeds_usd": -9.40,
"purchase_currency": "USD"
}
{
"event_type": "billing_issue_detected",
"profile_id": "0072102a-c00c-4ea5-9271-1b6e975f2d63",
"original_transaction_id": "txn_orig_001",
"store": "custom",
"product_id": "premium_monthly"
}
{
"event_type": "trial_renewal_cancelled",
"profile_id": "0072102a-c00c-4ea5-9271-1b6e975f2d63",
"original_transaction_id": "txn_orig_001",
"cancellation_reason": "user_cancelled"
}
{
"event_type": "trial_expired",
"profile_id": "0072102a-c00c-4ea5-9271-1b6e975f2d63",
"original_transaction_id": "txn_orig_001"
}
Responses
{
"received": true
}
| Status | Cause |
|---|---|
400 | Missing or invalid field, both or neither paywall identifiers on a chain-starting event, or a follow-up event whose chain head cannot be resolved |
403 | Secret key missing or does not match any app |
404 | Unknown placement_id, paywall_id, paywall_external_id, or product_id for your app |
422 | Duplicate event, already recorded. Safe to treat as success |
500 | Unhandled server error. Retry with backoff |
502 / 503 / 504 | Temporary infrastructure issue. Retry with backoff |
Deduplication
Retries are safe. Botsi rejects duplicates with 422, matching on the stored event type plus, in order of precedence:
transaction_idwhen present, ororiginal_transaction_id+profile_id+product_id+subscription_expires_at, orprofile_id+timestampas the last resort.
Verify your integration
Send a test event, then confirm it appears in the dashboard: the Events Feed shows all incoming events per app, and each profile's User History shows the subscription timeline. Test events count in analytics, so integrate against a test app, not your production app.
Common mistakes
Adding a
Bearerprefix to the Authorization header. Send the raw key.Sending an array instead of a single JSON object.
Sending both
paywall_idandpaywall_external_id. Provide exactly one.Omitting
transaction_idonsubscription_paidorsubscription_renewal.Omitting
original_transaction_idon follow-up events.Sending
product_idwithoutstore. Botsi then checks only Apple and Google product IDs and returns404for web products.Referencing a placement or paywall that does not exist yet in your app.
Reporting the same transactions through this API and the Stripe integration. Pick one path.
Try It Out
Click "Send API Request" to see the response here.