logicspike/docs

Billing

Billing & Subscription — API Specification

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: true are returned.
  • services is rebuilt from plan_service_limits grouped by service_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:

  • usage is computed live: count of resources vs tenant_services limits.
  • alerts array contains active warnings (trial ending, past_due, storage >95%).
  • limit: -1 means 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_id to init the Razorpay checkout modal. After payment, call POST /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_monthly or razorpay_plan_id_yearly from plans table (via IPaymentProvider).
  • If has_used_trial === false and plan has trial_days > 0, sets trial_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, update subscriptions.status = 'active' and call rebuildEntitlements().


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 OK

Response: 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
Billing