API documentation

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 tenantOne platform key + per-call webhookUrl
Who paysEach tenant pays Verdacert directly (their card).You pay Verdacert; you bill your tenants.
Revocation blast radiusRevoke one tenant; others unaffected.Revoke = everyone breaks. Use overrides instead.
Spend cap granularityPer-tenant.Per-platform only.
Webhook routingEach key has its own URL; events arrive pre-routed.One URL; route on endUser.externalId in the payload.
Onboarding frictionEach tenant runs /onboarding and adds a card.You onboard once; tenants don't see Verdacert directly.
Best whenYour 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";
};
Encrypt at rest
Tenant API keys are credentials. Use your platform's encryption-at-rest primitive (AWS KMS / Doppler / GCP KMS envelope encryption). Never store raw. Never log raw on a failed request — log the prefix only.

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.

Where to next

Get instant quotePricing