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. Each one has its own notification endpoint on https://api.botsi.com, built from your app's notification key. See Notification URLs.
Use only one path per transaction
Reporting the same subscriptions through both this endpoint and any other endpoint in the table above makes Botsi count the revenue twice.
Endpoint#
POST https://api.botsi.com/v2/notifications/custom/<notification-key>The dashboard doesn't show this URL yet. Your notification key is the same as in every other notification URL the dashboard shows, so take it from one of those. See Notification URLs.
Each 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.
The secret key must belong to the same app as the notification key in the URL. Botsi checks the key before it reads the body.
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/notifications/custom/9b2f6c1e-4d7a-4e3b-8c5f-2a1d0e9b7c64" \
-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 }How revenue is recorded#
Botsi records each event for revenue reporting:
- Store and amounts: the event's own
store(customwhen omitted), its transaction IDs, and its local amount and currency. Botsi converts the amount to USD itself. - USD-only events: an event that carries only
revenue_usdis recorded as a USD sale. - Proceeds: default to the revenue amount when you send none.
- Paid trials: a
trial_startedwith a positive amount is a paid trial start. - Refunds: always recorded as negative, whatever sign you send.
- Status events: recorded at $0.
- Environment: always production, because the payload has no environment field.
An event with neither transaction_id nor original_transaction_id isn't recorded for revenue reporting.
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 endpoint and any other notification endpoint.