Developer Docs

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.

How It Works

1

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.

2

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.

3

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 Login

Redirect users to the following URL with your API key and signed JWT:

URL
GET https://www.otabot.com/api/auth/partner-login?key=YOUR_API_KEY&token=YOUR_JWT

OTABot verifies the token, creates a session cookie, and redirects to /dashboard. If the user doesn't have an account, one is created automatically.

javascript
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)

Iframe Embedding

Request an embed token from your server, then pass it to an iframe:

URL
GET https://www.otabot.com/api/auth/partner-embed-token?key=YOUR_API_KEY&token=YOUR_JWT

Response: { "embedToken": "eyJ..." } — valid for 24 hours.

HTML
<iframe
  src="https://www.otabot.com/embed/dashboard?token=EMBED_TOKEN"
  width="100%" height="800"
  style="border: none; border-radius: 8px;"
></iframe>

Available Pages

PathDescription
/embed/dashboardMonitored URLs and snapshots
/embed/alertsAlert settings and history
/embed/compareSide-by-side competitor comparison
/embed/mapInteractive competitor map
/embed/positioningRate positioning analysis
javascript
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');

Customization

Customize embed pages to match your brand by passing color parameters on the iframe URL:

HTML
<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.

List Plans

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.

URL
GET https://www.otabot.com/api/partners/plans?key=YOUR_API_KEY

Response:

JSON
{
  "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, "..." : "..." }
  }
}

User Management

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

URL
GET https://www.otabot.com/api/partners/users?key=YOUR_API_KEY

Returns all users created through your partner integration:

JSON
{
  "users": [
    {
      "id": "...",
      "email": "user@hotel.com",
      "plan": "growth",
      "billingInterval": "monthly",
      "maxMonitoredUrls": 10,
      "createdAt": "2026-03-20T..."
    }
  ]
}

Set User Plan

URL
PUT https://www.otabot.com/api/partners/users?key=YOUR_API_KEY

Update a user's plan. The request body should contain:

JSON
{
  "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).

Alert Webhooks

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 webhookUrl and 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:

HeaderDescription
Content-Typeapplication/json
X-OTABot-SignatureHex-encoded HMAC-SHA256 of `${timestamp}.${body}` using your partner secret
X-OTABot-TimestampUnix 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.

JSON
{
  "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

FieldDescription
eventAlways alert_summary — one call bundles all alerts for a user's scan.
hotelThe OTABot user the alerts belong to — id matches the id returned by /api/partners/users, email is the email they log in with.
alert.severityhigh when any price change ≥ 20% or any undercut is present, medium otherwise.
data.changesSignificant price changes on competitor properties. Prices in EUR, percentChange is signed.
data.availabilityChangesCompetitor properties that became unavailable for a specific date.
data.undercutAlertsCompetitors priced below the user's own property on the same date.
data.marketShiftMarket-wide average shift versus the previous scan, if it crossed the user's threshold.
emailOptional 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_versionPayload 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:

javascript
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).

javascript
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.

javascript
// 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 2xx status 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 on hotel.id + timestamp if 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.

Price Movement API

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.

URL
GET https://www.otabot.com/api/partners/price-movement?key=YOUR_API_KEY&lat=37.12&lng=25.24&radiusKm=2&days=14

Parameters

ParameterRequiredDefaultDescription
keyYesYour partner API key
latYesLatitude of the search center
lngYesLongitude of the search center
radiusKmNo0.5Search radius in kilometers (max 50)
daysNo7Lookback period in days (max 14)

Example Response

JSON
{
  "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.

javascript
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