Partner Integration
Integrate OTABot into your PMS, channel manager, or hospitality platform. Offer rate intelligence to your users with just a few lines of code.
Redirect Login
Redirect users to OTABot with a signed JWT. They land on the dashboard with a session — no registration needed.
Iframe Embedding
Embed OTABot pages directly in your app. Get an embed token server-side, pass it to an iframe — users never leave your product.
Price Movement API
Detect market price changes near any location. Pass coordinates and a radius — get back dates where the market average shifted, grouped by when the change was detected.
Alert Webhooks
Receive price, availability, undercut, and market-shift alerts on your own endpoint. Forward them to your users via your own email, SMS, or in-app UI — or let OTABot send the emails instead.
Get Your Credentials
Contact us to receive your API Key (pk_...) and Secret (sk_...). Store the secret securely — never expose it in client-side code.
Sign a JWT
On your server, sign a short-lived JWT (60s) with your secret using HS256. The payload must include the user's email.
Redirect or Embed
Redirect the user to our login endpoint, or request an embed token and load an iframe. Users are auto-registered on first login.
Redirect users to the following URL with your API key and signed JWT:
GET https://otabot.com/api/auth/partner-login?key=YOUR_API_KEY&token=YOUR_JWTOTABot verifies the token, creates a session cookie, and redirects to /dashboard. If the user doesn't have an account, one is created automatically.
const jwt = require('jsonwebtoken');
// Your partner credentials (from OTABot admin panel)
const API_KEY = 'pk_your_api_key_here';
const SECRET = 'sk_your_secret_here';
function generateOTABotLoginUrl(userEmail) {
const token = jwt.sign(
{ email: userEmail },
SECRET,
{ expiresIn: '60s' }
);
return `https://otabot.com/api/auth/partner-login?key=${API_KEY}&token=${token}`;
}
// Usage: redirect the user to this URL
const loginUrl = generateOTABotLoginUrl('user@example.com');
// e.g. res.redirect(loginUrl)Request an embed token from your server, then pass it to an iframe:
GET https://otabot.com/api/auth/partner-embed-token?key=YOUR_API_KEY&token=YOUR_JWTResponse: { "embedToken": "eyJ..." } — valid for 24 hours.
<iframe
src="https://otabot.com/embed/dashboard?token=EMBED_TOKEN"
width="100%" height="800"
style="border: none; border-radius: 8px;"
></iframe>Available Pages
| Path | Description |
|---|---|
/embed/dashboard | Monitored URLs and snapshots |
/embed/alerts | Alert settings and history |
/embed/compare | Side-by-side competitor comparison |
/embed/map | Interactive competitor map |
/embed/positioning | Rate positioning analysis |
const jwt = require('jsonwebtoken');
const API_KEY = 'pk_your_api_key_here';
const SECRET = 'sk_your_secret_here';
async function getEmbedToken(userEmail) {
const token = jwt.sign(
{ email: userEmail },
SECRET,
{ expiresIn: '60s' }
);
const res = await fetch(
`https://otabot.com/api/auth/partner-embed-token?key=${API_KEY}&token=${token}`
);
const data = await res.json();
return data.embedToken; // Valid for 24 hours
}
const embedToken = await getEmbedToken('user@example.com');Customize embed pages to match your brand by passing color parameters on the iframe URL:
<iframe
src="https://otabot.com/embed/dashboard?token=TOKEN&primaryColor=2563EB&bgColor=F0F4FF"
width="100%" height="800"
style="border: none; border-radius: 8px;"
></iframe>primaryColor— hex color without #. Replaces the default purple across buttons, links, and accents.bgColor— hex color without #. Changes the page background to match your app.
Colors can also be configured per-partner in the admin panel as defaults, but URL params always take priority.
Fetch the available plans and their limits. Useful for displaying plan options in your own UI. Pricing is excluded — you control billing on your side.
GET https://otabot.com/api/partners/plans?key=YOUR_API_KEYResponse:
{
"plans": {
"free": {
"name": "Free",
"description": "2 monitored URLs, weekly scans, 1 month look-ahead",
"maxUrls": 2,
"lookAheadMonths": 1,
"scanFrequencyDays": 7,
"rateHistoryDays": 0,
"features": ["2 competitor properties", "1 month look-ahead", "..."]
},
"essential": { "name": "Essential", "maxUrls": 5, "..." : "..." },
"growth": { "name": "Growth", "maxUrls": 10, "..." : "..." },
"business": { "name": "Business", "maxUrls": 15, "..." : "..." }
}
}Partner-managed users don't see OTABot billing or Stripe checkout. You control their plans via API and invoice them however you want.
List Users
GET https://otabot.com/api/partners/users?key=YOUR_API_KEYReturns all users created through your partner integration:
{
"users": [
{
"id": "...",
"email": "user@hotel.com",
"plan": "growth",
"billingInterval": "monthly",
"maxMonitoredUrls": 10,
"createdAt": "2026-03-20T..."
}
]
}Set User Plan
PUT https://otabot.com/api/partners/users?key=YOUR_API_KEYUpdate a user's plan. The request body should contain:
{
"email": "user@hotel.com",
"plan": "growth",
"billingInterval": "yearly"
}Valid plans: free, essential, growth, business. The user's maxMonitoredUrls is automatically set based on the plan.
Optional billingInterval: monthly (default), 6month (1 month free), or yearly (2 months free).
When OTABot detects a significant price change, availability change, competitor undercut, or market shift for one of your users, it can POST a signed JSON payload to an endpoint you control — so you can notify users from your own email domain, push to Slack, drop into a queue, or update your own UI.
Delivery Modes
Every partner account has an alertsHandledByOtabot flag that controls how alerts reach your users:
- OTABot sends emails (default) — we send white-labeled alert emails directly to your users, using your logo, brand color, display name, and reply-to address.
- You receive webhooks — we POST the alert data to your
webhookUrland do not send any email. You decide how (and whether) to notify the user.
To switch to webhook delivery, contact us with your webhook URL — we'll set alertsHandledByOtabot=false and register the endpoint on your partner account.
Request Format
Each alert is delivered as a single POST to your endpoint with a JSON body and two signing headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-OTABot-Signature | Hex-encoded HMAC-SHA256 of `${timestamp}.${body}` using your partner secret |
X-OTABot-Timestamp | Unix timestamp (seconds) — reject requests more than 5 minutes from the current time (past or future) to prevent replays |
Payload
The payload bundles every alert detected for a single user in one scan into one delivery. data.changes, data.availabilityChanges, and data.undercutAlerts are always arrays (possibly empty). data.marketShift is an object when a shift was detected and null otherwise.
{
"event": "alert_summary",
"timestamp": "2026-04-21T03:12:04.518Z",
"hotel": {
"id": "65f1a2b3c4d5e6f7a8b9c0d1",
"name": "owner@hotel.com",
"email": "owner@hotel.com"
},
"alert": {
"type": "alert_summary",
"severity": "high",
"message": "3 alerts detected"
},
"data": {
"changes": [
{
"propertyName": "Grand Hotel Riviera on Booking.com",
"date": "2026-05-14",
"oldPrice": 145,
"newPrice": 119,
"percentChange": -17.93
}
],
"availabilityChanges": [
{ "propertyName": "Seaside Boutique Apt on Airbnb", "date": "2026-05-20" }
],
"undercutAlerts": [
{
"competitorName": "Harbor View Suites",
"competitorPrice": 89,
"date": "2026-05-14",
"ownPrice": 120,
"ownPropertyName": "Your property on Booking.com"
}
],
"marketShift": {
"previousAvg": 250,
"currentAvg": 212.5,
"shiftPercent": -15
}
},
"email": {
"subject": "Price changes detected",
"html": "<html>...white-labeled alert email...</html>",
"plain_text": "Price changes detected for your monitored properties..."
},
"meta": {
"partner_id": "65e0a1b2c3d4e5f6a7b8c9d0",
"webhook_version": "1.1"
}
}Field Reference
| Field | Description |
|---|---|
event | Always alert_summary — one call bundles all alerts for a user's scan. |
hotel | The OTABot user the alerts belong to — id matches the id returned by /api/partners/users, email is the email they log in with. |
alert.severity | high when any price change ≥ 20% or any undercut is present, medium otherwise. |
data.changes | Significant price changes on competitor properties. Prices in EUR, percentChange is signed. |
data.availabilityChanges | Competitor properties that became unavailable for a specific date. |
data.undercutAlerts | Competitors priced below the user's own property on the same date. |
data.marketShift | Market-wide average shift versus the previous scan, if it crossed the user's threshold. |
email | Optional pre-rendered white-label email (subject, html, plain_text) using your branding — forward as-is if you just want to send from your own domain. Omitted when there are no price changes to render. |
meta.webhook_version | Payload schema version. New fields may be added in minor bumps; breaking changes get a new major version. |
Verify & Handle
Verify the signature against the raw request body — not a re-serialized JSON object — and reject any timestamp more than 5 minutes from now:
const crypto = require('crypto');
const express = require('express');
const SECRET = process.env.OTABOT_SECRET; // 'sk_...' — same secret you use to sign JWTs
const app = express();
// IMPORTANT: use the raw body so the signature verifies byte-for-byte
app.post('/otabot-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-OTABot-Signature') || '';
const timestamp = req.get('X-OTABot-Timestamp') || '';
const body = req.body.toString('utf8');
// 1. Reject missing/malformed timestamps and requests more than 5 minutes
// from now — Number(timestamp) alone returns NaN for bad input, which
// silently passes the comparison below.
const tsSeconds = /^\d+$/.test(timestamp) ? Number(timestamp) : NaN;
if (!Number.isFinite(tsSeconds) || Math.abs(Date.now() / 1000 - tsSeconds) > 300) {
return res.status(401).send('Invalid or stale timestamp');
}
// 2. Verify HMAC-SHA256 over `${timestamp}.${body}`
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${body}`)
.digest('hex');
const sigBuf = Buffer.from(signature, 'hex');
const expBuf = Buffer.from(expected, 'hex');
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(body);
// Forward the pre-rendered email from payload.email, push to a queue, etc.
// Respond 2xx quickly — we don't retry.
res.status(200).send('ok');
});Response & Retries
- Respond with any
2xxstatus within 10 seconds. Deliveries that time out or return non-2xx are logged but not retried — if you need durability, acknowledge quickly and process asynchronously (e.g. push to a queue). - Deliveries are triggered after each daily scan completes for a user, so expect roughly one call per user per day when they have alerts.
- Your endpoint must be reachable over
https://. Treat duplicate deliveries defensively by keying onhotel.id+timestampif you want idempotency.
Testing
Once your webhook URL is registered, ask us to trigger a test delivery — one click on our side fires a realistic alert_summary payload, signed with your real secret and using your branding, covering all four alert types (price changes, availability, undercut, market shift). It lets you validate signature verification, payload parsing, and your notification flow end-to-end before any real user data flows through.
Test deliveries are tagged with meta.test: true — use it to gate test traffic out of production notifications (e.g. skip actually emailing your user) while still exercising the full verification and handler path.
Detect market-level price changes near a given location. Pass latitude, longitude, a search radius, and a lookback period — the API compares day-level prices between consecutive scans and returns dates where the market average shifted, grouped by when the change was detected.
GET https://otabot.com/api/partners/price-movement?key=YOUR_API_KEY&lat=37.12&lng=25.24&radiusKm=2&days=14Parameters
| Parameter | Required | Default | Description |
|---|---|---|---|
key | Yes | — | Your partner API key |
lat | Yes | — | Latitude of the search center |
lng | Yes | — | Longitude of the search center |
radiusKm | No | 0.5 | Search radius in kilometers (max 50) |
days | No | 7 | Lookback period in days (max 14) |
Example Response
{
"market": [
{
"detectedOn": "2026-03-25",
"changes": [
{
"date": "2026-04-15",
"marketAvgBefore": 220.5,
"marketAvgAfter": 195.0,
"changePercent": -11.56
},
{
"date": "2026-05-01",
"marketAvgBefore": 180.0,
"marketAvgAfter": 175.0,
"changePercent": -2.78
}
]
},
{
"detectedOn": "2026-03-28",
"changes": [
{
"date": "2026-04-20",
"marketAvgBefore": 310.0,
"marketAvgAfter": 295.0,
"changePercent": -4.84
}
]
}
],
"query": { "lat": 37.12, "lng": 25.24, "radiusKm": 2, "days": 14 }
}The market array groups detected price changes by scan date. Each detectedOn entry contains a changes array listing future dates where the market average shifted between consecutive scans. Prices are in EUR. Only dates with actual price changes are included — no individual property names or details are exposed.
If no monitored properties are found within the radius or no price changes occurred, market will be an empty array.
const API_KEY = 'pk_your_api_key_here';
async function checkPriceMovement(lat, lng, radiusKm = 0.5, days = 7) {
const params = new URLSearchParams({
key: API_KEY,
lat: String(lat),
lng: String(lng),
radiusKm: String(radiusKm),
days: String(days),
});
const res = await fetch(
`https://otabot.com/api/partners/price-movement?${params}`
);
return res.json();
}
// Check market movement within 2km of Naousa, Paros over the last 14 days
const data = await checkPriceMovement(37.1235, 25.2387, 2, 14);
for (const entry of data.market) {
console.log(`Changes detected on ${entry.detectedOn}:`);
for (const c of entry.changes) {
const sign = c.changePercent > 0 ? '+' : '';
console.log(` ${c.date}: ${c.marketAvgBefore}€ → ${c.marketAvgAfter}€ (${sign}${c.changePercent}%)`);
}
}Security Notes
- — Never expose your secret in client-side (browser) code
- — Always generate tokens server-side with a short expiry (60s)
- — Store your secret in environment variables, not in source code
- — Each partner JWT should be single-use; generate a fresh one per request
- — Embed tokens expire after 24 hours — refresh them when needed
Ready to integrate?
Contact us to get your partner credentials and start offering rate intelligence to your users.
Get in Touch