Billing, Usage & Add-ons

Subscription management, usage tracking, plan limits enforcement, add-on purchases, and Stripe integration.

Subscription Status

Returns the current user's subscription plan and status.

Get Subscription

JavaScript
const { data } = await $fetch('https://taxmtd.uk/api/stripe/subscription')

// Response shape
interface SubscriptionState {
  plan: 'starter' | 'essential' | 'pro' | 'business' | 'practice'
  status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'incomplete' | 'none'
  currentPeriodEnd: string | null
  cancelAtPeriodEnd: boolean
  stripeSubscriptionId?: string
}
Python
import requests

res = requests.get(
    "https://taxmtd.uk/api/stripe/subscription",
    cookies=session_cookies,
)
data = res.json()["data"]
# data["plan"]   => "pro"
# data["status"] => "active"
PHP
$response = Http::withCookies($session)
    ->get('https://taxmtd.uk/api/stripe/subscription');

$sub = $response->json()['data'];
// $sub['plan']   => "pro"
// $sub['status'] => "active"
Rust
#[derive(Deserialize)]
struct SubscriptionState {
    plan: String,
    status: String,
    current_period_end: Option<String>,
    cancel_at_period_end: bool,
    stripe_subscription_id: Option<String>,
}

#[derive(Deserialize)]
struct ApiResponse { data: SubscriptionState }

let res: ApiResponse = client
    .get("https://taxmtd.uk/api/stripe/subscription")
    .send().await?
    .json().await?;
cURL
curl https://taxmtd.uk/api/stripe/subscription \
  -b "session_cookie=..."

Users with no subscription default to the Starter plan with status: 'active'. The Starter plan is free for non-VAT registered sole traders and requires no card. Starter includes MTD quarterly submissions; the yearly Self-Assessment (SA103S) submission requires Essential or higher (POST /api/filing/submissions with status: 'submitted' returns 403 with { upgrade: true, requiredPlan: 'essential' } on Starter).


Usage Tracking

Returns current usage counts for all limit-enforced resources. Used to display usage dashboards and client-side limit warnings.

Get Usage

JavaScript
const { data } = await $fetch('https://taxmtd.uk/api/usage')

// Response shape
interface UsageCounts {
  entities: number              // Total business entities
  invoices_monthly: number      // Invoices created this calendar month
  ocr_receipts_monthly: number  // Receipts scanned this calendar month
  team_members: number          // Active team members across all entities
  inventory_skus: number        // Total products
  payroll_employees: number     // Total employees
  bank_connections: number      // Total bank connections
}
Python
res = requests.get(
    "https://taxmtd.uk/api/usage",
    cookies=session_cookies,
)
usage = res.json()["data"]
print(f"Entities: {usage['entities']}")
print(f"Invoices this month: {usage['invoices_monthly']}")
print(f"OCR receipts this month: {usage['ocr_receipts_monthly']}")
PHP
$response = Http::withCookies($session)
    ->get('https://taxmtd.uk/api/usage');

$usage = $response->json()['data'];
// $usage['entities']              => 3
// $usage['invoices_monthly']      => 12
// $usage['ocr_receipts_monthly']  => 8
Rust
#[derive(Deserialize)]
struct UsageCounts {
    entities: u32,
    invoices_monthly: u32,
    ocr_receipts_monthly: u32,
    team_members: u32,
    inventory_skus: u32,
    payroll_employees: u32,
    bank_connections: u32,
}

#[derive(Deserialize)]
struct ApiResponse { data: UsageCounts }

let res: ApiResponse = client
    .get("https://taxmtd.uk/api/usage")
    .send().await?
    .json().await?;
cURL
curl https://taxmtd.uk/api/usage \
  -b "session_cookie=..."

Each key maps to a limit_key in the plan_limits table. The server enforces limits on resource creation endpoints - attempting to exceed a limit returns a 403 with upgrade guidance:

{
  "statusCode": 403,
  "data": {
    "upgrade": true,
    "limitKey": "invoices_monthly",
    "currentUsage": 50,
    "limit": 50,
    "baseLimit": 50,
    "addonGrant": 0,
    "currentPlan": "pro"
  }
}

The limit field reflects the effective limit (base plan limit + any purchased add-on grants).


Plan Configuration

Public endpoint - returns all feature gates and usage limits. No authentication required. Cached for 5 minutes.

Get Plan Config

JavaScript
const { data } = await $fetch('https://taxmtd.uk/api/plan-config')

// Response shape
interface PlanConfig {
  features: PlanFeature[]
  limits: PlanLimit[]
}

interface PlanFeature {
  feature_slug: string
  min_plan: 'starter' | 'essential' | 'pro' | 'business' | 'practice'
  category: string
  label: string
  description: string | null
  sort_order: number
}

interface PlanLimit {
  plan: 'starter' | 'essential' | 'pro' | 'business' | 'practice'
  limit_key: string
  limit_value: number   // -1 = unlimited
}
Python
res = requests.get("https://taxmtd.uk/api/plan-config")
config = res.json()["data"]

for feature in config["features"]:
    print(f"{feature['label']}: requires {feature['min_plan']}")

for limit in config["limits"]:
    value = "Unlimited" if limit["limit_value"] == -1 else limit["limit_value"]
    print(f"{limit['plan']} - {limit['limit_key']}: {value}")
PHP
$response = Http::get('https://taxmtd.uk/api/plan-config');
$config = $response->json()['data'];

foreach ($config['features'] as $feature) {
    echo "{$feature['label']}: requires {$feature['min_plan']}\n";
}

foreach ($config['limits'] as $limit) {
    $value = $limit['limit_value'] === -1 ? 'Unlimited' : $limit['limit_value'];
    echo "{$limit['plan']} - {$limit['limit_key']}: {$value}\n";
}
Rust
#[derive(Deserialize)]
struct PlanFeature {
    feature_slug: String,
    min_plan: String,
    category: String,
    label: String,
    description: Option<String>,
    sort_order: i32,
}

#[derive(Deserialize)]
struct PlanLimit {
    plan: String,
    limit_key: String,
    limit_value: i64, // -1 = unlimited
}

#[derive(Deserialize)]
struct PlanConfig { features: Vec<PlanFeature>, limits: Vec<PlanLimit> }

#[derive(Deserialize)]
struct ApiResponse { data: PlanConfig }

let res: ApiResponse = client
    .get("https://taxmtd.uk/api/plan-config")
    .send().await?
    .json().await?;
cURL
curl https://taxmtd.uk/api/plan-config

Usage Caps by Plan

ResourceStarterEssentialProBusinessPractice
Bank connections125UnlimitedUnlimited
Transactions/mo100250UnlimitedUnlimitedUnlimited
Invoices/mo--50UnlimitedUnlimited
OCR receipts/mo--20100Unlimited
Payroll employees---525
Inventory SKUs---200Unlimited
Entities111225
Team members11310Unlimited

All limits are enforced server-side. A - means the feature requires a higher plan entirely (gated by plan_features, not by count).


Add-ons

Extend plan limits without changing your subscription tier. Add-ons are attached to your existing Stripe subscription as additional line items.

Available Add-ons

Add-onGrantsPrice
Extra Entities+5 entities£5/mo
Extra Employees+10 payroll employees£3/mo
OCR Receipt Bundle+50 receipt scans/mo£5/mo

List Add-ons

Returns the full catalog and the user's purchased add-ons.

JavaScript
const { data } = await $fetch('https://taxmtd.uk/api/addons')

// Response shape
interface AddonsResponse {
  catalog: AddonDefinition[]
  purchased: PurchasedAddon[]
}

interface AddonDefinition {
  slug: string
  label: string
  description: string
  unit_label: string
  grant_per_unit: number
  limit_key: string
  price_monthly_pence: number
  price_annual_pence: number
}

interface PurchasedAddon {
  id: string
  addon_slug: string
  quantity: number
  status: 'active' | 'canceled'
  date_created?: string
}
Python
res = requests.get(
    "https://taxmtd.uk/api/addons",
    cookies=session_cookies,
)
data = res.json()["data"]

for addon in data["catalog"]:
    price = addon["price_monthly_pence"] / 100
    print(f"{addon['label']}: £{price:.2f}/mo - {addon['description']}")

for purchased in data["purchased"]:
    print(f"Active: {purchased['addon_slug']} x{purchased['quantity']}")
PHP
$response = Http::withCookies($session)
    ->get('https://taxmtd.uk/api/addons');

$data = $response->json()['data'];

foreach ($data['catalog'] as $addon) {
    $price = number_format($addon['price_monthly_pence'] / 100, 2);
    echo "{$addon['label']}: £{$price}/mo\n";
}

foreach ($data['purchased'] as $purchased) {
    echo "Active: {$purchased['addon_slug']} x{$purchased['quantity']}\n";
}
Rust
#[derive(Deserialize)]
struct AddonDefinition {
    slug: String,
    label: String,
    description: String,
    unit_label: String,
    grant_per_unit: u32,
    limit_key: String,
    price_monthly_pence: u32,
    price_annual_pence: u32,
}

#[derive(Deserialize)]
struct PurchasedAddon {
    id: String,
    addon_slug: String,
    quantity: u32,
    status: String,
}

#[derive(Deserialize)]
struct AddonsResponse {
    catalog: Vec<AddonDefinition>,
    purchased: Vec<PurchasedAddon>,
}

#[derive(Deserialize)]
struct ApiResponse { data: AddonsResponse }

let res: ApiResponse = client
    .get("https://taxmtd.uk/api/addons")
    .send().await?
    .json().await?;
cURL
curl https://taxmtd.uk/api/addons \
  -b "session_cookie=..."

Purchase Add-on

Adds the add-on to the user's active subscription. If no active subscription exists, creates a Stripe Checkout session for the add-on.

addon_slug
string required
One of: extra_entities, extra_employees, ocr_bundle
quantity
number
Number of units to purchase (default: 1)
JavaScript
const { data } = await $fetch('https://taxmtd.uk/api/addons', {
  method: 'POST',
  body: { addon_slug: 'extra_entities', quantity: 1 }
})
// With active subscription: { success: true, method: 'subscription_item' }
// Without subscription:     { url: 'https://checkout.stripe.com/...' }
Python
res = requests.post(
    "https://taxmtd.uk/api/addons",
    json={"addon_slug": "extra_entities", "quantity": 1},
    cookies=session_cookies,
)
data = res.json()["data"]

if "url" in data:
    # Redirect user to Stripe Checkout
    print(f"Redirect to: {data['url']}")
else:
    # Add-on added to existing subscription
    print(f"Success: {data['method']}")
PHP
$response = Http::withCookies($session)->post(
    'https://taxmtd.uk/api/addons',
    ['addon_slug' => 'extra_entities', 'quantity' => 1]
);

$data = $response->json()['data'];

if (isset($data['url'])) {
    return redirect($data['url']); // Stripe Checkout
} else {
    echo "Add-on activated: {$data['method']}";
}
Rust
let res = client.post("https://taxmtd.uk/api/addons")
    .json(&serde_json::json!({
        "addon_slug": "extra_entities",
        "quantity": 1
    }))
    .send().await?;

#[derive(Deserialize)]
struct PurchaseResult {
    success: Option<bool>,
    url: Option<String>,
    method: Option<String>,
}

#[derive(Deserialize)]
struct ApiResponse { data: PurchaseResult }

let result: ApiResponse = res.json().await?;
if let Some(url) = result.data.url {
    println!("Redirect to: {}", url);
}
cURL
curl -X POST https://taxmtd.uk/api/addons \
  -H "Content-Type: application/json" \
  -b "session_cookie=..." \
  -d '{"addon_slug":"extra_entities","quantity":1}'

Stripe Checkout

Creates a Stripe Checkout session to start a new subscription.

priceId
string required
Stripe Price ID for the plan
plan
string required
Plan slug: starter, essential, pro, business, or practice
interval
string
Billing interval: monthly or annual (default: monthly)
JavaScript
const { data } = await $fetch('https://taxmtd.uk/api/stripe/checkout', {
  method: 'POST',
  body: {
    priceId: 'price_...',
    plan: 'pro',
    interval: 'annual'
  }
})
// Redirect user to: data.url
Python
res = requests.post(
    "https://taxmtd.uk/api/stripe/checkout",
    json={
        "priceId": "price_...",
        "plan": "pro",
        "interval": "annual",
    },
    cookies=session_cookies,
)
checkout_url = res.json()["data"]["url"]
# Redirect user to checkout_url
PHP
$response = Http::withCookies($session)->post(
    'https://taxmtd.uk/api/stripe/checkout',
    [
        'priceId' => 'price_...',
        'plan' => 'pro',
        'interval' => 'annual',
    ]
);

$url = $response->json()['data']['url'];
return redirect($url);
Rust
let res = client.post("https://taxmtd.uk/api/stripe/checkout")
    .json(&serde_json::json!({
        "priceId": "price_...",
        "plan": "pro",
        "interval": "annual"
    }))
    .send().await?;

#[derive(Deserialize)]
struct CheckoutResult { url: String }

#[derive(Deserialize)]
struct ApiResponse { data: CheckoutResult }

let result: ApiResponse = res.json().await?;
// Redirect user to result.data.url
cURL
curl -X POST https://taxmtd.uk/api/stripe/checkout \
  -H "Content-Type: application/json" \
  -b "session_cookie=..." \
  -d '{"priceId":"price_...","plan":"pro","interval":"annual"}'

The Starter plan is free with no card required (non-VAT sole traders only). All paid plans include a 14-day free trial - the user is not charged until the trial ends.


Customer Portal

Opens the Stripe Customer Portal where users can update payment methods, change plans, view invoices, and cancel subscriptions. Mid-cycle plan changes are prorated automatically.

JavaScript
const { data } = await $fetch('https://taxmtd.uk/api/stripe/portal', {
  method: 'POST'
})
// Redirect user to: data.url
Python
res = requests.post(
    "https://taxmtd.uk/api/stripe/portal",
    cookies=session_cookies,
)
portal_url = res.json()["data"]["url"]
# Redirect user to portal_url
PHP
$response = Http::withCookies($session)
    ->post('https://taxmtd.uk/api/stripe/portal');

$url = $response->json()['data']['url'];
return redirect($url);
Rust
let res = client.post("https://taxmtd.uk/api/stripe/portal")
    .send().await?;

#[derive(Deserialize)]
struct PortalResult { url: String }

#[derive(Deserialize)]
struct ApiResponse { data: PortalResult }

let result: ApiResponse = res.json().await?;
// Redirect user to result.data.url
cURL
curl -X POST https://taxmtd.uk/api/stripe/portal \
  -b "session_cookie=..."

Webhooks

TaxMTD processes the following Stripe webhook events:

EventAction
checkout.session.completedCreates subscription record, handles add-on purchases
customer.subscription.updatedUpdates plan (via price ID reverse-lookup), status, period end, syncs add-on items
customer.subscription.deletedMarks subscription and all add-ons as canceled
invoice.payment_failedSets subscription status to past_due

Plan changes made through the Stripe Customer Portal are automatically detected via price ID mapping - no metadata required.