Skip to content

Search the documentation by title, section, or page text.

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:

Billing runs onUse
Apple or GoogleThe Apple or Google store connections.
StripeThe Stripe integration.
Anything elseThis custom webhook endpoint

Endpoint#

POST https://api.botsi.com/v2/webhooks/custom

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/json

Send the key raw. A Bearer prefix fails with 403.

Event types#

event_typeMeaning
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 canceled before conversion
trial_expiredTrial ended without converting

Every event requires event_type and profile_id. Beyond that:

event_typeAlso required
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 othersoriginal_transaction_id

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 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_refunded

AI 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.

ParameterTypeRequiredDescription
ai_pricing_model_idintegerRecommendedThe model that served the price. Returned by Fetch Paywall as aiPricingModelId
is_experimentbooleanRecommendedtrue if the user was in an experiment, false for control. Set it explicitly. Omitting it is treated as unknown
ai_strategyenumOptionalexploit for the current best price, explore for a price being tested

Later events inherit all three. Never send them again after the chain start.

Field reference#

ParameterTypeDescription
event_typeenumOne of the nine values above. Required
profile_idstringBotsi profile ID of the subscriber. Required
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
product_idstringStore product identifier. Must match an existing Botsi Product for that store
product_durationenumconsumable, non_subscription, lifetime, annual, 6_months, 3_months, 2_months, monthly, weekly
countrystring(2)ISO 3166-1 alpha-2. Stored uppercase
paywall_external_idstringExternal ID from the dashboard. Preferred on chain-starting events
paywall_idintegerNumeric Paywall ID. Alternative to the above. 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 currency
proceeds_localnumberNet revenue in the buyer's 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 period ends

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:

SentStored as
subscription_renewaltrial_converted
subscription_expiredtrial_expired
subscription_renewal_cancelledtrial_cancelled

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 }
StatusCause
400Missing or invalid field, both or neither Paywall identifier on a chain-starting event, a paywall_external_id matching more than one Paywall, or an unresolvable chain head
403Secret key missing or not matching any app
404Unknown placement_id, paywall_id, paywall_external_id, or product_id
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:

  1. transaction_id, when present.
  2. original_transaction_id + profile_id + product_id + subscription_expires_at.
  3. 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 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.
  • Reusing one paywall_external_id across several Paywalls. Chain-starting events that reference it then fail with 400.
  • Sending a second subscription_paid for a subscription that already started. Botsi does not reject it, and the chain is recorded as starting twice.
  • 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.
  • Reporting the same transactions through this API and the Stripe integration.