Recipe: Multi-tenant SaaS
You're building a SaaS where each of your customers wants to send their own end-users through Verdacert. Two shapes; this recipe is the decision matrix and the working code for both.
The two shapes
Pick before you write a line of code — it changes how you store keys and how webhooks route.
| One key per tenant | One platform key + per-call webhookUrl | |
|---|---|---|
| Who pays | Each tenant pays Verdacert directly (their card). | You pay Verdacert; you bill your tenants. |
| Revocation blast radius | Revoke one tenant; others unaffected. | Revoke = everyone breaks. Use overrides instead. |
| Spend cap granularity | Per-tenant. | Per-platform only. |
| Webhook routing | Each key has its own URL; events arrive pre-routed. | One URL; route on endUser.externalId in the payload. |
| Onboarding friction | Each tenant runs /onboarding and adds a card. | You onboard once; tenants don't see Verdacert directly. |
| Best when | Your product is a platform tenants sign up for separately. Verdacert is one of many services they connect. | Your product wraps Verdacert as a feature; your tenants don't know it exists. |
Shape A · One key per tenant
Each tenant onboards via /onboarding, mints their own key in /portal/api, and hands you the token. You store it, scoped to their tenant row, and use it for every call on their behalf.
Tenant key storage
// Schema sketch — actual storage depends on your stack.
type Tenant = {
id: string; // your internal id
name: string;
// Encrypted at rest. Never log this; never expose to clients.
verdacertApiKey: EncryptedString;
// Same — you'll use this to verify webhooks for events on this tenant.
verdacertWebhookSecret: EncryptedString;
status: "active" | "suspended";
};Resolving the right key per request
// Resolve the calling tenant from your session, then load + decrypt their key.
async function verdacertHeadersFor(tenantId: string): Promise<HeadersInit> {
const t = await db.tenants.findUnique({ where: { id: tenantId } });
if (!t || t.status !== "active") throw new Error("tenant_inactive");
const apiKey = await decrypt(t.verdacertApiKey);
return {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
};
}
async function placeOrderForTenant(tenantId: string, input: OrderInput) {
const headers = await verdacertHeadersFor(tenantId);
// ... same quote+submit flow as the nextjs-server-action recipe ...
}Webhook routing
Each tenant's key has its own webhook URL. Configure them to point to https://yourapp.com/webhooks/verdacert/<tenantId> so the tenant is in the path:
// app/webhooks/verdacert/[tenantId]/route.ts
import { verifyVerdacertSignature } from "@/lib/verdacert-verify";
export async function POST(
req: Request,
{ params }: { params: Promise<{ tenantId: string }> },
) {
const { tenantId } = await params;
const t = await db.tenants.findUnique({ where: { id: tenantId } });
if (!t) return new Response("not found", { status: 404 });
const raw = await req.text();
const sig = req.headers.get("x-verdacert-signature") ?? "";
const secret = await decrypt(t.verdacertWebhookSecret);
if (!verifyVerdacertSignature(raw, sig, secret)) {
return new Response("bad signature", { status: 400 });
}
await enqueueTenantEvent(tenantId, JSON.parse(raw));
return new Response("ok", { status: 200 });
}Shape B · One platform key, per-call webhookUrl
You hold a single live key. Every submit call passes webhookUrl (per-call override) plus endUser.externalId identifying the originating tenant. Events come back tagged.
Submit with tenant routing baked in
async function placeOrder({
tenantId,
endUserId,
documentUrl,
sourceLanguage,
useCase,
pageCount,
}: {
tenantId: string;
endUserId: string;
documentUrl: string;
sourceLanguage: string;
useCase: string;
pageCount: number;
}) {
const headers = {
Authorization: `Bearer ${process.env.VERDACERT_API_KEY!}`,
"Content-Type": "application/json",
};
const quoteInput = { sourceLanguage, useCase, pageCount, speedTier: "standard" };
const quote = await fetch("https://verdacert.com/api/v1/quote",
{ method: "POST", headers, body: JSON.stringify(quoteInput) }).then(r => r.json());
// Build a tenant-scoped idempotency key so the same end-user retrying
// doesn't collide with a different tenant's submission.
const idempotencyKey =
"ord_" + sha256(`${tenantId}.${endUserId}.${documentUrl}`).slice(0, 24);
return fetch("https://verdacert.com/api/v1/submit", {
method: "POST",
headers,
body: JSON.stringify({
quoteId: quote.quoteId,
idempotencyKey,
echoedInput: quoteInput,
documents: [{ url: documentUrl }],
endUser: { externalId: `${tenantId}:${endUserId}` },
// Per-call override — events for THIS order go here, not to the
// key-level webhookUrl (which can be a fallback / admin firehose).
webhookUrl: `https://yourapp.com/webhooks/verdacert/${tenantId}`,
metadata: { tenantId, endUserId },
}),
}).then(r => r.json());
}Webhook receiver — same as Shape A
With the tenant id in the URL, the receiver code is identical. The only difference: the signing secret is the platform key's single secret (since all events fan out from one key), not a per-tenant secret.
Spend caps and revenue attribution
A small but real ops concern — your finance team will ask.
- Shape A:Each tenant sets their own cap in their portal. You can't enforce it; they can.
- Shape B: Set the platform cap to your total monthly comfort. Enforce per-tenant caps in YOUR code — count submits per tenant in your DB and refuse the call before it hits Verdacert.
- For revenue share / referral payouts (Shape A only — your tenants are first-class Verdacert customers), see /business/agents.
Gotchas
The things we've seen go wrong.
- Idempotency key collisions across tenants (Shape B). Include the tenant id in the key — without it, two tenants with the same internal user id can collide.
- Logging raw tenant keys (Shape A) on failure. Log the prefix (
vc_live_abc…) only — same hygiene as logging API tokens generally. - Forgetting to handle PAYMENT_REQUIRES_ACTION (Shape A) — the tenant's end-user, not yours, needs to complete 3DS. Surface the next_action URL through your tenant.
- One tenant's rate limit takes the rest with it (Shape B, when traffic is bursty). Use your own rate-limit pre-check; route around backpressure inside your platform.
