Custom Webhooks
Send subscription lifecycle events to Botsi from your server. Use this when your billing & checkout runs outside the platforms Botsi integrates with natively.
Choosing this path#
Pick where your billing & checkout actually runs:
Use only one path per transaction
Reporting the same subscriptions through both this API and the Stripe integration makes Botsi count the revenue twice.
Endpoint#
POST https://api.botsi.com/v2/webhooks/customEach request carries exactly one event as a single JSON object. Arrays are rejected.
Authenticating#
Send your app Secret key as the Authorization header value, found under App Settings → General → API Keys.
Authorization: {{secret_key}}
Content-Type: application/jsonSend the key raw. A Bearer prefix fails with 403.
Event types#
Every event requires event_type and profile_id. Beyond that:
Subscription chains#
Botsi models each subscription as a chain of events tied together by original_transaction_id.
Chain-starting events (subscription_paid and trial_started) carry the Paywall and Placement, the source of the conversion.
Later events inherit the Paywall, Placement, and AI Pricing context automatically through the original_transaction_id lookup. Do not send those fields again.
original_transaction_id must never change
Use the stable ID your billing system assigns to the whole subscription. transaction_id changes with every charge; original_transaction_id must not.
A follow-up event whose chain head cannot be found is rejected with 400, so send the chain-starting event first.
Send exactly one chain-starting event per subscription
Botsi does not enforce this for you. Deduplication for subscription_paid matches on transaction_id, so a second subscription_paid carrying the same original_transaction_id under a different transaction_id is accepted rather than rejected, and the subscription is recorded as starting twice.
Nothing surfaces at the time. The damage appears later, as duplicated conversions and revenue attributed to the same subscription twice.
Trial flow#
trial_started ─────────────────────────────── chain start
│ (placement_id + paywall identifier required)
├─► trial_renewal_cancelled
│ └─► trial_expired
├─► trial_expired
└─► subscription_renewal (trial converts, stored as trial_converted)
└─► continues as the paid flowPaid subscription flow#
subscription_paid ─────────────────────────── chain start
│ (transaction_id + placement_id + paywall identifier required)
├─► subscription_renewal
├─► subscription_renewal_cancelled
│ └─► subscription_expired
├─► billing_issue_detected
│ ├─► subscription_renewal (billing recovered)
│ └─► subscription_expired (billing not recovered)
└─► subscription_refundedAI Pricing fields#
Send these on chain-starting events when the subscription came through an AI Pricing Model. Without them, the event cannot be attributed to a pricing experiment.
Later events inherit all three. Never send them again after the chain start.
Field reference#
Event normalization#
When a chain started with a trial that has not yet converted, Botsi stores these as their trial equivalents so trial analytics stay accurate:
Example requests#
A chain-starting purchase:
{
"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"
}Starting a trial:
curl -X POST "https://api.botsi.com/v2/webhooks/custom" \
-H "Authorization: $BOTSI_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 stay small, since context is 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"
}A refund sends negative revenue:
{
"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"
}Responses#
A successful request returns 200:
{ "received": true }Deduplication#
Retries are safe. Botsi rejects duplicates with 422, matching on the stored event type plus, in order of precedence:
transaction_id, when present.original_transaction_id+profile_id+product_id+subscription_expires_at.profile_id+timestamp, as a last resort.
Verifying your integration#
Send a test event, then confirm it lands in the dashboard: each Profile's User History shows the subscription timeline.
Test events count in analytics
Integrate against a test app, not your production app. There is no separate test mode here. An event sent to production is production data.
Common mistakes#
- Adding a
Bearerprefix to theAuthorizationheader. Send the raw key. - Sending an array instead of a single JSON object.
- Sending both
paywall_idandpaywall_external_id. - Reusing one
paywall_external_idacross several Paywalls. Chain-starting events that reference it then fail with400. - Sending a second
subscription_paidfor a subscription that already started. Botsi does not reject it, and the chain is recorded as starting twice. - 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.
- Reporting the same transactions through this API and the Stripe integration.