diff --git a/README.md b/README.md
index 9a69cc75a..b547b50a0 100644
--- a/README.md
+++ b/README.md
@@ -58,14 +58,7 @@ That's it! š
## š¢ Why urBackend?
-While tools like PocketBase are great for small single-server apps (SQLite), **urBackend** is built for scale.
-
-| Feature | urBackend | Firebase | PocketBase |
-| :--- | :--- | :--- | :--- |
-| **Database** | **MongoDB (Scalable)** | Proprietary NoSQL | SQLite (Single Server) |
-| **Caching** | **Redis Built-in** | None | None |
-| **Hosting** | **Self-Hosted / Cloud** | Cloud Only | Self-Hosted |
-| **Source** | **Open-Source** | Closed-Source | Open-Source |
+**urBackend** is built for developer velocity and horizontal scale, combining the simplicity of a modern BaaS with the power of MongoDB and Redis.
### Core Features:
- **Instant NoSQL:** Create collections and push JSON data instantly.
diff --git a/apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js b/apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js
index 17f0bac8a..e96dadd14 100644
--- a/apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js
+++ b/apps/dashboard-api/src/__tests__/planEnforcement.capabilities.test.js
@@ -8,6 +8,7 @@ jest.mock('@urbackend/common', () => {
const Project = {
countDocuments: jest.fn(),
+ findById: jest.fn(),
findOne: jest.fn(),
};
@@ -16,12 +17,12 @@ jest.mock('@urbackend/common', () => {
Project,
sanitizeObjectId: jest.fn((value) => value || null),
resolveEffectivePlan: jest.fn(() => 'free'),
- getPlanLimits: jest.fn(() => ({ maxProjects: 1, maxCollections: 5 })),
+ getPlanLimits: jest.fn(() => ({ maxProjects: 1, maxCollections: 5, webhooksLimit: 3 })),
};
});
const { Project, AppError } = require('@urbackend/common');
-const { checkDeveloperCapability, checkProjectLimit, checkCollectionLimit } = require('../middlewares/planEnforcement');
+const { checkDeveloperCapability, checkProjectLimit, checkCollectionLimit, checkWebhookGate } = require('../middlewares/planEnforcement');
const makeReq = (overrides = {}) => ({
user: { _id: 'dev_1', email: 'dev@example.com', isVerified: false },
@@ -131,4 +132,39 @@ describe('planEnforcement capability checks', () => {
expect(next).toHaveBeenCalledWith();
});
+
+ test('passes the finite webhook quota to the create controller without pre-counting', async () => {
+ Project.findById.mockReturnValue({
+ select: jest.fn().mockReturnThis(),
+ lean: jest.fn().mockResolvedValue({ customLimits: null }),
+ });
+ const req = makeReq({
+ method: 'POST',
+ params: { projectId: 'project_1' },
+ user: { _id: 'dev_1', email: 'dev@example.com', isVerified: true },
+ developer: { _id: 'dev_1', isVerified: true },
+ });
+ const next = jest.fn();
+
+ await checkWebhookGate(req, {}, next);
+
+ expect(req.webhookQuotaLimit).toBe(3);
+ expect(next).toHaveBeenCalledWith();
+ });
+
+ test('preserves the admin webhook quota bypass', async () => {
+ const req = makeReq({
+ method: 'POST',
+ params: { projectId: 'project_1' },
+ user: { _id: 'admin_1', email: 'admin@example.com', isAdmin: true, isVerified: true },
+ developer: { _id: 'admin_1', isVerified: true },
+ });
+ const next = jest.fn();
+
+ await checkWebhookGate(req, {}, next);
+
+ expect(req.webhookQuotaLimit).toBeUndefined();
+ expect(Project.findById).not.toHaveBeenCalled();
+ expect(next).toHaveBeenCalledWith();
+ });
});
diff --git a/apps/dashboard-api/src/__tests__/webhook.controller.test.js b/apps/dashboard-api/src/__tests__/webhook.controller.test.js
index 723f2c4a1..a5666853b 100644
--- a/apps/dashboard-api/src/__tests__/webhook.controller.test.js
+++ b/apps/dashboard-api/src/__tests__/webhook.controller.test.js
@@ -15,6 +15,7 @@ jest.mock('@urbackend/common', () => {
return {
Webhook: {
create: jest.fn(),
+ countDocuments: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
findOneAndUpdate: jest.fn(),
@@ -26,6 +27,7 @@ jest.mock('@urbackend/common', () => {
},
Project: {
findOne: jest.fn(),
+ updateOne: jest.fn(),
},
AppError,
encrypt: jest.fn((val) => ({ encrypted: 'enc', iv: 'iv', tag: 'tag' })),
@@ -95,6 +97,10 @@ describe('webhook.controller', () => {
next = jest.fn();
});
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
describe('createWebhook', () => {
test('creates webhook with valid input', async () => {
Project.findOne.mockResolvedValue({ _id: validProjectId });
@@ -145,6 +151,90 @@ describe('webhook.controller', () => {
);
});
+ test('checks finite quota and creates the webhook in one transaction', async () => {
+ const session = {
+ withTransaction: jest.fn(async (operation) => operation()),
+ endSession: jest.fn(),
+ };
+ jest.spyOn(mongoose, 'startSession').mockResolvedValue(session);
+ Project.findOne.mockResolvedValue({ _id: validProjectId });
+ Project.updateOne.mockResolvedValue({ matchedCount: 1 });
+ Webhook.countDocuments.mockResolvedValue(2);
+ createWebhookSchema.safeParse.mockReturnValue({
+ success: true,
+ data: {
+ name: 'Test Webhook',
+ url: 'https://example.com/hook',
+ secret: 'whsec_testsecret12345678',
+ events: {},
+ },
+ });
+ Webhook.create.mockResolvedValue([{
+ _id: validWebhookId,
+ projectId: validProjectId,
+ name: 'Test Webhook',
+ url: 'https://example.com/hook',
+ events: new Map(),
+ enabled: true,
+ }]);
+ req.body = {
+ name: 'Test Webhook',
+ url: 'https://example.com/hook',
+ secret: 'whsec_testsecret12345678',
+ };
+ req.webhookQuotaLimit = 3;
+
+ await createWebhook(req, res, next);
+
+ expect(Project.updateOne).toHaveBeenCalledWith(
+ { _id: validProjectId, owner: 'user123' },
+ { $inc: { webhookQuotaVersion: 1 } },
+ { session }
+ );
+ expect(Webhook.countDocuments).toHaveBeenCalledWith(
+ { projectId: validProjectId },
+ { session }
+ );
+ expect(Webhook.create).toHaveBeenCalledWith(
+ [expect.objectContaining({ projectId: validProjectId, name: 'Test Webhook' })],
+ { session }
+ );
+ expect(session.endSession).toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(201);
+ });
+
+ test('does not create a webhook when the transactional quota reservation fails', async () => {
+ const session = {
+ withTransaction: jest.fn(async (operation) => operation()),
+ endSession: jest.fn(),
+ };
+ jest.spyOn(mongoose, 'startSession').mockResolvedValue(session);
+ Project.findOne.mockResolvedValue({ _id: validProjectId });
+ Project.updateOne.mockResolvedValue({ matchedCount: 1 });
+ Webhook.countDocuments.mockResolvedValue(3);
+ createWebhookSchema.safeParse.mockReturnValue({
+ success: true,
+ data: {
+ name: 'Test Webhook',
+ url: 'https://example.com/hook',
+ secret: 'whsec_testsecret12345678',
+ },
+ });
+ req.body = {
+ name: 'Test Webhook',
+ url: 'https://example.com/hook',
+ secret: 'whsec_testsecret12345678',
+ };
+ req.webhookQuotaLimit = 3;
+
+ await createWebhook(req, res, next);
+
+ expect(Webhook.create).not.toHaveBeenCalled();
+ expect(session.endSession).toHaveBeenCalled();
+ expect(next).toHaveBeenCalledWith(expect.any(AppError));
+ expect(next.mock.calls[0][0].statusCode).toBe(403);
+ });
+
test('returns 404 if project not found', async () => {
Project.findOne.mockResolvedValue(null);
diff --git a/apps/dashboard-api/src/controllers/webhook.controller.js b/apps/dashboard-api/src/controllers/webhook.controller.js
index e09777627..019757c71 100644
--- a/apps/dashboard-api/src/controllers/webhook.controller.js
+++ b/apps/dashboard-api/src/controllers/webhook.controller.js
@@ -47,15 +47,48 @@ module.exports.createWebhook = async (req, res, next) => {
// Encrypt the secret
const encryptedSecret = encrypt(secret);
-
- const webhook = await Webhook.create({
+ const webhookData = {
projectId,
name,
url,
secret: encryptedSecret,
events: events || {},
enabled: enabled !== false,
- });
+ };
+
+ let webhook;
+ const quotaLimit = req.webhookQuotaLimit;
+ if (Number.isInteger(quotaLimit) && quotaLimit >= 0) {
+ const session = await mongoose.startSession();
+ try {
+ await session.withTransaction(async () => {
+ // Updating one shared project document serializes concurrent quota
+ // checks for this project. The update rolls back with the insert.
+ const reservation = await Project.updateOne(
+ { _id: projectId, ...getProjectAccessQuery(req.user._id) },
+ { $inc: { webhookQuotaVersion: 1 } },
+ { session }
+ );
+ if (reservation.matchedCount !== 1) {
+ throw new AppError(404, "Project not found");
+ }
+
+ const currentCount = await Webhook.countDocuments(
+ { projectId },
+ { session }
+ );
+ if (currentCount >= quotaLimit) {
+ throw new AppError(403, `Webhook limit reached (${quotaLimit}). Please upgrade your plan for unlimited webhooks.`);
+ }
+
+ [webhook] = await Webhook.create([webhookData], { session });
+ });
+ } finally {
+ await session.endSession();
+ }
+ } else {
+ webhook = await Webhook.create(webhookData);
+ }
// Return without secret
return new ApiResponse({
diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js
index 70cd25cb6..d33220a97 100644
--- a/apps/dashboard-api/src/middlewares/planEnforcement.js
+++ b/apps/dashboard-api/src/middlewares/planEnforcement.js
@@ -186,8 +186,15 @@ exports.checkWebhookGate = async function(req, res, next) {
return next(new AppError(403, 'Verify your email to create or test webhooks.'));
}
+ // Only enforce count limit on new webhook creation (POST without webhookId)
+ const isCreate = req.method === 'POST' && !req.params.webhookId;
+ if (!isCreate) {
+ return next();
+ }
+
const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId;
const cleanProjectId = sanitizeObjectId(rawProjectId);
+ if (!cleanProjectId) return next(new AppError(400, 'Invalid or missing projectId'));
let customLimits = null;
if (cleanProjectId) {
@@ -198,9 +205,9 @@ exports.checkWebhookGate = async function(req, res, next) {
const effectivePlan = resolveEffectivePlan(req.developer);
const limits = getPlanLimits({ plan: effectivePlan, customLimits });
- if (limits.webhooksLimit === 0) {
- return next(new AppError(403, 'Webhooks are a Pro feature. Please upgrade to create integrations.'));
- }
+ // The controller performs the authoritative quota check and insert in
+ // one transaction. Passing the limit avoids a count-then-create race.
+ req.webhookQuotaLimit = limits.webhooksLimit;
next();
} catch (err) {
diff --git a/apps/landing/src/pages/pricing.astro b/apps/landing/src/pages/pricing.astro
index 05ab5babd..d890cfc85 100644
--- a/apps/landing/src/pages/pricing.astro
+++ b/apps/landing/src/pages/pricing.astro
@@ -5,29 +5,27 @@ import Footer from '../components/Footer.astro';
import { Check, ChevronDown, HelpCircle, ArrowRight } from 'lucide-react';
const FREE_FEATURES = [
- '1 Project',
- '5 Collections per project',
+ '1 Project, 5 Collections',
'2,000 API requests / day',
- '10MB File Storage',
'200 Auth Users',
- '25 Emails / month',
- 'Global email templates',
+ '3 Active Webhooks',
+ '50 Emails / month (Sends & Broadcasts)',
+ 'Custom HTML Email Templates',
+ 'Bring Your Own MongoDB (BYOM)',
+ 'Bring Your Own Storage (S3 / R2)',
+ 'Bring Your Own Keys (Resend, OAuth, AI)',
+ 'Team Collaboration (up to 2 members)',
+ 'Analytics Pro & Deep Insights',
'Community support',
];
const PRO_FEATURES = [
- '10 Projects',
- 'Unlimited collections',
- 'Unlimited API requests',
+ '10 Projects, Unlimited Collections',
+ 'Unlimited API requests (600 req/min burst)',
'Unlimited Auth Users',
- 'Unlimited Webhooks',
- 'External Database (BYOM)',
- 'Bring your own Storage (S3/R2)',
+ 'Unlimited Webhooks + Priority Delivery',
'1,000 Emails / month',
- 'Custom HTML email templates',
- 'BYOK ā own API keys',
- 'Analytics Pro',
- 'AI integrations (OpenAI, Groq)',
+ 'Up to 6 Team Members',
'Priority support',
];
@@ -38,7 +36,7 @@ const PRICING_FAQS = [
},
{
q: 'Can I bring my own MongoDB Atlas cluster on the Free tier?',
- a: 'The Free tier includes urBackend managed database storage. Bring Your Own MongoDB (BYOM) is available on the Pro tier during beta.'
+ a: 'Yes! Both Free and Pro tiers support Bring Your Own MongoDB (BYOM) and Bring Your Own Storage (BYOS). Connect your Atlas cluster or S3 bucket directly on any plan.'
},
{
q: 'How does the free 1-month Pro trial work?',
@@ -46,7 +44,7 @@ const PRICING_FAQS = [
},
{
q: 'Do you offer team collaboration?',
- a: 'Yes. Pro plans support inviting team members with granular project access controls and role-based permissions.'
+ a: 'Yes. Free plans allow up to 2 team members (owner + 1 collaborator). Pro plans support up to 6 members with role-based access control.'
}
];
---
diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js
index 6fae46f00..be6c1dc1f 100644
--- a/apps/public-api/src/controllers/mail.controller.js
+++ b/apps/public-api/src/controllers/mail.controller.js
@@ -727,12 +727,12 @@ module.exports.deleteContact = async (req, res, next) => {
}
};
-// --- BROADCASTS (BYOK + Pro Gate) ---
+// --- BROADCASTS (BYOK Gate + Quota Tracking) ---
const requireBroadcastGate = async (req) => {
const { resend, usingByok } = await resolveResendClient(req);
if (!usingByok || !req.planLimits?.byokEnabled) {
- const err = new Error("Broadcasts require both a BYOK Resend key and a Pro plan.");
+ const err = new Error("Broadcasts require a configured BYOK Resend key.");
err.statusCode = 403;
throw err;
}
@@ -766,17 +766,34 @@ module.exports.createBroadcast = async (req, res, next) => {
};
module.exports.sendBroadcast = async (req, res, next) => {
+ let consumedQuotaKey = null;
try {
const { id } = req.params;
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
return next(new AppError(400, "Invalid broadcast ID format."));
}
const resend = await requireBroadcastGate(req);
+
+ const projectId = req.project?._id?.toString() || req.project?._id;
+ if (projectId) {
+ const limit = getMonthlyMailLimit(req.project, req.planLimits);
+ const { count, key } = await reserveMonthlyMailSlot(projectId, limit);
+ consumedQuotaKey = key;
+ }
+
const { data, error } = await resend.broadcasts.send(id);
- if (error) return next(new AppError(error.statusCode || 500, error.message));
+ if (error) {
+ if (consumedQuotaKey) {
+ await redis.decr(consumedQuotaKey).catch(() => {});
+ }
+ return next(new AppError(error.statusCode || 500, error.message));
+ }
return new ApiResponse(data).send(res, 200);
} catch (err) {
+ if (consumedQuotaKey) {
+ await redis.decr(consumedQuotaKey).catch(() => {});
+ }
return next(new AppError(err.statusCode || 500, err.message));
}
};
diff --git a/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx b/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx
index add9b3fa6..2b138f19b 100644
--- a/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx
+++ b/apps/web-dashboard/src/components/Dashboard/StatsRow.jsx
@@ -1,8 +1,12 @@
import React from 'react';
import { Folder, Activity, Zap } from 'lucide-react';
import StatCard from './StatCard';
+import { useAuth } from '../../context/AuthContext';
const StatsRow = ({ projectsCount }) => {
+ const { user } = useAuth();
+ const isPro = user?.plan === 'pro';
+
return (
{
/>
);
diff --git a/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx b/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx
index 161f45563..a19bd269c 100644
--- a/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx
+++ b/apps/web-dashboard/src/components/Dashboard/UsageQuota.jsx
@@ -67,7 +67,7 @@ const UsageQuota = () => {
{
limit={limits?.storageBytes ?? 10485760}
formatValue={formatBytes}
unit=""
- unlimited={limits?.storageBytes === -1}
- tooltip={limits?.storageBytes === -1 ? "Bring Your Own Storage (BYOS) enabled: Connect an external storage provider for unlimited storage." : ""}
+ unlimited={limits?.storageBytes === -1 || limits?.byosEnabled}
+ tooltip={limits?.byosEnabled ? "Bring Your Own Storage (BYOS) enabled: Connect an external storage provider for unlimited storage." : (limits?.storageBytes === -1 ? "Unlimited managed file storage" : "")}
/>
{
{!isPro && (