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

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 Auth | Cred field | Role |
|---|---|---|
auth.users.email | user_email / email | Primary identity. The same email is the same wallet across all apps in the project |
auth.users.id | user_id / external_id | Your 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:
/deductis the balance check. It's atomic and returns HTTP 400 withcode: "INSUFFICIENT_CREDITS"rather than letting a balance go negative. A user Cred has never seen gets404 USER_NOT_FOUND, which is effectively a zero balance. That's why thewelcomeaction runs at sign-in.- Idempotency keys go in the
Idempotency-Keyheader (a body fieldidempotency_keyalso works). A retry with the same key returns the first result withidempotent_replay: true, and nothing is charged twice. - If the paid work fails after a successful deduct, refund it with
POST /refundand{ "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 insupabase secretsand is read only throughDeno.env. - Trusting a
user_idor email from the request body. Anyone can post any ID. Take identity fromsupabase.auth.getUser(token). - Accepting an
amountfrom the client. Map an action name to a server-side price, asCOSTSdoes. - Mixed-case emails.
Ana@Example.comandana@example.comare 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_idalone for them, as theidentityobject 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
- Supabase: Edge Functions and the quickstart
- Supabase: Edge Function secrets
- Supabase: Auth in Edge Functions and
auth.getUser() - Supabase: Function configuration (
verify_jwt) - Supabase:
functions.invoke() - cred.diy: End users, Managing credits, Authentication, Refunds, Hosted checkout, Webhooks, Pricing

How to Add a Credit System to a Next.js AI App (App Router)
Build a Next.js App Router route handler that deducts credits per AI generation with an idempotency key, refunds failed runs, and keeps the Cred API key server-side.

Building AI-Powered Apps with Credit Metering
How to implement per-request credit billing for AI features like image generation, chat completions, and embeddings.