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

POSThttps://app.botsi.com/api/v1/webhooks/custom

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 numeric paywall_id. Provide exactly one of the two.

  • Profile: create user profiles with Create Profile and send the returned profileId as profile_id.

  • Products: if you send product_id, the product must exist in Botsi with a matching store product ID, and you must set store so Botsi checks the right one. For your own billing use store: "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_typeDescription
subscription_paidFirst paid purchase (not a renewal)
trial_startedFree trial activation
subscription_renewalSuccessful auto-renewal charge
subscription_expiredSubscription lapsed, access ended
subscription_renewal_cancelledAuto-renewal disabled, still active until expiry
billing_issue_detectedBilling failure or payment hold
subscription_refundedRefund processed for a charge
trial_renewal_cancelledTrial auto-renew cancelled before conversion
trial_expiredTrial ended without converting to paid

Required fields by event

Every event requires event_type and profile_id. On top of that:

event_typeAdditional required fields
subscription_paidtransaction_id, placement_id, one of paywall_external_id / paywall_id
trial_startedplacement_id, one of paywall_external_id / paywall_id
subscription_renewaltransaction_id, original_transaction_id
All other eventsoriginal_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_id lookup. 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_id changes 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_id is 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)
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:

ParameterTypeRequiredDescription
ai_pricing_model_idintegerRecommendedThe AI pricing model that served the price. Returned by Get Paywall as aiPricingModelId.
is_experimentbooleanRecommendedtrue 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_strategyenumOptionalexploit 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

ParameterTypeDescription
event_typeenumOne of the 9 supported values above. Required on every event.
profile_idstringBotsi profile ID of the subscriber. Required on every event.
timestampISO 8601When the event occurred, UTC. Auto-populated when omitted.
original_transaction_idstringStable ID tying all events of one subscription together.
transaction_idstringID of this specific charge. Changes on every renewal or refund.
storeenumapp_store, play_store, stripe, web2wave, or custom. Use custom for your own billing.
product_idstringStore product identifier. Must match an existing Botsi product for the given store.
product_durationenumconsumable, non_subscription, lifetime, annual, 6_months, 3_months, 2_months, monthly, weekly
countrystring (2)ISO 3166-1 alpha-2 subscriber country. Stored uppercase.
paywall_external_idstringExternal ID set on the paywall in the dashboard. Preferred paywall identifier on chain-starting events.
paywall_idintegerNumeric Botsi paywall ID. Alternative to paywall_external_id, provide exactly one.
placement_idstringPlacement ID as defined in the dashboard.
offer_typeenumpay_as_you_go, pay_up_front, free_trial, unknown
offer_categoryenumintroductory, promotional, code, win_back, unknown
offer_idstringStore offer identifier.
ab_test_idintegerBotsi A/B test identifier.
is_family_sharebooleanWhether the purchase is a family share.
is_restore_entrybooleanWhether this event is a restore.
revenue_usdnumberGross revenue in USD. Negative for refunds.
proceeds_usdnumberNet revenue after store fees in USD.
revenue_localnumberGross revenue in the buyer's local currency.
proceeds_localnumberNet revenue in the buyer's local currency.
purchase_currencystring (3)ISO 4217 code for the local revenue fields. Stored uppercase.
cancellation_reasonstringFree-form cancellation reason.
subscription_expires_atISO 8601When 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_typeStored as (if trial in chain)
subscription_renewaltrial_converted
subscription_expiredtrial_expired
subscription_renewal_cancelledtrial_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

200Event accepted
{
  "received": true
}
StatusCause
400Missing or invalid field, both or neither paywall identifiers on a chain-starting event, or a follow-up event whose chain head cannot be resolved
403Secret key missing or does not match any app
404Unknown placement_id, paywall_id, paywall_external_id, or product_id for your app
422Duplicate event, already recorded. Safe to treat as success
500Unhandled server error. Retry with backoff
502 / 503 / 504Temporary 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_id when present, or

  • original_transaction_id + profile_id + product_id + subscription_expires_at, or

  • profile_id + timestamp as 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 Bearer prefix to the Authorization header. Send the raw key.

  • Sending an array instead of a single JSON object.

  • Sending both paywall_id and paywall_external_id. Provide exactly one.

  • Omitting transaction_id on subscription_paid or subscription_renewal.

  • Omitting original_transaction_id on follow-up events.

  • Sending product_id without store. Botsi then checks only Apple and Google product IDs and returns 404 for 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

https://app.botsi.com/api/v1/webhooks/custom
Response
Click "Send API Request" to see the response here.