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://www.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://www.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://www.otabot.com/api/auth/partner-embed-token?key=YOUR_API_KEY&token=YOUR_JWTResponse: { "embedToken": "eyJ..." } — valid for 24 hours.
<iframe
src="https://www.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://www.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://www.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://www.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://www.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://www.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",
"date": "2026-05-14",
"oldPrice": 145,
"newPrice": 119,
"percentChange": -17.93
}
],
"availabilityChanges": [
{ "propertyName": "Seaside Boutique Apartment", "date": "2026-05-20" }
],
"undercutAlerts": [
{
"competitorName": "Harbor View Suites",
"competitorPrice": 89,
"date": "2026-05-14",
"ownPrice": 120,
"ownPropertyName": "Your Athens City Apartment"
}
],
"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');
});Forwarding the Alert Email
If you just want your users to get the alert from your own domain, you don't have to build a template. When there are price changes, the payload includes a pre-rendered email object (subject, html, plain_text) already white-labeled with your branding — forward it straight to your email provider. Two things to watch: only handle the alert_summary event, and skip delivery when email is absent (availability-only or market-shift-only alerts carry data but no rendered email).
const FormData = require('form-data');
const Mailgun = require('mailgun.js');
const mailgun = new Mailgun(FormData);
const mg = mailgun.client({ key: process.env.MAILGUN_API_KEY, username: 'api' });
// Call this after you've verified the signature (see "Verify & Handle").
async function handleAlert(payload) {
// Only 'alert_summary' events carry alerts today. Ignore anything else so a
// future event type never silently short-circuits your handler.
if (payload.event !== 'alert_summary') return;
// The 'email' object is only present when there are price changes to render.
// Guard for it — an availability-only or market-shift-only alert has no email.
if (!payload.email) return;
// payload.email is already white-labeled with your logo, brand color and
// reply-to. Forward it as-is to the user from your own domain — no templating.
await mg.messages.create(process.env.MAILGUN_DOMAIN, {
from: 'Rate Alerts <alerts@yourdomain.com>',
to: [payload.hotel.email],
subject: payload.email.subject,
html: payload.email.html,
text: payload.email.plain_text,
});
}Build Your Own Email
Prefer full control over wording, layout and language? Ignore payload.email and compose your own message from data.*. This is the more flexible path — and the only way to email on availability, undercut and market-shift alerts, which arrive with data but no pre-rendered email object.
// Build your own email from data.* instead of forwarding payload.email.
// Read from the four alert arrays directly — this way you can also notify on
// availability, undercut and market-shift alerts, which carry no rendered email.
function buildAlertEmail(payload) {
const { hotel, alert, data } = payload;
const eur = (n) => '€' + n;
const parts = [];
// Competitor price changes (already filtered to the user's threshold).
for (const c of data.changes) {
const sign = c.percentChange > 0 ? '+' : '';
parts.push(`${c.propertyName} (${c.date}): ${eur(c.oldPrice)} → ${eur(c.newPrice)} (${sign}${c.percentChange}%)`);
}
// Competitors now priced below this user's own property.
for (const u of data.undercutAlerts) {
parts.push(`${u.competitorName} undercuts you on ${u.date}: ${eur(u.competitorPrice)} vs your ${eur(u.ownPrice)}`);
}
// Competitor properties that went unavailable for a date.
for (const a of data.availabilityChanges) {
parts.push(`${a.propertyName} is now unavailable on ${a.date}`);
}
// Market-wide average moved past the user's threshold (object or null).
if (data.marketShift) {
const { previousAvg, currentAvg, shiftPercent } = data.marketShift;
parts.push(`Market average ${shiftPercent > 0 ? 'up' : 'down'} ${Math.abs(shiftPercent)}%: ${eur(previousAvg)} → ${eur(currentAvg)}`);
}
return {
to: hotel.email,
// alert.severity ('high' | 'medium') is a good hook for send-now vs. digest.
subject: `[${alert.severity.toUpperCase()}] ${alert.message}`,
html: '<ul>' + parts.map((p) => `<li>${p}</li>`).join('') + '</ul>',
};
}You don't re-filter — we already did. Each user's alert thresholds are applied on our side before the webhook fires, so every delivery is already worth sending. Price changes clear that user's threshold (10% by default), market shifts clear theirs (15% by default, and some users switch it off), and undercuts mean a competitor is genuinely below the user's own rate. You can key off alert.severity and the number of items in each data.* array, but you never need to recompute what counts as significant. percentChange is signed (negative means the competitor dropped their price), propertyNameis the listing's own title as it appears on the OTA (the payload carries no separate platform field), and all prices are in EUR.
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://www.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://www.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