App with no backend (SDK)
@authaction/whatsapp-sdk signs people in with WhatsApp from your app and
gives it ordinary AuthAction tokens: an ID token, an access token and a
refresh token. It uses PKCE, so the app needs no backend and no client
secret, and nothing opens a browser. It works in browsers, React Native and
Expo.
Before you start, create the WhatsApp connection and enable it for your
application, as in Set it up in the dashboard.
You need the application’s Client ID and your tenant domain,
<tenant>.<region>.authaction.com.
1. Install
Section titled “1. Install”npm install @authaction/whatsapp-sdk2. Sign in
Section titled “2. Sign in”signIn starts a sign-in, hands you the links to show, waits for the message
and returns the tokens.
import { WhatsappAuth } from "@authaction/whatsapp-sdk";import QRCode from "qrcode";
const auth = new WhatsappAuth({ domain: "<tenant>.<region>.authaction.com", clientId: "<CLIENT_ID>", audience: "<API_IDENTIFIER>", // optional: an access token for your API storage: { get: (k) => localStorage.getItem(k), set: (k, v) => localStorage.setItem(k, v), remove: (k) => localStorage.removeItem(k), },});
const tokens = await auth.signIn({ open: async (s) => { // On a phone, the link opens WhatsApp. On a computer, the person scans // the QR code with the phone that has WhatsApp and sends from there. signInLink.href = s.webUrl; qrImage.src = await QRCode.toDataURL(s.webUrl); },});// tokens.idToken, tokens.accessToken, tokens.refreshToken, tokens.expiresAtReact Native / Expo
Section titled “React Native / Expo”import * as Crypto from "expo-crypto";import * as SecureStore from "expo-secure-store";import { AppState, Linking } from "react-native";import QRCode from "react-native-qrcode-svg";import { WhatsappAuth } from "@authaction/whatsapp-sdk";
const auth = new WhatsappAuth({ domain: "<tenant>.<region>.authaction.com", clientId: "<CLIENT_ID>", randomBytes: (n) => Crypto.getRandomBytes(n), // React Native has no crypto.getRandomValues storage: { get: (k) => SecureStore.getItemAsync(k), set: (k, v) => SecureStore.setItemAsync(k, v), remove: (k) => SecureStore.deleteItemAsync(k), },});
// Check at once when the person comes back from WhatsApp.AppState.addEventListener("change", (s) => s === "active" && auth.checkNow());
const tokens = await auth.signIn({ open: async (s) => { if (await Linking.canOpenURL(s.whatsappUrl)) await Linking.openURL(s.whatsappUrl); else showQr(<QRCode value={s.webUrl} size={220} />); // WhatsApp is on another phone },});To let the app check whether WhatsApp is installed, add whatsapp to
LSApplicationQueriesSchemes on iOS, and
<queries><package android:name="com.whatsapp" /></queries> to
AndroidManifest.xml on Android.
Your own screen in between
Section titled “Your own screen in between”signIn is start, your open, then waitForTokens. Call them separately
to show your own waiting screen or a cancel button:
const started = await auth.start(); // { transactionId, whatsappUrl, webUrl, message, expiresAt }
const tokens = await auth.waitForTokens(started, { signal: abortController.signal, // cancel intervalMs: 2000, onStatus: (s) => console.log(s), // "pending" | "verified" | "completed" | "expired"});A dropped connection or a server error doesn’t end the wait. The SDK keeps checking until the code runs out, so a weak signal only delays the sign-in.
3. Stay signed in
Section titled “3. Stay signed in”With storage, the SDK keeps the tokens and refreshes them for you:
const accessToken = await auth.getAccessToken(); // refreshed a minute before it runs outawait auth.signOut(); // revokes the refresh token and forgets the tokensRefresh tokens rotate, and each refresh grants the application’s Refresh token lifetime again (30 days unless you set it, up to a year).
Errors are WhatsappSignInError with a code:
| Code | Meaning |
|---|---|
expired | The 10-minute code ran out. Start again. |
aborted | You cancelled the wait with signal. |
signed_out | There are no tokens to refresh. Sign in again. |
http | AuthAction returned an error. |
no_random | No secure random source. Pass randomBytes. |
4. Call your API
Section titled “4. Call your API”Your API checks the access token like any AuthAction token: a JWT signed with
the keys at https://<tenant>.<region>.authaction.com/.well-known/jwks.json,
with azp set to your Client ID and aud to your API identifier. See
Backend Integrations for ready-made middleware.
Native iOS and Android, without the SDK
Section titled “Native iOS and Android, without the SDK”The SDK is JavaScript. A Swift or Kotlin app makes the same three calls itself, and the connection’s Quickstart tab in the dashboard has complete Swift and Kotlin code.
For every sign-in, make a random code_verifier of 43 to 128 characters and
keep it in the app. Send only
code_challenge = BASE64URL(SHA-256(code_verifier)).
Start the sign-in. No token is needed: the Client ID and the challenge are enough.
curl --request POST \ --url https://<tenant>.<region>.authaction.com/api/v1/whatsapp-public/transaction \ --header 'content-type: application/json' \ --data '{ "client_id": "<CLIENT_ID>", "code_challenge": "<CODE_CHALLENGE>", "code_challenge_method": "S256" }'{ "transactionId": "KQc0LuRcYsvlRNXnVS3keUwYJiuNhUoX", "message": "Sign in to <app>. Send this message as it is.\n\n2CK4…", "whatsappUrl": "whatsapp://send?phone=…&text=…", "webUrl": "https://wa.me/…?text=…", "expiresAt": "2026-10-30T10:10:00.000Z"}Open WhatsApp and wait. Open whatsappUrl, or show webUrl as a QR code,
then poll every 2 seconds:
curl 'https://<tenant>.<region>.authaction.com/api/v1/whatsapp-public/transaction/<TRANSACTION_ID>/status?client_id=<CLIENT_ID>'{ "status": "pending" }{ "status": "verified", "nonce": "…" }{ "status": "expired" }Redeem the nonce with the code_verifier. audience is optional.
curl --request POST \ --url https://<tenant>.<region>.authaction.com/oauth2/token \ --header 'content-type: application/json' \ --data '{ "grant_type": "urn:authaction:params:oauth:grant-type:whatsapp", "client_id": "<CLIENT_ID>", "nonce": "<NONCE>", "code_verifier": "<CODE_VERIFIER>", "audience": "<API_IDENTIFIER>" }'{ "id_token": "eyJhbGciOiJSUzI1NiIs…", "access_token": "eyJhbGciOiJSUzI1NiIs…", "refresh_token": "3f9c1e…", "expires_in": 86400, "refresh_token_expires_in": 2592000, "token_type": "Bearer"}Only the app holding the code_verifier can redeem the nonce, so the nonce is
useless to anyone else who sees the status. Refresh with the Client ID and no
secret:
curl --request POST \ --url https://<tenant>.<region>.authaction.com/oauth2/token \ --header 'content-type: application/json' \ --data '{ "grant_type": "refresh_token", "client_id": "<CLIENT_ID>", "refresh_token": "<REFRESH_TOKEN>" }'