McTaba Labs logo
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);
}

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

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.

Learn Paystack Integration

KES 2,999

The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.

Start Paystack Course

Not ready? Create Free Account