React hooks and TypeScript types for integrating the Wiregate OTP verification flow into any React or Next.js application. Zero runtime dependencies.
Your Wiregate API key must never be embedded in client-side JavaScript.
gatewire-react never calls the Wiregate API directly. Instead, it accepts adapter functions that call your own backend proxy, which holds the real API key server-side. This is a hard architectural constraint — see Setting up your proxy for ready-to-use examples.
Browser (gatewire-react)
│
│ POST /api/otp/send (no API key)
▼
Your Backend Proxy ──── POST https://gatewire.raystate.com/api/v1/send-otp (API key here)
│
▼
Wiregate API
npm install gatewire-react
# or
yarn add gatewire-react
# or
pnpm add gatewire-reactPeer dependency: React ≥ 17
import { useOtp, createFetchAdapter } from 'gatewire-react';
// Point to your own backend proxy — never the Wiregate API directly
const adapter = createFetchAdapter('/api/otp');
function PhoneVerification() {
const [phone, setPhone] = React.useState('');
const [code, setCode] = React.useState('');
const { state, error, sendOtp, verifyOtp, resend, resendCountdown } = useOtp({
adapter,
resendCooldown: 60, // seconds before user can resend
pollInterval: 3000, // ms between status polls (0 to disable)
});
if (state === 'verified') return <p>Phone verified!</p>;
if (state === 'idle' || state === 'sending') {
return (
<form onSubmit={e => { e.preventDefault(); sendOtp(phone); }}>
<input
type="tel"
value={phone}
onChange={e => setPhone(e.target.value)}
placeholder="+213770123456"
/>
<button type="submit" disabled={state === 'sending'}>
{state === 'sending' ? 'Sending…' : 'Send code'}
</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
return (
<form onSubmit={e => { e.preventDefault(); verifyOtp(code); }}>
<input
type="text"
value={code}
onChange={e => setCode(e.target.value)}
placeholder="6-digit code"
maxLength={6}
/>
<button type="submit" disabled={state === 'verifying'}>
{state === 'verifying' ? 'Verifying…' : 'Verify'}
</button>
<button type="button" onClick={resend} disabled={resendCountdown > 0}>
{resendCountdown > 0 ? `Resend in ${resendCountdown}s` : 'Resend code'}
</button>
{error && <p role="alert">{error}</p>}
</form>
);
}useOtp({ adapter, pollInterval, resendCooldown })
│
├── sendOtp(phone) → calls adapter.sendOtp() → POST /api/otp/send
├── verifyOtp(code) → calls adapter.verifyOtp() → POST /api/otp/verify
├── resend() → alias for sendOtp() with last phone (respects cooldown)
├── reset() → resets all state
└── (internal polling) → calls adapter.getStatus() → GET /api/otp/status/:id
OTP state machine:
idle
│ sendOtp(phone)
▼
sending ──(error)──▶ idle (error set)
│
▼
pending ──(poll)──▶ dispatched ──▶ sent
│ │
│ ┌───────────────┤
│ ▼ ▼
│ failed expired / cancelled
│
│ verifyOtp(code)
▼
verifying ──(error)──▶ sent (error set, user can retry)
│
▼
verified (terminal)
The main hook for the full OTP lifecycle.
function useOtp(options: UseOtpOptions): UseOtpReturnUseOtpOptions
| Property | Type | Default | Description |
|---|---|---|---|
adapter |
GatewireAdapter |
required | Object with sendOtp, verifyOtp, and optional getStatus functions |
pollInterval |
number |
3000 |
Status poll interval in ms. Set to 0 to disable polling |
resendCooldown |
number |
60 |
Seconds the user must wait before resending |
UseOtpReturn
| Property | Type | Description |
|---|---|---|
state |
OtpStatus |
Current lifecycle state |
referenceId |
string | null |
Set after a successful sendOtp call |
error |
string | null |
Human-readable error from the last failed operation |
isLoading |
boolean |
true while sendOtp or verifyOtp is in flight |
resendCountdown |
number |
Seconds remaining in the resend cooldown |
sendOtp(phone, templateKey?) |
() => Promise<void> |
Send an OTP to the given phone number |
verifyOtp(code) |
() => Promise<void> |
Verify the code the user entered |
resend() |
() => Promise<void> |
Resend to the last phone (no-op if cooldown is active) |
reset() |
() => void |
Reset all state to initial values |
OtpStatus values: 'idle' | 'sending' | 'pending' | 'dispatched' | 'sent' | 'verifying' | 'verified' | 'failed' | 'expired' | 'cancelled'
Lower-level composable hook for polling the OTP status independently.
function useOtpStatus(
referenceId: string | null,
getStatus: GatewireAdapter['getStatus'],
options?: { interval?: number; enabled?: boolean }
): { status: OtpStatus | null; error: string | null; isLoading: boolean }Polls getStatus every interval ms (default 3000) while enabled !== false and referenceId is non-null. Automatically stops when a terminal status (sent, verified, failed, expired, cancelled) is received.
Creates a GatewireAdapter using the native fetch API that calls your backend proxy.
function createFetchAdapter(proxyBaseUrl: string): GatewireAdapterExpected routes on your proxy:
| Method | Path | Forwards to |
|---|---|---|
POST |
{proxyBaseUrl}/send |
Wiregate send-otp |
POST |
{proxyBaseUrl}/verify |
Wiregate verify-otp |
GET |
{proxyBaseUrl}/status/:id |
Wiregate status |
Example:
// All calls go to /api/otp/send, /api/otp/verify, /api/otp/status/:id
const adapter = createFetchAdapter('/api/otp');Thrown by createFetchAdapter when the proxy returns a non-2xx response.
class GatewireError extends Error {
readonly statusCode: number | undefined;
readonly body: unknown;
}Catch it to display user-friendly error messages:
import { GatewireError } from 'gatewire-react';
try {
await adapter.sendOtp(phone);
} catch (err) {
if (err instanceof GatewireError) {
console.error(err.message, err.statusCode, err.body);
}
}Note:
useOtpcatchesGatewireErrorautomatically and sets theerrorfield. Only non-GatewireErrorexceptions are re-thrown.
You can write your own adapter instead of using createFetchAdapter:
import type { GatewireAdapter } from 'gatewire-react';
const adapter: GatewireAdapter = {
async sendOtp(phone, templateKey) {
const res = await myApiClient.post('/otp/send', { phone, template_key: templateKey });
return res.data; // { reference_id, status: 'pending' }
},
async verifyOtp(referenceId, code) {
const res = await myApiClient.post('/otp/verify', { reference_id: referenceId, code });
return res.data; // { status: 'verified', message }
},
async getStatus(referenceId) {
const res = await myApiClient.get(`/otp/status/${referenceId}`);
return res.data; // { reference_id, status, created_at }
},
};Your proxy server is the only place that holds the Wiregate API key. It receives requests from gatewire-react and forwards them to the Wiregate API.
import express from 'express';
import { GateWireClient } from 'gatewire'; // Node.js SDK
const gw = new GateWireClient({ apiKey: process.env.GATEWIRE_API_KEY! });
const router = express.Router();
router.post('/otp/send', async (req, res) => {
try {
const { phone, template_key } = req.body as { phone: string; template_key?: string };
const result = await gw.otp.send({ phone, templateKey: template_key });
res.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to send OTP';
res.status(422).json({ error: message });
}
});
router.post('/otp/verify', async (req, res) => {
try {
const { reference_id, code } = req.body as { reference_id: string; code: string };
const result = await gw.otp.verify({ referenceId: reference_id, code });
res.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'Verification failed';
res.status(400).json({ error: message });
}
});
router.get('/otp/status/:id', async (req, res) => {
try {
const result = await gw.otp.getStatus(req.params.id!);
res.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'Status check failed';
res.status(500).json({ error: message });
}
});
export default router;// app/api/otp/send/route.ts
import { GateWireClient } from 'gatewire';
const gw = new GateWireClient({ apiKey: process.env.GATEWIRE_API_KEY! });
export async function POST(request: Request) {
try {
const { phone, template_key } = (await request.json()) as {
phone: string;
template_key?: string;
};
const result = await gw.otp.send({ phone, templateKey: template_key });
return Response.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to send OTP';
return Response.json({ error: message }, { status: 422 });
}
}// app/api/otp/verify/route.ts
import { GateWireClient } from 'gatewire';
const gw = new GateWireClient({ apiKey: process.env.GATEWIRE_API_KEY! });
export async function POST(request: Request) {
try {
const { reference_id, code } = (await request.json()) as {
reference_id: string;
code: string;
};
const result = await gw.otp.verify({ referenceId: reference_id, code });
return Response.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'Verification failed';
return Response.json({ error: message }, { status: 400 });
}
}// app/api/otp/status/[id]/route.ts
import { GateWireClient } from 'gatewire';
const gw = new GateWireClient({ apiKey: process.env.GATEWIRE_API_KEY! });
export async function GET(_request: Request, { params }: { params: { id: string } }) {
try {
const result = await gw.otp.getStatus(params.id);
return Response.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'Status check failed';
return Response.json({ error: message }, { status: 500 });
}
}Then point the adapter at your proxy:
// In your React app — note: this is a relative URL, not the Wiregate API
const adapter = createFetchAdapter('/api/otp');All types are exported from the package root:
import type {
OtpStatus,
GatewireAdapter,
UseOtpOptions,
UseOtpReturn,
SendOtpResult,
VerifyOtpResult,
StatusResult,
} from 'gatewire-react';The library is written in TypeScript with strict: true and ships declaration files (.d.ts) for full type safety.
- Fork the repository
- Create a feature branch:
git checkout -b feat/my-feature - Install dependencies:
npm install - Make your changes, ensuring tests pass:
npm test - Check types:
npm run typecheck - Submit a pull request
Please follow the existing code style and add tests for any new behaviour.
MIT © GateWire Team