Partner integration¶
This guide is for a partner who lets the users of its own website book with Reserva businesses. The usual way is to embed the booking widget. Your users sign in with your login, and the widget exchanges their identity for a Reserva token (see Authentication).
1. What a partner gets from Reserva¶
- Client application: the partner's registration in Reserva.
clientId: the public identifier of the client application. It is not a secret and goes into the page code.allowed_origins: the list of exact origins (for examplehttps://partner-site.example) allowed to embed the widget. The widget sends messages only to a page origin on this list.- Business consent per location: the widget works only for locations of businesses that have approved consent for the partner.
When the widget cannot be used, the loader calls onUnavailable({ reason }):
reason |
Meaning |
|---|---|
LOCATION_OUT_OF_SCOPE |
The location does not belong to this partner. |
CONSENT_NOT_APPROVED |
The business has not approved consent for this location. |
PARTNER_DISABLED |
A Reserva admin has disabled the partner. |
In that case the partner should show a fallback option, for example a contact phone number.
2. Loader installation¶
<div id="reserva-widget"></div>
<script>
window.ReservaWidget = window.ReservaWidget || [];
window.ReservaWidget.push([
'mount',
'#reserva-widget',
{
clientId: '<public client application id>',
locationId: '<location id>',
theme: 'light', // required: 'light' | 'dark' (without it nothing is drawn)
locale: 'sr', // optional: 'sr' | 'en' (default 'sr')
// string | null | Promise<string | null>; null means guest
getIdentityToken: () => myAuth.getIdentityToken(),
// { id, status, startTime, endTime, locationName }
onReservationCreated: (reservation) => {
console.log('Reservation created', reservation.id);
},
onUnavailable: ({ reason }) => {
// 'LOCATION_OUT_OF_SCOPE' | 'CONSENT_NOT_APPROVED' | 'PARTNER_DISABLED'
document.querySelector('#reserva-widget').textContent = 'Booking is currently unavailable.';
},
},
]);
</script>
<script async src="https://reserva.bojanlab.com/widget.js"></script>
The required fields are clientId, locationId and getIdentityToken. Without them, mount logs an error to the console and renders nothing.
Commands are placed in the window.ReservaWidget queue before the script loads, and they run in order. After the script loads, push runs them immediately.
['mount', selector, options]renders the iframe into the first element that matchesselector.-
['refreshIdentity', selector?]callsgetIdentityToken()again and sends the widget a new identity. Without a selector, it applies to all widgets. It is also available aswindow.ReservaWidget.refreshIdentity(selector?). -
['setTheme', theme, selector?]switches the widget between'light'and'dark'without a reload; the chosen slot is kept. Without a selector it applies to all widgets. It is also available aswindow.ReservaWidget.setTheme(theme, selector?). The loader sends the iframe areserva:thememessage ({ v, type, theme }). The widget never follows the system color scheme: pass the theme of your own site and callsetThemewhen your site's theme changes.
Call refreshIdentity after every login and logout of a user on your site:
function onAuthChanged() {
window.ReservaWidget.push(['refreshIdentity']);
}
The Reserva token in the widget lasts about 10 minutes and cannot be refreshed. When it expires, the widget asks for an identity again, and the loader calls getIdentityToken(). That function must therefore always return a fresh, unused token, or null.
A guest (null) sees the available slots and can pick one. Clicking "Reserve" makes the widget remember the choice and send reserva:sign-in-required, and the loader calls the optional onSignInRequired(). Open your sign-in there, then call ReservaWidget.refreshIdentity(): the widget completes the reservation without a second choice. Without onSignInRequired the widget asks the guest to sign in on your site.
3. Path 1: a trusted OIDC issuer (for example Google)¶
If your users sign in through a trusted OIDC issuer, pass that issuer's ID token directly through getIdentityToken. No backend is needed.
Example with Google Identity Services, as on the demo partner website:
<script src="https://accounts.google.com/gsi/client" async></script>
<div id="google-login"></div>
<script>
let idToken = null;
window.addEventListener('load', () => {
google.accounts.id.initialize({
client_id: '<your Google OAuth client id>',
callback: ({ credential }) => {
idToken = credential; // the ID token stays in memory
window.ReservaWidget.push(['refreshIdentity']);
},
});
google.accounts.id.renderButton(document.getElementById('google-login'), {
type: 'standard',
theme: 'outline',
text: 'signin_with',
});
});
function signOut() {
idToken = null;
google.accounts.id.disableAutoSelect();
window.ReservaWidget.push(['refreshIdentity']);
}
function getIdentityToken() {
if (!idToken) return null;
const payload = idToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const { exp } = JSON.parse(atob(payload));
return exp * 1000 > Date.now() ? idToken : null; // an expired token is treated as a guest
}
window.ReservaWidget = window.ReservaWidget || [];
window.ReservaWidget.push([
'mount',
'#reserva-widget',
{
clientId: '<public client application id>',
locationId: '<location id>',
getIdentityToken,
},
]);
</script>
Note: the token decoding in this example is only for checking expiry. Reserva verifies the signature.
4. Path 2: the partner has a backend and signs the assertion¶
A partner with its own backend signs a short-lived JWT assertion about its signed-in user. The front end gets that token from your backend and returns it through getIdentityToken. Reserva verifies the signature with your public key, so you do not need an OIDC issuer.
Key registration¶
A Reserva administrator adds a trusted issuer of type PARTNER_KEY to the partner integration:
issuer: your identifier (a URL or an opaque ID, without spaces). It is theissin every assertion.- Public key, exactly one of two sources:
- JWKS URL: an absolute URL where you publish your JWKS, or
- Inline JWK: a single public JWK as a JSON object.
- The key is
RSAorECon curveP-256, with no private members (d,p,q,dp,dq,qi). A key with a private member is rejected. audiencesis not filled in forPARTNER_KEY(it is sent as[]).
An invalid key, or both or neither source: the backend returns 400 (partner_key_invalid).
Widget¶
Add identityTokenType: 'jwt' to the mount options. Without it, the widget treats the token as an ID token (path 1):
window.ReservaWidget.push([
'mount',
'#reserva-widget',
{
clientId: '<public client application id>',
locationId: '<location id>',
identityTokenType: 'jwt', // 'id_token' (default, path 1) | 'jwt' (path 2)
getIdentityToken, // returns your signed assertion
},
]);
Header and claims¶
alg is RS256 or ES256. kid is optional and is used to pick the key from the JWKS.
| Claim | Required | Meaning |
|---|---|---|
iss |
yes | The registered PARTNER_KEY issuer. |
aud |
yes | Must contain reserva. |
sub |
yes | A stable identifier of the user on your side. |
email |
yes | Contact data only. It is never used to link accounts. |
email_verified |
no | Whether the email is verified. |
exp |
yes | At most 300 s after receipt and after iat. |
iat |
no | Issue time. |
jti |
yes | Unique per partner. A repeated jti is rejected, so every assertion needs a new one. |
The allowed clock skew is 60 s.
Keep the private key on the server only: in environment secrets, never in front-end code, in the repository, or in an API response.
Example of signing in Node.js (jose):
import { SignJWT, importPKCS8 } from 'jose';
import { randomUUID } from 'node:crypto';
// PARTNER_KEY: private key (PKCS#8 PEM), from the server environment only
const privateKey = await importPKCS8(process.env.PARTNER_KEY, 'ES256');
export async function signIdentityAssertion(user) {
return new SignJWT({ email: user.email, email_verified: user.emailVerified })
.setProtectedHeader({ alg: 'ES256', kid: process.env.PARTNER_KEY_ID })
.setIssuer('<iss>') // the registered PARTNER_KEY issuer
.setSubject(user.id)
.setAudience('reserva')
.setIssuedAt()
.setExpirationTime('5m') // at most 300 s
.setJti(randomUUID())
.sign(privateKey);
}
Your backend endpoint returns the assertion only to a signed-in user:
app.get('/api/reserva-identity', requireLogin, async (req, res) => {
res.json({ token: await signIdentityAssertion(req.user) });
});
Front end:
getIdentityToken: async () => {
const response = await fetch('/api/reserva-identity', { credentials: 'include' });
if (!response.ok) return null; // not signed in: guest
return (await response.json()).token; // a fresh token for every call
},
What Reserva does with the assertion¶
The widget exchanges the assertion for a Reserva token with POST /auth/oauth2/token (application/x-www-form-urlencoded). The same request is described in Authentication:
| Field | Value |
|---|---|
grant_type |
urn:ietf:params:oauth:grant-type:token-exchange |
client_id |
the public client application id |
subject_token |
your signed assertion |
subject_token_type |
urn:ietf:params:oauth:token-type:jwt |
scope |
optional |
Possible errors:
invalid_grant: the assertion was rejected (signature,iss,aud,exp, repeatedjti, ...).invalid_client: the partner is unknown or disabled.invalid_scope: the requested scope is not allowed for the partner.invalid_request: wrongsubject_token_type.
The resulting Reserva token is used to create reservations. A reservation carries the user's externalIdentityId.
5. Calling the API without the widget¶
A partner can also book from its own code. Exchange the identity token as described in Authentication, request the scopes you need, and call:
| Endpoint | Scope | Purpose |
|---|---|---|
POST /api/client-applications/reservations |
reservation:create |
Book for the token's user. The body has locationId, serviceId, resourceId, startTime and endTime. |
GET /api/client-applications/reservations |
reservation:read:own |
List the reservations your application made for that user. |
GET /api/client-applications/reservations/{id} |
reservation:read:own |
Read one of them. |
Find free times first with the public endpoints from Getting started: locations,
services, resources and GET /api/resources/{resourceId}/slots. Times are local times of the business
location. The same rules as for the widget apply: business consent for the location, and an enabled partner.
Cancelling
Cancelling a partner reservation is not available in the public API yet.
6. Terms of use¶
- The "by Reserva" mark (with the Reserva logo) at the bottom of the widget must stay visible in every widget state.
- It is forbidden to cover it, clip it (an iframe height or width smaller than its content, or
overflow: hiddenon a parent), make it transparent, or change or remove it in any way. - Breaking this rule is grounds for removing the partner. The same rule is in the Reserva Terms of use.
7. Security notes¶
- Exact origins. Enter exact origins (scheme, host, port) in
allowed_origins. Do not use'*'or patterns. - No
'*'in messages. The loader sends messages to the iframe only to the Reserva origin, and accepts messages only from that origin and only from its own iframe. If you write your own code around the widget, do not usepostMessage(..., '*'). - Iframe sandbox. The loader sets
sandboxwithoutallow-top-navigation, andreferrerpolicy="strict-origin". Do not change these attributes. - The Reserva token is never visible on the partner's page. It lives only inside the iframe. The partner page sees only its own identity and the
onReservationCreatedpayload, with the fieldsid,status,startTime,endTimeandlocationName. It contains no email and no user or identity IDs. - Do not write the identity token that you return from
getIdentityTokento logs, and do not put it in a URL.