Billing, Usage & Add-ons
Subscription Status
Returns the current user's subscription plan and status.
Get Subscription
const { data } = await $fetch('https://www.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
}
import requests
res = requests.get(
"https://www.taxmtd.uk/api/stripe/subscription",
cookies=session_cookies,
)
data = res.json()["data"]
# data["plan"] => "pro"
# data["status"] => "active"
$response = Http::withCookies($session)
->get('https://www.taxmtd.uk/api/stripe/subscription');
$sub = $response->json()['data'];
// $sub['plan'] => "pro"
// $sub['status'] => "active"
#[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://www.taxmtd.uk/api/stripe/subscription")
.send().await?
.json().await?;
curl https://www.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
const { data } = await $fetch('https://www.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
}
res = requests.get(
"https://www.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']}")
$response = Http::withCookies($session)
->get('https://www.taxmtd.uk/api/usage');
$usage = $response->json()['data'];
// $usage['entities'] => 3
// $usage['invoices_monthly'] => 12
// $usage['ocr_receipts_monthly'] => 8
#[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://www.taxmtd.uk/api/usage")
.send().await?
.json().await?;
curl https://www.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
const { data } = await $fetch('https://www.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
}
res = requests.get("https://www.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}")
$response = Http::get('https://www.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";
}
#[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://www.taxmtd.uk/api/plan-config")
.send().await?
.json().await?;
curl https://www.taxmtd.uk/api/plan-config
Usage Caps by Plan
| Resource | Starter | Essential | Pro | Business | Practice |
|---|---|---|---|---|---|
| Bank connections | 1 | 2 | 5 | Unlimited | Unlimited |
| Transactions/mo | 100 | 250 | Unlimited | Unlimited | Unlimited |
| Invoices/mo | - | - | 50 | Unlimited | Unlimited |
| OCR receipts/mo | - | - | 20 | 100 | Unlimited |
| Payroll employees | - | - | - | 5 | 25 |
| Inventory SKUs | - | - | - | 200 | Unlimited |
| Entities | 1 | 1 | 1 | 2 | 25 |
| Team members | 1 | 1 | 3 | 10 | Unlimited |
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-on | Grants | Price |
|---|---|---|
| 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.
const { data } = await $fetch('https://www.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
}
res = requests.get(
"https://www.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']}")
$response = Http::withCookies($session)
->get('https://www.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";
}
#[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://www.taxmtd.uk/api/addons")
.send().await?
.json().await?;
curl https://www.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.
extra_entities, extra_employees, ocr_bundleconst { data } = await $fetch('https://www.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/...' }
res = requests.post(
"https://www.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']}")
$response = Http::withCookies($session)->post(
'https://www.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']}";
}
let res = client.post("https://www.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 -X POST https://www.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.
starter, essential, pro, business, or practicemonthly or annual (default: monthly)const { data } = await $fetch('https://www.taxmtd.uk/api/stripe/checkout', {
method: 'POST',
body: {
priceId: 'price_...',
plan: 'pro',
interval: 'annual'
}
})
// Redirect user to: data.url
res = requests.post(
"https://www.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
$response = Http::withCookies($session)->post(
'https://www.taxmtd.uk/api/stripe/checkout',
[
'priceId' => 'price_...',
'plan' => 'pro',
'interval' => 'annual',
]
);
$url = $response->json()['data']['url'];
return redirect($url);
let res = client.post("https://www.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 -X POST https://www.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.
const { data } = await $fetch('https://www.taxmtd.uk/api/stripe/portal', {
method: 'POST'
})
// Redirect user to: data.url
res = requests.post(
"https://www.taxmtd.uk/api/stripe/portal",
cookies=session_cookies,
)
portal_url = res.json()["data"]["url"]
# Redirect user to portal_url
$response = Http::withCookies($session)
->post('https://www.taxmtd.uk/api/stripe/portal');
$url = $response->json()['data']['url'];
return redirect($url);
let res = client.post("https://www.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 -X POST https://www.taxmtd.uk/api/stripe/portal \
-b "session_cookie=..."
Webhooks
TaxMTD processes the following Stripe webhook events:
| Event | Action |
|---|---|
checkout.session.completed | Creates subscription record, handles add-on purchases |
customer.subscription.updated | Updates plan (via price ID reverse-lookup), status, period end, syncs add-on items |
customer.subscription.deleted | Marks subscription and all add-ons as canceled |
invoice.payment_failed | Sets subscription status to past_due |
Plan changes made through the Stripe Customer Portal are automatically detected via price ID mapping - no metadata required.