ChinqIT VerifyChinqIT Verify

Supabase phone login with ChinqIT

Send Supabase login codes by SMS with ChinqIT.

Send Supabase Auth login codes by SMS with ChinqIT, using one small function.

Before you start

Create the function

supabase functions new chinqit-sms

Replace the content of supabase/functions/chinqit-sms/index.ts with this code:

// Supabase "Send SMS" hook: checks the Supabase signature, then sends the
// login code as an SMS through ChinqIT.
// Env vars: CHINQIT_API_KEY, SEND_SMS_HOOK_SECRET, CHINQIT_SMS_LANGUAGE (fr, en or ar; default fr).
import { Webhook } from "npm:standardwebhooks@1.0.0";

const TEXTS: Record<string, (otp: string) => string> = {
  fr: (otp) => `Votre code ChinqIT est ${otp}`,
  en: (otp) => `Your ChinqIT code is ${otp}`,
  ar: (otp) => `رمز ChinqIT الخاص بك هو ${otp}`,
};

// Supabase only reads the error message when the hook answers 200, so refusals
// go out as 200 with the real code inside. 429 and 503 stay real statuses so
// Supabase retries.
const reply = (status: number, message?: string) => {
  if (message) console.error(message);
  const retry = status === 429 || status === 503;
  return new Response(JSON.stringify(message ? { error: { http_code: status, message } } : {}), {
    status: message && !retry ? 200 : status,
    headers: { "Content-Type": "application/json", ...(retry && { "retry-after": "true" }) },
  });
};

export async function handler(req: Request): Promise<Response> {
  const secret = Deno.env.get("SEND_SMS_HOOK_SECRET")!.replace(/^v1,/, "").replace(/^whsec_/, "");
  const payload = await req.text();
  let data: { user: { phone: string }; sms: { otp: string } };
  try {
    data = new Webhook(secret).verify(payload, Object.fromEntries(req.headers)) as typeof data;
  } catch {
    return reply(401, "Invalid signature");
  }

  const phone = data.user.phone.startsWith("+") ? data.user.phone : `+${data.user.phone}`;
  const text = TEXTS[Deno.env.get("CHINQIT_SMS_LANGUAGE") ?? "fr"] ?? TEXTS.fr;
  try {
    const res = await fetch("https://sms.chinqit.com/api/v2/notify", {
      method: "POST",
      headers: { "x-api-key": Deno.env.get("CHINQIT_API_KEY")!, "Content-Type": "application/json" },
      body: JSON.stringify({ phoneNumber: phone, message: text(data.sms.otp) }),
      signal: AbortSignal.timeout(4000),
    });
    if (res.ok) return reply(200);
    const err = await res.json().catch(() => ({}));
    const code = err.code ?? err.errorCode;
    const message = err.message ?? "ChinqIT refused the message";
    return reply(res.status, code ? `${code}: ${message}` : message);
  } catch {
    return reply(503, "Could not reach ChinqIT, try again");
  }
}

Deno.serve(handler);

Secrets

supabase secrets set CHINQIT_API_KEY=your_api_key SEND_SMS_HOOK_SECRET=v1,whsec_xxx

You get SEND_SMS_HOOK_SECRET in the "Enable the hook" step. To change the SMS language, add CHINQIT_SMS_LANGUAGE=fr, en or ar (fr by default).

Deploy

supabase functions deploy chinqit-sms --no-verify-jwt

Enable the hook

In Authentication → Sign In / Providers, enable Phone.

  1. In Supabase, open Authentication → Hooks → Send SMS hook and choose HTTPS.
  2. Enter the URL https://<project-ref>.supabase.co/functions/v1/chinqit-sms.
  3. Generate the secret, save it as SEND_SMS_HOOK_SECRET with supabase secrets set, then redeploy the function.

Rate limit

In Authentication → Rate Limits, Supabase allows 30 SMS per hour by default. Raise it to match the number of logins you expect.

Test

await supabase.auth.signInWithOtp({ phone: '+22238089336' })

// After the SMS arrives
await supabase.auth.verifyOtp({ phone: '+22238089336', token: '123456', type: 'sms' })

If it fails

The error appears in the response of signInWithOtp and in the function logs (Edge Functions → chinqit-sms → Logs), as CODE: message. Then see Troubleshooting.

On this page