Detailed request/response contracts for every billing endpoint.
Docs: user_journeys.md · architecture.md · implementation_plan.md
Conventions
| Item | Convention |
|---|---|
| Base URL | https://api.logicspike.com/billing (proxied via Gateway) |
| Auth | All endpoints (except webhooks) require a valid JWT via Authorization: Bearer <token> |
| Tenant | Extracted from JWT's active workspace. No tenantId in URL needed. |
| Content-Type | application/json |
| Prices | Always in smallest currency unit: paise for INR (41500 = ₹415), cents for USD |
| Limits | -1 = unlimited, 0 = disabled/not included, 1 = enabled (for boolean features) |
| Pagination | Cursor-based: ?cursor=<id>&limit=<n> (default limit: 20, max: 100) |
| Errors | Standard error envelope (see Error Format) |
| Payment provider | Razorpay (current). POST /billing/checkout returns modal params, not a redirect URL. |
Authentication & Authorization
| Endpoint | Auth | Required Role / Permission |
|---|---|---|
GET /billing/plans |
JWT | Any authenticated user |
GET /billing/current |
JWT | Any workspace member |
POST /billing/checkout |
JWT | owner only |
POST /billing/payment/verify |
JWT | owner only |
POST /billing/portal |
JWT | owner only (Stripe Phase 8 only) |
POST /billing/change-plan |
JWT | owner only |
POST /billing/switch-cycle |
JWT | owner only |
GET /billing/invoices |
JWT | owner OR billing:invoices.read |
GET /billing/coins/balance |
JWT | owner OR billing:coins.read |
GET /billing/coins/transactions |
JWT | owner OR billing:coins.read |
POST /billing/coins/buy |
JWT | owner only |
GET /billing/addons |
JWT | owner OR billing:addons.read |
POST /billing/addons/buy |
JWT | owner only |
POST /billing/addons/cancel |
JWT | owner only |
GET /billing/info |
JWT | owner OR billing:info.read |
PUT /billing/info |
JWT | owner only |
POST /webhooks/razorpay |
Razorpay Signature | No JWT — validated via X-Razorpay-Signature header |
POST /webhooks/stripe |
Stripe Signature | No JWT — Phase 8 only |
Error Format
All errors follow a consistent structure:
{
"error": {
"code": "PLAN_LIMIT_REACHED",
"message": "Your Free plan allows 10 blog posts. Upgrade to create more.",
"details": {
"resource": "posts",
"limit": 10,
"current": 10,
"upgrade_url": "/dashboard/settings/billing"
}
}
}Error Codes
| Code | HTTP Status | Meaning |
|---|---|---|
UNAUTHORIZED |
401 | Missing or invalid JWT |
FORBIDDEN |
403 | User lacks required permission |
NOT_FOUND |
404 | Resource not found |
PLAN_LIMIT_REACHED |
403 | Tenant has hit a plan limit |
INSUFFICIENT_COINS |
400 | Not enough coins for the operation |
ALREADY_SUBSCRIBED |
409 | Tenant already on the requested plan |
TRIAL_ALREADY_USED |
409 | Tenant has already used their free trial |
INVALID_PLAN |
400 | Requested plan does not exist or is not public |
STRIPE_ERROR |
502 | Stripe API call failed |
VALIDATION_ERROR |
400 | Request body validation failed |
Endpoints
GET /billing/plans
Returns all public plans with their service limits. Used to render the pricing page.
Request: No body. No query params.
Response: 200 OK
{
"plans": [
{
"id": "free",
"name": "Free",
"price_monthly": 0,
"price_yearly": 0,
"yearly_discount_pct": 0,
"max_seats_included": 2,
"extra_seat_cost": 0,
"trial_days": 0,
"services": {
"blog": {
"posts": 10,
"storage_mb": 512,
"custom_domain": 0,
"api_keys": 1
},
"media": {
"storage_mb": 512
}
}
},
{
"id": "pro",
"name": "Pro",
"price_monthly": 2900,
"price_yearly": 28800,
"yearly_discount_pct": 17,
"max_seats_included": 10,
"extra_seat_cost": 500,
"trial_days": 30,
"services": {
"blog": {
"posts": -1,
"storage_mb": 25600,
"custom_domain": 1,
"api_keys": 10
},
"media": {
"storage_mb": 25600
},
"comms": {
"email_sends": 5000
}
}
}
]
}Notes:
- Only plans with
is_public: trueare returned.servicesis rebuilt fromplan_service_limitsgrouped byservice_code.- Prices are in cents.
GET /billing/current
Returns the current tenant's subscription state, coin balance, and usage summary.
Request: No body.
Response: 200 OK
{
"subscription": {
"plan_id": "pro",
"plan_name": "Pro",
"status": "active",
"billing_cycle": "monthly",
"has_used_trial": true,
"trial_end": null,
"current_period_end": "2026-03-27T00:00:00Z",
"cancel_at_period_end": false,
"pending_plan_id": null
},
"coins": {
"balance": 1700
},
"usage": {
"blog": {
"posts": { "used": 45, "limit": -1 },
"storage_mb": { "used": 8320, "limit": 25600 }
},
"platform": {
"seats": { "used": 7, "limit": 10 },
"api_keys": { "used": 3, "limit": 10 }
}
},
"alerts": [
{
"type": "trial_ending",
"message": "Your trial ends in 3 days.",
"days_remaining": 3
}
]
}Notes:
usageis computed live: count of resources vstenant_serviceslimits.alertsarray contains active warnings (trial ending, past_due, storage >95%).limit: -1means unlimited.
POST /billing/checkout
Creates a Stripe Checkout Session for subscribing to a plan.
Request:
{
"plan_id": "pro",
"cycle": "monthly",
"success_url": "https://app.logicspike.com/dashboard/settings/billing?success=true",
"cancel_url": "https://app.logicspike.com/dashboard/settings/billing"
}| Field | Type | Required | Notes |
|---|---|---|---|
plan_id |
string | ✅ | Must be a valid public plan: starter, pro, business |
cycle |
string | ✅ | monthly or yearly |
Response: 200 OK (Razorpay — current)
{
"provider": "razorpay",
"subscription_id": "sub_ABC123",
"key_id": "rzp_test_...",
"name": "LogicSpike",
"description": "Pro Plan — Monthly",
"prefill": {
"name": "Ayva Mehta",
"email": "ayva@techstartup.com"
}
}Frontend: Use
subscription_id+key_idto init the Razorpay checkout modal. After payment, callPOST /billing/payment/verify.
Response: 200 OK (Stripe — Phase 8)
{
"provider": "stripe",
"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_..."
}Errors:
| Code | When |
|---|---|
INVALID_PLAN |
plan_id doesn't exist or isn't public |
ALREADY_SUBSCRIBED |
Tenant is already on this plan |
FORBIDDEN |
Caller is not the workspace owner |
Backend logic:
- Looks up
razorpay_plan_id_monthlyorrazorpay_plan_id_yearlyfromplanstable (viaIPaymentProvider).- If
has_used_trial === falseand plan hastrial_days > 0, setstrial_duration.- Sets
metadata: { tenantId, planId }on the subscription for webhook processing.
POST /billing/payment/verify
Verifies a Razorpay payment after the checkout modal completes. Required for Razorpay (not needed for Stripe).
Request:
{
"razorpay_payment_id": "pay_ABC123",
"razorpay_subscription_id": "sub_ABC123",
"razorpay_signature": "<hmac_sha256_signature>"
}Response: 200 OK
{
"verified": true,
"subscription_id": "sub_ABC123",
"message": "Payment verified. Your plan has been activated."
}Errors:
| Code | When |
|---|---|
SIGNATURE_INVALID |
HMAC signature verification failed |
PAYMENT_NOT_FOUND |
razorpay_payment_id not recognized |
Backend: Verifies signature using
HMAC-SHA256(razorpay_payment_id + '|' + razorpay_subscription_id, key_secret). On success, updatesubscriptions.status = 'active'and callrebuildEntitlements().
POST /billing/portal
Creates a Stripe Billing Portal session for payment method updates and cancellation.
Request:
{
"return_url": "https://app.logicspike.com/dashboard/settings/billing"
}Response: 200 OK
{
"portal_url": "https://billing.stripe.com/p/session/..."
}POST /billing/change-plan
Changes the tenant's subscription plan (upgrade or downgrade).
Request:
{
"plan_id": "business",
"cycle": "yearly"
}Response: 200 OK
{
"action": "upgraded",
"effective": "immediate",
"new_plan": "business",
"prorated_amount": 4250,
"message": "Upgraded to Business. You've been charged $42.50 (prorated)."
}Downgrade response:
{
"action": "downgraded",
"effective": "end_of_period",
"new_plan": "starter",
"effective_date": "2026-03-27T00:00:00Z",
"message": "Your plan will change to Starter on Mar 27. No refund for unused time."
}Errors:
| Code | When |
|---|---|
INVALID_PLAN |
Target plan doesn't exist |
ALREADY_SUBSCRIBED |
Already on this plan and cycle |
Edge cases:
| Scenario | Behavior |
|---|---|
| Downgrade while a pending downgrade already exists | Overwrite pending_plan_id with the new target plan. Only one pending change at a time. |
| Upgrade while a pending downgrade is active | Cancel the pending downgrade (cancel_at_period_end = false, pending_plan_id = null), then upgrade immediately with proration. |
| Change plan while subscription is past_due | Reject with 403 PAYMENT_REQUIRED: "Please update your payment method before changing plans." |
| Change plan while on trial | Upgrade: switch immediately (trial continues on new plan). Downgrade: schedule for trial end. |
POST /billing/switch-cycle
Switches billing between monthly and yearly.
Request:
{
"cycle": "yearly"
}Response: 200 OK
{
"action": "cycle_switched",
"new_cycle": "yearly",
"effective": "immediate",
"new_price": 28800,
"message": "Switched to yearly billing. Charged $288.00 (prorated credit applied)."
}GET /billing/invoices
Returns paginated list of invoices from Stripe.
Request: ?cursor=inv_xxx&limit=20
Response: 200 OK
{
"invoices": [
{
"id": "inv_1ABC",
"date": "2026-02-27T00:00:00Z",
"amount": 2900,
"tax": 522,
"total": 3422,
"status": "paid",
"currency": "usd",
"pdf_url": "https://pay.stripe.com/invoice/acct_.../pdf",
"description": "LogicSpike Pro - Monthly"
}
],
"has_more": true,
"next_cursor": "inv_1ABD"
}GET /billing/coins/balance
Response: 200 OK
{
"balance": 1700
}GET /billing/coins/transactions
Returns paginated coin transaction history.
Request: ?cursor=txn_xxx&limit=20
Response: 200 OK
{
"transactions": [
{
"id": "txn_abc123",
"amount": 2200,
"balance_after": 2200,
"reason": "purchase",
"description": "Purchased Medium Coin Pack",
"reference_id": "pi_stripe_123",
"created_at": "2026-02-25T10:00:00Z"
},
{
"id": "txn_abc124",
"amount": -500,
"balance_after": 1700,
"reason": "addon_storage",
"description": "+5 GB Storage Add-on",
"reference_id": "addon_xyz",
"created_at": "2026-02-25T10:05:00Z"
}
],
"has_more": false,
"next_cursor": null
}POST /billing/coins/buy
Creates a Stripe Checkout Session for a one-time coin pack purchase.
Request:
{
"pack": "medium",
"success_url": "https://app.logicspike.com/dashboard/settings/billing?tab=coins&success=true",
"cancel_url": "https://app.logicspike.com/dashboard/settings/billing?tab=coins"
}| Field | Type | Required | Notes |
|---|---|---|---|
pack |
string | ✅ | small, medium, or large |
success_url |
string | ✅ | |
cancel_url |
string | ✅ |
Response: 200 OK
{
"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_..."
}Coin Packs Reference
| Pack | Price (cents) | Coins |
|---|---|---|
small |
500 | 500 |
medium |
2000 | 2,200 (10% bonus) |
large |
5000 | 6,000 (20% bonus) |
GET /billing/addons
Returns active and paused add-ons for the tenant.
Response: 200 OK
{
"addons": [
{
"id": "addon_abc",
"addon_type": "storage",
"display_name": "+5 GB Storage",
"quantity": 5,
"coin_cost": 500,
"status": "active",
"next_renewal": "2026-03-25T00:00:00Z"
},
{
"id": "addon_xyz",
"addon_type": "seat",
"display_name": "+1 Team Seat",
"quantity": 1,
"coin_cost": 250,
"status": "paused",
"next_renewal": null
}
]
}POST /billing/addons/buy
Purchase an add-on using coins.
Request:
{
"addon_type": "storage",
"quantity": 5
}| Field | Type | Required | Notes |
|---|---|---|---|
addon_type |
string | ✅ | storage, seat, email_sends, blog_posts, custom_domain |
quantity |
integer | ✅ | Units to add (e.g., 5 = 5 GB for storage, 1 = 1 seat) |
Response: 200 OK
{
"addon_id": "addon_new123",
"addon_type": "storage",
"quantity": 5,
"coins_deducted": 500,
"balance_after": 1200,
"message": "+5 GB storage added. 500 coins deducted."
}Errors:
| Code | When |
|---|---|
INSUFFICIENT_COINS |
tenant_coins.balance < required cost |
VALIDATION_ERROR |
Invalid addon_type or quantity <= 0 |
Add-on Pricing Reference
| Type | Coin Cost per Unit | Unit |
|---|---|---|
storage |
100 coins | 1 GB |
seat |
250 coins | 1 seat / month |
email_sends |
50 coins | 100 sends |
blog_posts |
75 coins | 10 posts |
custom_domain |
500 coins | 1 domain / month |
POST /billing/addons/cancel
Cancel (pause) an active add-on. Limits reduced on next rebuildEntitlements().
Request:
{
"addon_id": "addon_abc"
}Response: 200 OK
{
"addon_id": "addon_abc",
"status": "paused",
"message": "+5 GB storage add-on has been paused."
}GET /billing/info
Returns tenant's billing information.
Response: 200 OK
{
"company_name": "TechStartup Inc.",
"tax_id": "GST12345678",
"tax_id_type": "in_gst"
}PUT /billing/info
Update tenant's billing information. Synced to Stripe Customer object.
Request:
{
"company_name": "TechStartup Inc.",
"tax_id": "GST12345678",
"tax_id_type": "in_gst"
}| Field | Type | Required | Notes |
|---|---|---|---|
company_name |
string | ❌ | Appears on invoices |
tax_id |
string | ❌ | VAT/GST/Tax ID number |
tax_id_type |
string | ❌ | Stripe tax ID type: eu_vat, in_gst, us_ein, etc. |
Response: 200 OK
{
"company_name": "TechStartup Inc.",
"tax_id": "GST12345678",
"tax_id_type": "in_gst",
"message": "Billing info updated. Will appear on future invoices."
}Webhook Endpoint
POST /webhooks/razorpay
Receives events from Razorpay. No JWT auth — authenticated via X-Razorpay-Signature header.
Headers:
X-Razorpay-Signature: <sha256_hmac>Content-Type: application/json
Processing Logic:
1. Verify X-Razorpay-Signature using RAZORPAY_WEBHOOK_SECRET
2. Parse event.event (event type string)
3. Check processed_payment_events for event.id (idempotency)
4. Normalize to BillingEvent via webhook.normalizer.ts
5. Route to handler:
- SUBSCRIPTION_ACTIVATED → handleSubscriptionActivated()
- SUBSCRIPTION_CHARGED → handleSubscriptionCharged()
- SUBSCRIPTION_PAYMENT_FAILED → handlePaymentFailed()
- SUBSCRIPTION_CANCELLED → handleSubscriptionCancelled()
- COIN_PAYMENT_CAPTURED → handleCoinPayment()
6. Insert event.id into processed_payment_events
7. Return 200 OKResponse: Always 200 OK (even for unknown event types — prevents Razorpay retries).
{ "received": true }Error: 400 Bad Request — only if signature verification fails.
POST /webhooks/stripe (Phase 8 — future)
Same pattern as Razorpay. Validated via Stripe-Signature header. Events normalized to same BillingEvent types.
Webhook Event Payloads (What We Read)
checkout.session.completed
// Key fields we extract:
{
"id": "evt_123",
"type": "checkout.session.completed",
"data": {
"object": {
"mode": "subscription", // or "payment" for coins
"subscription": "sub_ABC",
"customer": "cus_XYZ",
"metadata": {
"tenantId": "tenant_123",
"planId": "pro", // only for subscriptions
"coinPack": "medium" // only for coin purchases
}
}
}
}mode |
Action |
|---|---|
subscription |
Upsert subscriptions, set tenants.plan_id, call rebuildEntitlements() |
payment |
Credit coins to tenant_coins, insert coin_transactions |
invoice.payment_failed
{
"data": {
"object": {
"subscription": "sub_ABC",
"customer": "cus_XYZ"
}
}
}→ Set subscriptions.status = 'past_due' where id = sub_ABC.
customer.subscription.deleted
{
"data": {
"object": {
"id": "sub_ABC",
"customer": "cus_XYZ"
}
}
}→ Set status canceled, plan to free, call rebuildEntitlements(), pause coin add-ons.
Rate Limiting
| Endpoint Category | Limit |
|---|---|
Read endpoints (GET) |
60 requests / minute / tenant |
Write endpoints (POST, PUT) |
20 requests / minute / tenant |
Webhook (POST /webhooks/stripe) |
No limit (Stripe controls delivery rate) |
Enforcement layer: Rate limiting is enforced at the Gateway level using Cloudflare's built-in rate limiting rules. The Manager service does NOT implement its own rate limiting — it trusts the Gateway.
Webhook protection: While no request-rate limit applies to the webhook endpoint, we recommend:
- Stripe IP allowlisting on the webhook route (Stripe publishes their IP ranges)
- Signature verification (always enabled — rejects unsigned/spoofed requests)
Versioning
- Current version: v1 (no prefix in URL)
- Future breaking changes: Prefix with
/v2/billing/... - Non-breaking additions (new fields in response): No version bump required