Blog
    Tutorial7 min read

    How to Add Usage Credits to a Supabase App with an Edge Function

    Verify the Supabase Auth user in an Edge Function, map them to a Cred end user by email, and deduct credits per action with the API key stored as a Supabase secret.

    By cred.diy Team

    How to Add Usage Credits to a Supabase App with an Edge Function

    You'll build a Supabase Edge Function, use-credits, that verifies the caller's Supabase Auth session, maps that auth user to a Cred end user by email, and deducts credits for each metered action. The Cred API key is stored as a Supabase secret, so your frontend only ever sends its normal Supabase session.

    Supabase handles identity and Cred keeps the balances. A few lines of mapping in the Edge Function connect the two.

    Prerequisites

    • A Supabase project with Auth enabled and the Supabase CLI linked to it.
    • A cred.diy project with a credit type. Copy its ID from Dashboard → Credit Types.
    • A Cred API key (sk_live_...) from Dashboard → API Keys.

    How Supabase users map to Cred users

    Cred identifies end users email-first within a project:

    Supabase AuthCred fieldRole
    auth.users.emailuser_email / emailPrimary identity. The same email is the same wallet across all apps in the project
    auth.users.iduser_id / external_idYour internal reference, used when no email matches

    Send both on every call. Cred looks up the user by email first and falls back to the external ID, which also covers users without an email, such as phone or anonymous sign-ins.

    Emails are matched exactly, so always lowercase them before sending.

    Step 1: Store the secrets

    supabase secrets set CRED_API_KEY=sk_live_your_api_key CRED_CREDIT_TYPE_ID=your_credit_type_id
    

    For local development with supabase functions serve, put the same two lines in supabase/functions/.env and add that file to .gitignore. SUPABASE_URL and SUPABASE_ANON_KEY are available to functions as default secrets.

    Step 2: Create the function

    The command is from the Edge Functions quickstart:

    supabase functions new use-credits
    

    Step 3: Verify the user, then call Cred

    // supabase/functions/use-credits/index.ts
    import { createClient } from "npm:@supabase/supabase-js@2";
    
    const CRED_API = "https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/credits-api";
    const CREDIT_TYPE_ID = Deno.env.get("CRED_CREDIT_TYPE_ID")!;
    
    // Prices live on the server. The client names an action; it never sends an amount.
    const COSTS: Record<string, number> = { summarize: 2, export_pdf: 10 };
    
    const cors = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
    };
    const json = (body: unknown, status = 200) =>
      new Response(JSON.stringify(body), { status, headers: { ...cors, "Content-Type": "application/json" } });
    
    async function cred(path: string, init: RequestInit = {}) {
      const res = await fetch(`${CRED_API}${path}`, {
        ...init,
        headers: {
          "Content-Type": "application/json",
          "x-api-key": Deno.env.get("CRED_API_KEY")!,
          ...init.headers,
        },
      });
      return { status: res.status, body: await res.json().catch(() => ({})) };
    }
    
    Deno.serve(async (req) => {
      if (req.method === "OPTIONS") return new Response("ok", { headers: cors });
    
      // 1. Identify the caller from their Supabase JWT, never from the request body.
      const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!);
      const token = (req.headers.get("Authorization") ?? "").replace("Bearer ", "");
      const { data: { user } } = await supabase.auth.getUser(token);
      if (!user) return json({ error: "Unauthorized" }, 401);
    
      const identity = {
        user_id: user.id,
        ...(user.email ? { user_email: user.email.toLowerCase() } : {}),
      };
      const { action, requestId } = await req.json();
    
      // 2. Starter credits. One fixed key per user, so calling this on every sign-in is safe.
      if (action === "welcome") {
        const r = await cred("/issue", {
          method: "POST",
          headers: { "Idempotency-Key": `welcome:${user.id}` },
          body: JSON.stringify({ ...identity, credit_type_id: CREDIT_TYPE_ID, amount: 20, reason: "Welcome credits" }),
        });
        return json({ balance: r.body.new_balance }, r.status);
      }
    
      // 3. Current balance.
      if (action === "balance") {
        const q = user.email
          ? `email=${encodeURIComponent(user.email.toLowerCase())}`
          : `user_id=${encodeURIComponent(user.id)}`;
        const r = await cred(`/balance?${q}&credit_type_id=${CREDIT_TYPE_ID}`);
        return json({ balance: r.body.balances?.[0]?.balance ?? 0 });
      }
    
      // 4. Charge for a metered action.
      const cost = COSTS[action];
      if (!cost) return json({ error: "Unknown action" }, 400);
      if (typeof requestId !== "string" || !requestId) return json({ error: "requestId is required" }, 400);
    
      const charge = await cred("/deduct", {
        method: "POST",
        headers: { "Idempotency-Key": `${action}:${user.id}:${requestId}` },
        body: JSON.stringify({
          ...identity,
          credit_type_id: CREDIT_TYPE_ID,
          amount: cost,
          reason: action,
          reference_id: requestId,
        }),
      });
    
      if (charge.body.code === "INSUFFICIENT_CREDITS" || charge.body.code === "USER_NOT_FOUND") {
        return json({ error: "Not enough credits", cost }, 402);
      }
      if (charge.status !== 200) return json({ error: "Billing unavailable" }, 503);
      if (charge.body.idempotent_replay) return json({ error: "Already processed" }, 409);
    
      // 5. Do the paid work here (or return and let the caller proceed).
      return json({ ok: true, transaction_id: charge.body.transaction_id, balance: charge.body.new_balance });
    });
    

    supabase.auth.getUser(jwt) checks the token with Supabase Auth over the network, so the user it returns can be trusted for authorization (reference). Supabase's Edge Functions auth guide also documents a withSupabase helper that exposes the verified claims. Either approach works here.

    Things to note:

    • /deduct is the balance check. It's atomic and returns HTTP 400 with code: "INSUFFICIENT_CREDITS" rather than letting a balance go negative. A user Cred has never seen gets 404 USER_NOT_FOUND, which is effectively a zero balance. That's why the welcome action runs at sign-in.
    • Idempotency keys go in the Idempotency-Key header (a body field idempotency_key also works). A retry with the same key returns the first result with idempotent_replay: true, and nothing is charged twice.
    • If the paid work fails after a successful deduct, refund it with POST /refund and { "transaction_id": "..." }. That credits the user back, and each transaction can be refunded only once.

    Step 4: Deploy with JWT verification on

    supabase functions deploy use-credits
    

    By default (verify_jwt = true), Supabase checks the JWT before your function runs. Leave that on. Turning it off lets anyone invoke the function, so if you ever do, keep the getUser check above.

    Step 5: Call it from your app

    Use supabase.functions.invoke, and pass the session's access token explicitly so the function always receives it:

    import { supabase } from "./supabaseClient";
    
    const { data: { session } } = await supabase.auth.getSession();
    const headers = { Authorization: `Bearer ${session?.access_token}` };
    
    // After sign-in:
    await supabase.functions.invoke("use-credits", { headers, body: { action: "welcome" } });
    
    // Per metered action. Create the requestId once per click; reuse it only to retry that click.
    const { data, error } = await supabase.functions.invoke("use-credits", {
      headers,
      body: { action: "summarize", requestId: crypto.randomUUID() },
    });
    if (error) {
      // A non-2xx response (402 Not enough credits, 409, ...) arrives as a FunctionsHttpError;
      // read its body with await error.context.json().
    }
    

    Step 6: Keep emails in sync

    When a user changes their email in Supabase, tell Cred from server code:

    await cred("/users", {
      method: "POST",
      body: JSON.stringify({ external_id: user.id, email: newEmail.toLowerCase() }),
    });
    

    Nothing has the new email yet, so Cred falls back to external_id and updates that user's email in place. One exception: if the new address already belongs to a different wallet in your project, email-first lookup will resolve to that wallet from now on. Decide whether that's what you want before allowing the change.

    For an existing user base, POST /users/bulk syncs up to 1,000 users per request: { users: [{ external_id, email, name }] }.

    Common mistakes

    • Calling Cred from the browser with the key in a VITE_ variable. Anything in your frontend bundle is public. The key belongs in supabase secrets and is read only through Deno.env.
    • Trusting a user_id or email from the request body. Anyone can post any ID. Take identity from supabase.auth.getUser(token).
    • Accepting an amount from the client. Map an action name to a server-side price, as COSTS does.
    • Mixed-case emails. Ana@Example.com and ana@example.com are different lookups. Lowercase both sides.
    • A fresh idempotency key per retry, or one key for many actions. Use one key per user action. Reusing a key for a different request returns 409 IDEMPOTENCY_KEY_REUSED.
    • Assuming every auth user has an email. Phone and anonymous users don't. Send user_id alone for them, as the identity object does.
    • Granting credits from a client-side "payment success" page. Purchases made through Cred's hosted checkout are credited by Cred's Stripe webhook. A success page anyone can open must not issue credits.

    FAQ

    Why map by email instead of the Supabase user UUID? You send both. Email is what lets one person keep one balance across several apps that share a Cred project, while the UUID still resolves the user when there's no email.

    Should I mirror balances into a Supabase table? Only as a cache. Subscribe to the credits.issued and credits.deducted webhooks, write them to a table, and treat Cred's ledger as the source of truth.

    How do users buy more credits? Add a checkout action that posts { user_id, user_email, pricing_tier_id, success_url, cancel_url } to https://eutdgemlrpvnxfkkpvlu.supabase.co/functions/v1/hosted-checkout with the same x-api-key header, and return the url it responds with. See Hosted checkout.

    Can I use Row Level Security instead? RLS protects your own tables. Cred is called over HTTP with a secret key, so the protection here is that only the Edge Function holds that key.

    What does it cost? Every Cred API call uses 1 CRD from your Cred account. See pricing.

    Sources

    Try it on a real project.