PWA integration
HOW TO LOG YOUR USERS IN TO THE AUTHIC LOYALTY PROGRAM AND OPEN IT INSIDE YOUR APP.
This is a quick overview of the integration required for connecting the Authic PWA and loyalty system with your platform. The implementation is intentionally lightweight and should be straightforward for your team.
How it fits together
A full two-way integration with Authic consists of three parts, each with its own guide:
- PWA Integration (this guide): log your users in to the loyalty program and open it inside your app.
- Booking Platform Data Requirements: send us completed bookings and events, so members earn points.
- Outbound Webhooks: receive points and tier changes back, so you can keep your own systems in sync.
Authentication and user login
This is not a full OIDC or SAML SSO implementation. Instead, it is a simple server-to-server login flow. Your backend calls our authentication endpoint with the tenant headers and shared secret, along with a user identifier. We then find or create the user, issue the session tokens and return a single PWA access URL.
In practice, the flow looks like this:
POST /v2/auth/login_sdkfrom your backend- Send a user identifier + optional profile fields
- Receive a fully authenticated PWA URL, resembling:
/oauth?accessToken=...&refreshToken=...&bg=... - Open the URL in a WebView
curl -X POST https://api-perks.authic.io/v2/auth/login_sdk \
-H "x-perks-tenant: UNIQUE_UUID" \
-H "x-perks-tenant-secret: UNIQUE_SECRET" \
-H "x-language: USER_LANGUAGE_CODE" \
-H "Content-Type: application/json" \
-d '{
"type": "externalID",
"externalID": "unique_user_id_001",
"email": "jane.doe@example.com",
"name": "Jane Doe"
}'
# Response: PWA access URL string.
Note: The credentials in the headers will be provided by us. Always call this endpoint from your backend; never ship the secret inside your app.
Parameters
- type (String): Which identifier you log the user in with:
externalID,emailorphone. We recommendexternalID. - externalID (String): Unique id of the user on your system, used to allocate the correct points. Required when type is
externalID. - email (String): Email of the user. Required when type is
email, recommended otherwise. Helps clubs find members and analyse their activity in the Authic dashboard. - phone (String): Phone number in international format (e.g. +31612345678). Required when type is
phone. - name (String): First name or full name of the user. Used to greet the user and shown on their membership card.
- externalCreatedAt (ISO 8601 date): The date the user joined your platform, shown on their membership card.
- tag (String): Optional segment of the user on your side, for example their membership level. It appears as a tag in the dashboard, so clubs can use it for segments and campaigns. Send
nullto remove it.
Headers
| Header | Description |
|---|---|
x-perks-tenant |
Your tenant id, provided by us. |
x-perks-tenant-secret |
Your shared secret, provided by us. |
x-language |
The user’s language code (e.g. en, nl). Sets the app language; falls back to the club’s default language. |
Behaviour
- First login creates the member. If no member matches the identifier, we create one with the details you send.
- Profile stays in sync. name, email and language are updated on every login, so always send the latest values.
- Request a fresh URL every time the user opens the loyalty program. Don’t cache or reuse it.
- Session lifetime: the access token is valid for 1 day and refreshes automatically for up to 180 days.
- Treat the URL as a secret. It logs the user in directly, so don’t log it or share it.
Errors and limits
| Response | Meaning |
|---|---|
| 200 | The PWA access URL, as a plain string. |
| 400 | Invalid or missing parameters, for example a malformed email or phone number. |
| 401 | Missing or invalid tenant credentials. |
| 429 | Rate limit reached. The endpoint allows 60 requests per minute. |
Native bridge
In the loyalty program, some challenges or rewards open content in a browser. Since the PWA runs inside a WebView, it needs to ask your app to open the native browser; otherwise users get stuck on the opened page, unable to go back to the loyalty program. This step ensures a seamless browser interaction with your application.
The functions below let the web environment send messages back to your application layer:
| Message | What your app should do |
|---|---|
NAVIGATE_BACK |
Close the WebView and return to the previous screen. |
OPEN_IN_BROWSER |
Open the given url in the system’s default browser. |
/**
* Injects bridge handlers for native navigation.
* This script establishes communication between the WebView and the
* React Native application layer.
*/
(function() {
// Triggers a 'Back' navigation event in the native app
window.navigateBackInApp = function() {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'NAVIGATE_BACK'
}));
};
// Requests the native app to open a URL in the system's default browser
window.openInBrowser = function(url) {
if (typeof url === 'string' && url) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'OPEN_IN_BROWSER',
url: url
}));
}
};
})();
// Note: Returning 'true' prevents silent execution failures in some WebView versions.
true;
React Native example usage
<WebView
source=
javaScriptEnabled={true}
domStorageEnabled={true}
allowsInlineMediaPlayback={true}
cacheEnabled={false}
injectedJavaScript={injectedJavaScript} // ← Injected bridge script above
onMessage={event => { // ← Message listener
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'NAVIGATE_BACK') {
router.back();
}
if (data.type === 'OPEN_IN_BROWSER' && typeof data?.url === 'string') {
Linking.openURL(data.url.trim()).catch(err =>
console.warn('Failed to open URL:', err),
);
}
} catch (error) {
console.error('Error parsing webview message:', error);
}
}}
/>
Flutter example usage
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
const _bridgeScript = '''
(function() {
window.navigateBackInApp = function() {
AuthicBridge.postMessage(JSON.stringify({ type: 'NAVIGATE_BACK' }));
};
window.openInBrowser = function(url) {
if (typeof url === 'string' && url) {
AuthicBridge.postMessage(JSON.stringify({ type: 'OPEN_IN_BROWSER', url: url }));
}
};
})();
true;
''';
class PwaWebView extends StatefulWidget {
const PwaWebView({super.key, required this.pwaAccessUrl});
final String pwaAccessUrl;
@override
State<PwaWebView> createState() => _PwaWebViewState();
}
class _PwaWebViewState extends State<PwaWebView> {
late final WebViewController _controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(NavigationDelegate(
onPageFinished: (_) => _controller.runJavaScript(_bridgeScript),
))
..addJavaScriptChannel('AuthicBridge',
onMessageReceived: (msg) => _handleMessage(msg.message))
..loadRequest(Uri.parse(widget.pwaAccessUrl));
void _handleMessage(String rawJson) {
try {
final data = jsonDecode(rawJson);
switch (data['type']) {
case 'NAVIGATE_BACK':
if (mounted && Navigator.canPop(context)) Navigator.pop(context);
break;
case 'OPEN_IN_BROWSER':
if (data['url'] != null) {
launchUrl(Uri.parse(data['url']), mode: LaunchMode.externalApplication);
}
break;
}
} catch (e) {
debugPrint('Bridge Error: $e');
}
}
@override
Widget build(BuildContext context) =>
Scaffold(body: SafeArea(child: WebViewWidget(controller: _controller)));
}