Bonaventure OgetoBy Bonaventure Ogeto|

Build Paystack Subscriptions in Supabase Edge Functions

Initialize a Paystack subscription by passing a plan code when calling /transaction/initialize in your Edge Function. After checkout, Paystack fires subscription.create and charge.success webhooks — handle them in a webhook Edge Function to update your Supabase users table with the subscription code, email token, and status.

Subscribe Edge Function

// supabase/functions/subscribe/index.ts
import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response("ok", { headers: corsHeaders });
  }

  const secretKey = Deno.env.get("PAYSTACK_SECRET_KEY")!;
  const { email, planCode } = await req.json();

  if (!email || !planCode) {
    return new Response(
      JSON.stringify({ error: "email and planCode are required" }),
      { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );
  }

  const reference = "sub_" + Date.now() + "_" + crypto.randomUUID().split("-")[0];

  const paystackRes = await fetch(
    "https://api.paystack.co/transaction/initialize",
    {
      method: "POST",
      headers: {
        Authorization: "Bearer " + secretKey,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ email, plan: planCode, reference }),
    }
  );

  const data = await paystackRes.json();

  if (!data.status) {
    return new Response(
      JSON.stringify({ error: data.message }),
      { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );
  }

  // Store pending subscription reference
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
  );

  await supabase.from("subscriptions").upsert({
    email,
    plan_code: planCode,
    reference,
    status: "pending",
    created_at: new Date().toISOString(),
  }, { onConflict: "email" });

  return new Response(
    JSON.stringify({
      authorization_url: data.data.authorization_url,
      reference: data.data.reference,
    }),
    { headers: { ...corsHeaders, "Content-Type": "application/json" } }
  );
});

Subscription Webhook Handler

// In your webhook Edge Function, handle subscription events:
if (event.event === "subscription.create") {
  const sub = event.data;
  await supabase
    .from("subscriptions")
    .update({
      status: "active",
      subscription_code: sub.subscription_code,
      email_token: sub.email_token,
      next_billing_date: sub.next_payment_date,
      activated_at: new Date().toISOString(),
    })
    .eq("email", sub.customer.email);
}

if (event.event === "charge.success" && event.data.plan) {
  // Recurring charge on an existing subscription
  await supabase
    .from("subscriptions")
    .update({
      status: "active",
      last_paid_at: new Date().toISOString(),
      next_billing_date: event.data.plan.next_payment_date,
    })
    .eq("email", event.data.customer.email);
}

if (event.event === "invoice.payment_failed") {
  await supabase
    .from("subscriptions")
    .update({ status: "past_due" })
    .eq("email", event.data.customer.email);
}

if (event.event === "subscription.disable") {
  await supabase
    .from("subscriptions")
    .update({ status: "cancelled" })
    .eq("subscription_code", event.data.subscription_code);
}

Cancel Subscription

// supabase/functions/cancel-subscription/index.ts
import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response("ok", { headers: corsHeaders });
  }

  const { email } = await req.json();
  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
  );

  const { data: sub } = await supabase
    .from("subscriptions")
    .select("subscription_code, email_token")
    .eq("email", email)
    .eq("status", "active")
    .single();

  if (!sub) {
    return new Response(
      JSON.stringify({ error: "No active subscription found" }),
      { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );
  }

  const paystackRes = await fetch(
    "https://api.paystack.co/subscription/disable",
    {
      method: "POST",
      headers: {
        Authorization: "Bearer " + Deno.env.get("PAYSTACK_SECRET_KEY"),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        code: sub.subscription_code,
        token: sub.email_token,
      }),
    }
  );

  const data = await paystackRes.json();

  if (!data.status) {
    return new Response(
      JSON.stringify({ error: data.message }),
      { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );
  }

  await supabase
    .from("subscriptions")
    .update({ status: "cancelled" })
    .eq("email", email);

  return new Response(
    JSON.stringify({ cancelled: true }),
    { headers: { ...corsHeaders, "Content-Type": "application/json" } }
  );
});

Learn More

Key Takeaways

  • Subscribe a user by passing a plan code in the transaction/initialize call from your Edge Function.
  • After checkout, Paystack fires subscription.create with the subscription_code and email_token — store both in Supabase.
  • Handle invoice.payment_failed to mark subscriptions as past_due and send a payment recovery email.
  • Store subscription_code and email_token in your database — you need them to cancel via /subscription/disable.
  • Expose a subscription status endpoint that reads from Supabase, not Paystack, to avoid extra API calls.
  • Use the service role key in Edge Functions to bypass RLS when writing subscription data server-side.

Frequently Asked Questions

How do I create a plan in Paystack for Edge Function subscriptions?
Create the plan once in the Paystack dashboard or via the API (POST /plan). Note the plan code (PLN_xxxx). Pass this as the plan parameter in your /transaction/initialize call. Paystack automatically sets up the subscription after a successful payment.
Where do I get the email_token for cancelling a subscription?
Paystack includes email_token in the subscription.create webhook payload. Store it in your database alongside the subscription_code. You need both to call /subscription/disable. Without the email_token, you cannot cancel via the API.
What is the difference between subscription.create and charge.success for recurring billing?
subscription.create fires once when the subscription is first created (after the initial payment). charge.success fires for every subsequent recurring charge. Handle both to keep your subscription status accurate.
Can I access subscription plans list from a Supabase Edge Function?
Yes. Call GET https://api.paystack.co/plan with your Paystack secret key from Deno.env. This returns all your plans. Cache the response or store plan details in Supabase to avoid repeated API calls from your frontend.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon