Plannyfy API & Signed Webhooks
Connect your hotel PMS, villa catalog, yacht fleet, sub-vendor network, or DMC rate sheets directly to Plannyfy. Push rich offerings with 4K video and seasonal tiers via REST, let Plannyfy poll your read-only feed, and subscribe to real-time HMAC-SHA256 webhooks.
curl -X POST "https://plannyfy.com/api/v1/vendor/portfolio" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"version": "v1",
"items": [
{
"externalId": "pal-roma-01",
"category": "hotel",
"title": "Hotel Palazzo Roma · 5★ Boutique Stay",
"location": "Rome, Italy",
"pricingUnit": "per_night",
"basePriceUsd": 420,
"retailReferenceUsd": 520,
"seasonalRates": {
"peakSummerUsd": 510,
"shoulderUsd": 420,
"lowSeasonUsd": 350
},
"floorPctOfBase": 84,
"galleryUrls": [
"https://images.unsplash.com/photo-1552832230-c0197dd311b5?w=1200",
"https://images.unsplash.com/photo-1533105079780-92b9be482077?w=1200"
],
"youtubeUrl": "https://www.youtube.com/watch?v=LXb3EKWsInQ",
"videoTitle": "4K Courtyard & Rooftop Suite Walkthrough",
"includedPerks": ["Daily breakfast included", "Welcome Prosecco", "14-day free cancellation"],
"tripAdvisorUrl": "https://www.tripadvisor.com/Hotel_Palazzo_Roma"
}
]
}'Sync offerings via POST /api/v1/vendor/portfolio, manage sub-vendor networks via /sub-vendors, or upload WebP media & collections via /photos and /collections.
Query matched trips with GET /api/v1/vendor/opportunities, place or revise bids with /bids (retry-safe via Idempotency-Key), and reconcile ledger state with /earnings & /payouts.
Configure your endpoint via PUT /api/v1/vendor/webhooks, rotate secrets with /rotate-secret, and receive signed X-Plannyfy-Signature events in real time.
Interactive Schema Normalizer
Send Canonical Schema v1—or your existing PMS JSON
Switch between our canonical plannyfy.travel.portfolio.v1 payload and a typical external Hotel/Tour PMS payload to see how Plannyfy normalizes both into a rich offering card.
{
"externalId": "belvedere-positano-01",
"category": "hotel",
"title": "Villa Belvedere Positano · Sea-View Terrace Suite",
"location": "Amalfi Coast, Italy",
"country": "Italy",
"pricingUnit": "per_night",
"basePriceUsd": 680,
"retailReferenceUsd": 840,
"seasonalRates": {
"peakSummerUsd": 890,
"shoulderUsd": 680,
"lowSeasonUsd": 510
},
"floorPctOfBase": 85,
"galleryUrls": [
"https://images.unsplash.com/photo-1533105079780-92b9be482077?w=1200",
"https://images.unsplash.com/photo-1552832230-c0197dd311b5?w=1200",
"https://images.unsplash.com/photo-1570077188670-e3a8d69ac5ff?w=1200"
],
"youtubeUrl": "https://www.youtube.com/watch?v=LXb3EKWsInQ",
"videoTitle": "4K Cliffside Infinity Pool & Suite Walkthrough",
"socialReelUrl": "https://www.instagram.com/reel/C8positano/",
"highlights": [
"Heated Cliffside Infinity Pool",
"Private Boat Jetty Access"
],
"includedPerks": [
"Daily gourmet breakfast",
"Sea-view terrace upgrade",
"Welcome sunset Prosecco"
],
"tripAdvisorUrl": "https://www.tripadvisor.com/Villa_Belvedere",
"googleMapsUrl": "https://maps.google.com/?q=Positano+Italy",
"cancellationPolicy": "moderate_7d",
"autoMatchEnabled": true
}Villa Belvedere Positano · Sea-View Terrace Suite
v1 or point our Pull Connector at your existing PMS JSON feed, Plannyfy normalizes your offerings and attaches rich media automatically.Interactive Webhook Event & Signature Inspector
Click any of the 4 live webhook events below to inspect the X-Plannyfy-Signature header, the exact JSON payload delivered to your endpoint, and the Node.js verification snippet.
{
"id": "evt_opp_99281a",
"version": "v1",
"event": "opportunity.matched",
"createdAt": "2026-10-23T14:20:00.000Z",
"vendorId": "vendor-villa-belvedere",
"data": {
"tripId": "trip-italy-anniversary",
"title": "10-Day Rome, Florence & Amalfi Coast Anniversary Escape",
"destination": "Rome, Florence & Amalfi Coast, Italy",
"travelers": 2,
"priority": "value",
"marketBenchmarkUsd": 9700,
"matchedPortfolioItems": [
{
"externalId": "belvedere-positano-01",
"title": "Villa Belvedere Positano · Sea-View Terrace Suite",
"suggestedBidUsd": 2720
}
],
"biddingClosesAt": "2026-10-30T14:20:00.000Z"
}
}Every outbound webhook is signed using your private webhookSecret. Compute the HMAC-SHA256 digest over ${t}.${rawBody} and compare with constant-time equality.
import { createHmac, timingSafeEqual } from "crypto";
/** rawBody must be the exact bytes Plannyfy sent, before JSON.parse. */
export function verifyPlannyfyWebhook(rawBody: string, signatureHeader: string, secret: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.trim().split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // too old: replay
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}POST /api/v1/vendor/webhooks/test.Authentication & limits
API keys, scopes and rate limits
Create keys in the Plannyfy app under Account > API keys. A key only ever sees your own account's data.
Send the key on every request. The secret is shown once, when you create it; Plannyfy keeps only a hash, so a lost key is replaced, not recovered.
Authorization: Bearer pfy_live_YOUR_KEY # or X-Plannyfy-Api-Key: pfy_live_YOUR_KEY
pfy_test_ keys belong to demo accounts and only work on demo data.
Errors are JSON with a machine-readable code, a message and a request id. Details, every code and retry advice are in the Conventions section below.
HTTP 403
{ "ok": false,
"error": {
"code": "insufficient_scope",
"message": "This API key needs the `bids:write` scope.",
"requestId": "req_3fa85f6457174562"
} }Limits are per key, and the day resets at 00:00 UTC. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). At the limit you get 429 with Retry-After.
| Tier | Per minute | Per day |
|---|---|---|
| Free | 60 | 5,000 |
| Pro | 600 | 100,000 |
| Enterprise | Custom limits for your key | |
Need more? Email concierge@plannyfy.com.
A key can only call endpoints whose scope it holds. Each endpoint below lists its scope.
portfolio:readRead your offerings.portfolio:writeCreate, update and delete your offerings.photos:readRead your photo library and the Plannyfy gallery.photos:writeUpload, edit and delete your photos.collections:readRead your photo collections.collections:writeCreate, edit, duplicate and delete your photo collections.subvendors:readRead your sub-vendor invites and synced offerings.subvendors:writePush sub-vendor offerings and manage invites.webhooks:testSend a signed test webhook to your endpoint.billing:readRead your saved payment methods (never full card numbers).escrow:writeStart an escrow checkout for one of your trips.opportunities:readRead the open trips and events that match your services.bids:readRead your own bids.bids:writePlace, revise and save drafts of your own bids.earnings:readRead your won bookings, commission and net earnings.payouts:readRead the state of your payouts.webhooks:readRead your webhook endpoint settings and recent deliveries (the secret is never returned).webhooks:writeChange your webhook endpoint and events, and rotate the webhook signing secret.availability:readRead your availability calendar, blackout windows and daily capacity limits.availability:writeUpdate your blackout date ranges, minimum notice and daily capacity limits.trips:readRead your own trips, items, live optimal mix picks and escrow state.trips:writeCreate, publish and edit your own trips or event packages.messages:readRead moderated concierge and Q&A messages on trips you own or bid on.messages:writeSend moderated concierge and Q&A messages on trips you own or bid on.
Conventions · v1.7.0
Errors, retries, paging, webhooks and changes
The same rules apply to every /api/v1 endpoint. The machine-readable version is the OpenAPI 3.1 document.
One shape for every error. Branch on error.code, never on the message. Every response, success or error, carries X-Request-Id (the same value as error.requestId): quote it when you contact support.
HTTP 409
X-Request-Id: req_3fa85f6457174562
{ "ok": false,
"error": {
"code": "bid_exists",
"message": "You already have a live bid on this trip. ...",
"requestId": "req_3fa85f6457174562"
},
"bidId": "bid-1042" }Some errors add fields next to error (bidId, needsConfirm, errors). Codes:
unauthenticatedinvalid_keykey_revokedkey_expiredinsufficient_scopeforbidden_rolesession_onlycross_siterate_limitedbad_requestvalidation_failednot_foundconflictpayload_too_largeunsupported_media_typeidempotency_key_invalididempotency_key_reusedidempotency_in_progressvendor_not_approvedbid_existsbid_expiredtrip_closedtrip_changedconfirmation_requiredin_usebuilt_ininvalidtoo_largenot_an_imageunreadablecap_reachedstorage_unavailableservice_unavailableinternal_error
Statuses: 400 malformed, 401 key problem, 403 scope or account type, 404 not found or not yours, 409 state conflict, 413 too large, 422 breaks a rule, 429 rate limited (see Retry-After), 5xx ours: retry with backoff.
Send Idempotency-Key (a UUID works well) on any POST so a timeout or retry cannot do the work twice. The first answer is kept for 24 hours, per key owner.
- Same key, same method, path and body: the stored answer comes back with
Idempotent-Replayed: true. Nothing runs again. - Same key, anything different:
409 idempotency_key_reused. Use a new key for a different request. - First request still running:
409 idempotency_in_progresswithRetry-After: 1. 5xxand429answers are not kept, so retrying them is safe and simple.- Rotating a webhook secret ignores the key on purpose: the secret is never stored in an answer.
curl -X POST "https://plannyfy.com/api/v1/vendor/bids" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Idempotency-Key: 7f1b8f0e-1d1c-4c4e-8a77-0a4f6a1f5c2e" \
-H "Content-Type: application/json" -d '{ ... }'List endpoints take limit (1 to 100; the portfolio list up to 500) and an opaque cursor. Filters are plain query parameters named after the field (state, status, tripId, q). A bad value is 400, never silently ignored.
GET /api/v1/vendor/bids?state=live&limit=2
{ "ok": true, "bids": [ ... ],
"page": { "limit": 2, "nextCursor": "eyJvIjoyfQ", "hasMore": true, "total": 5 },
"next": "/api/v1/vendor/bids?state=live&limit=2&cursor=eyJvIjoyfQ" }Follow next until it is null. The photo and collection lists predate this and page with page and limit, as documented on each.
- Every response is
Cache-Control: no-store. - There is no CORS: call the API from your servers. A key must never be shipped to a browser.
- JSON bodies up to 2 MB (
413 payload_too_large); photo uploads up to 25 MB. - Rate limits and tiers are in the Authentication section above.
Plannyfy sends a signed POST with a JSON body to your endpoint. Set it with PUT /api/v1/vendor/webhooks, test it with POST /api/v1/vendor/webhooks/test.
opportunity.matched· Fired when a traveler publishes a trip whose destination & items match your portfolio.bid.status_changed· Fired when your live bid becomes Contested, Outbid, or Paused after a traveler trip edit.trip.locked· Fired when a traveler locks in a trip and your lines win (includes escrow allocation payload).dispute.updated· Fired when a post-lock issue or withdrawal review is opened or resolved.
POST https://api.yourhotel.com/webhooks/plannyfy
Content-Type: application/json
X-Plannyfy-Event: trip.locked
X-Plannyfy-Signature: t=1792798000,v1=5a8c91d4...
User-Agent: Plannyfy-Webhooks/1.0
{ "id": "evt_mh3k2x9a_1f2e3d4c", "version": "v1", "event": "trip.locked",
"vendorId": "vendor-atlas-dmc", "createdAt": "2026-10-26T06:00:00.000Z",
"data": { ... } }- Delivery: one attempt with a 3.5 second timeout; any
2xxmeans received. - Retries: there is no automatic retry yet. Treat a webhook as a hint and reconcile with
GET /vendor/opportunities,/bidsand/payouts. - Log: the last 20 deliveries and the status your endpoint answered are in
GET /api/v1/vendor/webhooks. - Duplicates: de-duplicate on the envelope
id. - Privacy: payloads never contain a traveler's budget, per-item targets, an Ask Realism Score or a rival's price.
The header is t=<unix_seconds>,v1=<hex>: HMAC-SHA256 with your webhook secret over <t>.<raw body>. Verify over the raw bytes before parsing, compare in constant time, and reject timestamps more than 5 minutes old.
import { createHmac, timingSafeEqual } from "crypto";
/** rawBody must be the exact bytes Plannyfy sent, before JSON.parse. */
export function verifyPlannyfyWebhook(rawBody: string, signatureHeader: string, secret: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.trim().split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // too old: replay
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}import hmac, hashlib, time
def verify_plannyfy_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_sec: int = 300) -> bool:
parts = dict(kv.strip().split("=", 1) for kv in signature_header.split(","))
try:
t = int(parts["t"]); given = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance_sec:
return False # too old: replay
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(given, expected)Rotate the secret with POST /api/v1/vendor/webhooks/rotate-secret: the new secret is shown once and the old one stops signing at once.
The version in the path is the major version (v1). Adding endpoints, fields, scopes and error codes does not change it, so ignore fields you do not know. A breaking change would ship under a new path version with notice here. The document version is 1.7.0 and is also in /api/health.
- New: GET and POST /api/v1/trips, GET and PATCH /api/v1/trips/{id} so customers can create, publish, edit, and inspect trips and their live #1 Best Price / #1 Best Value optimal mix via API.
- New: POST /api/v1/trips/{id}/lock, /complete, /cancel, and /disputes for full customer escrow lock-in, post-trip completion sign-off, policy cancellations, and dispute creation.
- New: POST /api/v1/vendor/bids/{id}/withdraw and POST /api/v1/vendor/bids/{id}/confirm for vendor bid withdrawal and mid-bid customer-edit price re-confirmation.
- New: GET and PUT /api/v1/vendor/availability for vendor capacity caps (maxEventsPerDay, leadTimeDays) and blackout date ranges.
- New: GET and POST /api/v1/trips/{id}/messages for moderated concierge messaging between customers and bidding/locked vendors (with PII/contact-info blocking).
- New scopes: trips:read, trips:write, availability:read, availability:write, messages:read, messages:write.
- New: GET /api/v1/vendor/opportunities and /opportunities/{id} (matched, traveler-safe, event fields, offering candidates).
- New: GET and POST /api/v1/vendor/bids, GET and PATCH /api/v1/vendor/bids/{id} with the bid composer's rules, drafts, unit pricing, minimum guests and offering links by offeringId.
- New: GET /api/v1/vendor/earnings and /payouts.
- New: GET and PUT /api/v1/vendor/webhooks and POST /api/v1/vendor/webhooks/rotate-secret; webhook events, payloads, signature and delivery behavior are documented.
- New scopes: opportunities:read, bids:read, bids:write, earnings:read, payouts:read, webhooks:read, webhooks:write. Existing keys keep working with the scopes they have.
- Changed: every error is now { ok: false, error: { code, message, requestId } }. Before, error was a string and code sat next to it. Read error.code and error.message.
- New: X-Request-Id on every response, Cache-Control: no-store on every response, Idempotency-Key on every POST (24 hours, per key owner), and the new error codes listed in the OpenAPI document.
- New: limit and cursor pagination with a next link; GET /api/v1/vendor/portfolio now pages (default 200, up to 500 per page).
- Changed: JSON request bodies are limited to 2 MB (413 payload_too_large); the test webhook only accepts a public https webhookUrl.
- API keys with scopes and Free, Pro and Enterprise rate limits; X-RateLimit-* headers and Retry-After.
- Photo library, collections and the Plannyfy gallery endpoints; escrow checkout and payment-method reads for customers.
- Earlier history was not recorded in a changelog.
Complete Single-Page Reference · v1.7.0
Plannyfy API Endpoints & Schema Reference
Every public Plannyfy v1.7.0 endpoint on one page—grouped clearly with parameters, request snippets, and JSON responses. Use the search bar below to filter by endpoint, parameter, or keyword.
Portfolio, Inventory & Sub-Vendor Network API
List, upsert, and delete hotel, villa, yacht, tour, guide, and transfer offerings in Plannyfy Common Schema v1, plus manage sub-vendor onboarding links and wholesale offerings.
/api/v1/vendor/portfolioList & Filter Portfolio OfferingsReturns all portfolio offerings for the authenticated vendor in Plannyfy Common Schema v1 (`plannyfy.travel.portfolio.v1`), optionally filtered by category.
categoryOptional filter to return only offerings in a specific category.
curl -X GET "https://plannyfy.com/api/v1/vendor/portfolio?category=hotel" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"schema": "plannyfy.travel.portfolio.v1",
"vendorId": "demo-vendor",
"count": 1,
"items": [
{
"id": "pf-rome-palazzo",
"externalId": "pal-roma-01",
"category": "hotel",
"title": "Hotel Palazzo Roma · 5★ Boutique Stay",
"location": "Rome, Italy",
"pricingUnit": "per_night",
"basePriceUsd": 420,
"seasonalRates": { "peakSummerUsd": 510, "shoulderUsd": 420, "lowSeasonUsd": 350 },
"floorPctOfBase": 84,
"includedPerks": ["Daily breakfast included", "Welcome Prosecco"]
}
]
}/api/v1/vendor/portfolioUpsert 1–100 Portfolio OfferingsIdempotently creates or updates 1–100 portfolio offerings per request. Accepts canonical Plannyfy Schema v1 (`{ items: [...] }`) as well as external PMS field aliases (`sku`, `name`, `rate`, `photos`, `perks`).
titleOffering title displayed to customers (max 140 chars).
basePriceUsdStandard net unit rate in USD.
externalIdPartner SKU used for idempotent upserts across syncs.
seasonalRatesOptional Peak (Jun–Aug), Shoulder (Apr–May, Sep–Oct), and Low (Nov–Mar) rates.
galleryUrls / youtubeUrlMulti-photo gallery URLs and 4K YouTube walkthrough URL.
curl -X POST "https://plannyfy.com/api/v1/vendor/portfolio" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"version": "v1",
"items": [
{
"externalId": "pal-roma-01",
"category": "hotel",
"title": "Hotel Palazzo Roma · 5★ Boutique Stay",
"location": "Rome, Italy",
"pricingUnit": "per_night",
"basePriceUsd": 420,
"seasonalRates": { "peakSummerUsd": 510, "shoulderUsd": 420, "lowSeasonUsd": 350 },
"floorPctOfBase": 84,
"includedPerks": ["Daily breakfast included", "Welcome Prosecco"]
}
]
}'{
"ok": true,
"version": "v1",
"schema": "plannyfy.travel.portfolio.v1",
"vendorId": "demo-vendor",
"upsertedCount": 1,
"items": [
{
"id": "pf-demo-vendor-pal-roma-01",
"externalId": "pal-roma-01",
"title": "Hotel Palazzo Roma · 5★ Boutique Stay",
"basePriceUsd": 420,
"source": "api_push"
}
]
}/api/v1/vendor/portfolioDelete a Portfolio OfferingRemoves a portfolio offering from your catalog by Plannyfy item `id` or your internal `externalId` (SKU).
externalIdPartner external SKU / room code (e.g. `pal-roma-01`). Provide either `externalId` or `id`.
idPlannyfy portfolio item ID (e.g. `pf-rome-palazzo`).
curl -X DELETE "https://plannyfy.com/api/v1/vendor/portfolio?externalId=pal-roma-01" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"vendorId": "demo-vendor",
"deletedId": "pf-rome-palazzo"
}/api/v1/vendor/sub-vendorsList Sub-Vendor Invite Links & Wholesale OfferingsLists all sub-vendor onboarding invite links created by the primary vendor/DMC along with synced sub-vendor wholesale offerings and auto-calculated markup rates (`effectiveBidUsd`).
AuthorizationPrimary vendor API key (`X-Plannyfy-Api-Key`) or authenticated vendor session cookie.
curl -X GET "https://plannyfy.com/api/v1/vendor/sub-vendors" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"vendorId": "demo-vendor",
"inviteCount": 2,
"invites": [
{
"id": "svi-capri-yacht",
"subVendorName": "Capri Riva Private Yachts",
"category": "activity",
"location": "Capri & Amalfi Coast, Italy",
"defaultMarkupPct": 15,
"status": "active",
"inviteUrl": "/vendor/invite/svi-capri-yacht"
}
],
"subVendorOfferingCount": 2,
"subVendorOfferings": [
{
"id": "pf-sub-capri-yacht",
"title": "Private Riva Yacht Full-Day Capri Charter",
"wholesaleRateUsd": 1400,
"markupPct": 15,
"effectiveBidUsd": 1610
}
]
}/api/v1/vendor/sub-vendorsCreate Sub-Vendor Invite or Push Wholesale OfferingCreates a scoped sub-vendor onboarding link (`action: "create_invite"`) or pushes a sub-vendor wholesale offering (`action: "push_offering"`) that automatically computes `basePriceUsd = round(wholesaleRateUsd * (1 + markupPct / 100))` in the primary vendor's portfolio.
actionSelects whether to generate a new sub-vendor invite link or push a sub-vendor wholesale rate.
subVendorName / subVendorEmailRequired for `create_invite`: partner business name and contact email.
defaultMarkupPct / markupPctWholesale-to-bid markup percentage applied when composing primary vendor bids.
inviteId / wholesaleRateUsd / titleRequired for `push_offering`: sub-vendor invite token, net wholesale rate in USD, and offering title.
# 1. Primary vendor creates a sub-vendor onboarding link
curl -X POST "https://plannyfy.com/api/v1/vendor/sub-vendors" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "create_invite",
"subVendorName": "Sorrento Limo & Mercedes Fleet",
"subVendorEmail": "dispatch@sorrentolimo.it",
"category": "transfer",
"location": "Naples & Amalfi Coast, Italy",
"defaultMarkupPct": 15
}'
# 2. Sub-vendor pushes a wholesale offering via inviteId
curl -X POST "https://plannyfy.com/api/v1/vendor/sub-vendors" \
-H "Content-Type: application/json" \
-d '{
"action": "push_offering",
"inviteId": "svi-sorrento-transfers",
"title": "Mercedes V-Class Private Transfer Naples ↔ Positano",
"wholesaleRateUsd": 220,
"pricingUnit": "per_group",
"includedPerks": ["English-speaking chauffeur", "Flight tracking"]
}'{
"ok": true,
"version": "v1",
"parentVendorId": "demo-vendor",
"item": {
"id": "pf-sub-svi-sorrento-transfers",
"title": "Mercedes V-Class Private Transfer Naples ↔ Positano",
"wholesaleRateUsd": 220,
"subVendorMarkupPct": 15,
"basePriceUsd": 253,
"source": "sub_vendor"
}
}Outbound Webhooks & Verification API
Trigger signed HMAC-SHA256 test webhook deliveries and verify real-time event notifications (`X-Plannyfy-Signature`).
/api/v1/vendor/webhooks/testTrigger Signed Test Webhook DeliveryGenerates a realistic event payload, signs it with your `webhookSecret` (`X-Plannyfy-Signature: t=<unix_sec>,v1=<hmac_sha256>`), dispatches it to your `webhookUrl`, and records the delivery in your log.
eventWebhook lifecycle event to simulate (defaults to `opportunity.matched`).
webhookUrlOptional override destination URL for testing before saving.
curl -X POST "https://plannyfy.com/api/v1/vendor/webhooks/test" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"event": "opportunity.matched",
"webhookUrl": "https://api.yourhotel.com/webhooks/plannyfy"
}'{
"ok": true,
"event": "opportunity.matched",
"webhookUrl": "https://api.yourhotel.com/webhooks/plannyfy",
"signatureHeader": "X-Plannyfy-Signature: t=1792798000,v1=5a8c91d4f20e7b11a3e84c9b2016d7f9c4e12a8b",
"statusText": "200 OK · Signed \"opportunity.matched\" delivered to https://api.yourhotel.com/webhooks/plannyfy",
"payload": { "id": "evt_...", "version": "v1", "event": "opportunity.matched", "vendorId": "...", "createdAt": "...", "data": { } },
"delivery": {
"id": "whd-m1x2-ab12",
"event": "opportunity.matched",
"mode": "test",
"webhookUrl": "https://api.yourhotel.com/webhooks/plannyfy",
"statusCode": 200,
"statusText": "200 OK · ...",
"deliveredAt": "2026-10-26T06:00:00.000Z"
}
}/api/v1/vendor/webhooksRead webhook settingsYour endpoint URL, subscribed events, the last 20 deliveries (with the HTTP status your endpoint answered) and the events that exist. The signing secret is never returned.
curl -X GET "https://plannyfy.com/api/v1/vendor/webhooks" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"webhook": { "url": "https://api.yourhotel.com/webhooks/plannyfy", "events": ["opportunity.matched", "trip.locked"], "secretHint": "whsec_…9c1d", "lastTestAt": null, "lastTestStatus": null, "updatedAt": "2026-10-26T06:00:00.000Z" },
"deliveries": [{ "id": "whd-m1x2-ab12", "event": "trip.locked", "mode": "live", "statusCode": 200, "statusText": "200 OK ...", "deliveredAt": "2026-10-26T05:41:00.000Z" }],
"availableEvents": [{ "id": "opportunity.matched", "description": "..." }]
}/api/v1/vendor/webhooksChange webhook settingsSets the endpoint URL and/or the subscribed events; what you leave out stays. url: null removes the endpoint. The URL must be https and a public host (no credentials, localhost, private or link-local address). The first time an endpoint is set, a signing secret is created: fetch it with rotate-secret.
urlYour receiving endpoint.
eventsopportunity.matched, bid.status_changed, trip.locked, dispute.updated.
curl -X PUT "https://plannyfy.com/api/v1/vendor/webhooks" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourhotel.com/webhooks/plannyfy",
"events": [
"opportunity.matched",
"trip.locked"
]
}'{ "ok": true, "version": "v1", "webhook": { "url": "https://api.yourhotel.com/webhooks/plannyfy", "events": ["opportunity.matched", "trip.locked"], "secretHint": "whsec_…9c1d" } }/api/v1/vendor/webhooks/rotate-secretRotate the webhook signing secretReplaces your signing secret and returns the new one, this once. The old secret stops signing at once: update your receiver first, or accept both for a while. Not idempotent; Idempotency-Key is ignored here on purpose so the secret is never kept in a stored answer.
curl -X POST "https://plannyfy.com/api/v1/vendor/webhooks/rotate-secret" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{ "ok": true, "version": "v1", "webhookSecret": "whsec_plannyfy_a1b2c3d4_…", "rotatedAt": "2026-10-26T06:10:00.000Z", "note": "Shown once. Store it now." }Photo Library, Collections & Gallery API
Upload photos to your library, group them into collections that your offerings link to, and browse the Plannyfy stock gallery. Every call only sees your own library.
/api/v1/vendor/photosList your photosYour photo library, newest first. Each photo has an `id`, a `url`, its `alt` text, its `tags` and size. Search and filter by tag; send `page` or `limit` to page the results.
qSearch alt text, file name and tags.
tagFilter by tag. Repeat or comma-separate: a photo must carry every tag.
sort`newest` (default), `oldest` or `name`.
pagePage number. Paging starts when `page` or `limit` is sent.
limitPhotos per page, up to 100.
curl "https://plannyfy.com/api/v1/vendor/photos" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"photos": [{ "id": "a1b2c3d4e5f60718293a", "url": "https://plannyfy.com/api/media/...", "alt": "Rooftop terrace", "tags": ["rooftop", "suites"] }],
"tags": [{ "tag": "rooftop", "count": 4 }]
}/api/v1/vendor/photosUpload a photoSend one image as `multipart/form-data`. It is converted to WebP and its metadata is removed. Answers `413` when too large, `415` when it is not an image, `409` at the library cap.
fileThe image to add to your library.
tagsComma-separated tags for the photo (up to 10, lowercase, 24 characters each).
curl -X POST "https://plannyfy.com/api/v1/vendor/photos" \ -H "Authorization: Bearer pfy_live_YOUR_KEY" \ -F "file=@terrace.jpg"
{ "ok": true, "photo": { "id": "a1b2c3d4e5f60718293a", "url": "https://plannyfy.com/api/media/...", "alt": "" } }/api/v1/vendor/photos/bulkChange several photosTag, untag, add to a collection or delete up to 100 photos at once. You get a result per photo (`done`, `unchanged` or `failed`), so one failure never blocks the rest and repeating a request is safe. Deleting keeps photos that offerings still show (`in_use`) unless you send `force`.
action`add_tags`, `remove_tags`, `add_to_collection` or `delete`.
idsPhoto ids, up to 100.
tagsFor `add_tags` and `remove_tags`.
collectionIdFor `add_to_collection`.
forceFor `delete`: also delete photos that offerings show.
curl -X POST "https://plannyfy.com/api/v1/vendor/photos/bulk" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "add_tags", "ids": ["a1b2c3d4e5f60718293a"], "tags": ["rooftop"] }'{ "ok": true, "results": [{ "id": "a1b2c3d4e5f60718293a", "status": "done" }], "summary": { "done": 1, "unchanged": 0, "failed": 0 } }/api/v1/vendor/galleryList Plannyfy gallery photosThe active Plannyfy stock photos you can add to a collection, newest first, plus `collections`: curated Plannyfy sets whose `photoKeys` you can add all at once.
curl "https://plannyfy.com/api/v1/vendor/gallery" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{ "ok": true, "photos": [{ "key": "rome", "url": "https://plannyfy.com/api/media/...", "label": "Rome" }] }/api/v1/vendor/collectionsList your photo collectionsYour collections with their `tags`, photos resolved (cover first) and the offerings that use each. Search and filter by tag like the photo list.
qSearch names and tags.
tagFilter by tag (repeat or comma-separate; all must match).
sort`updated` (default), `name` or `photos`.
pagePage number. Paging starts when `page` or `limit` is sent.
limitCollections per page, up to 100.
curl "https://plannyfy.com/api/v1/vendor/collections" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{ "ok": true, "collections": [{ "id": "c1", "name": "Palazzo suites", "photos": [], "usedBy": [] }] }/api/v1/vendor/collectionsCreate a photo collectionCreate a collection from library photos or gallery photos, or send `fromOfferingId` to save an offering's current photos as a collection and link the offering to it.
nameCollection name.
itemsPhotos in order. `ref` is `{ kind: "library", photoId }` or `{ kind: "plannyfy", key }`.
tagsUp to 10 tags (lowercase, 24 characters each).
fromOfferingIdCreate the collection from one of your offerings' photos instead.
curl -X POST "https://plannyfy.com/api/v1/vendor/collections" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Palazzo suites", "items": [{ "ref": { "kind": "library", "photoId": "a1b2c3d4e5f60718293a" } }] }'{ "ok": true, "id": "c1", "name": "Palazzo suites", "photos": [], "skipped": [] }/api/v1/vendor/collections/{id}Get a collectionOne of your photo collections with its resolved photos and the offerings that use it. Someone else's collection is the same 404 as a missing one.
idThe collection id.
curl -X GET "https://plannyfy.com/api/v1/vendor/collections/a1b2c3d4e5f60718293a" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{ "ok": true, "collection": { "id": "a1b2c3d4e5f60718293a", "name": "Palazzo suites", "tags": ["rome"] }, "photos": [], "usedBy": [] }/api/v1/vendor/collections/{id}Edit a collectionSame body as creating one. Every offering linked to the collection shows the change.
nameCollection name.
items[]The photos in order.
coverPhotoRef / tagsCover photo and tags.
curl -X PATCH "https://plannyfy.com/api/v1/vendor/collections/a1b2c3d4e5f60718293a" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Palazzo suites",
"items": [
{
"ref": {
"kind": "library",
"photoId": "ph_123"
}
}
]
}'{ "ok": true, "collection": { "id": "a1b2c3d4e5f60718293a", "name": "Palazzo suites" }, "photos": [], "usedBy": [] }/api/v1/vendor/collections/{id}Delete a collection409 in_use (with usedBy) while offerings show the collection. Add ?force=1 to delete anyway: those offerings keep their photos as a plain gallery.
forceDelete even when offerings use it.
curl -X DELETE "https://plannyfy.com/api/v1/vendor/collections/a1b2c3d4e5f60718293a?force=1" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{ "ok": true }/api/v1/vendor/collections/{id}/duplicateDuplicate a collectionAn independent copy named "<name> (copy)".
Idempotency-KeyMakes a retry safe.
curl -X POST "https://plannyfy.com/api/v1/vendor/collections/a1b2c3d4e5f60718293a/duplicate" \ -H "Authorization: Bearer pfy_live_YOUR_KEY" \ -H "Idempotency-Key: 7f1b8f0e-1d1c-4c4e-8a77-0a4f6a1f5c2e"
{ "ok": true, "collection": { "id": "b2c3d4e5f60718293a4b", "name": "Palazzo suites (copy)" }, "photos": [], "usedBy": [] }/api/v1/vendor/photos/{id}Edit a photoChange the alt text and/or tags of one of your photos (send at least one).
altAlt text.
tagsReplaces the photo's tags.
curl -X PATCH "https://plannyfy.com/api/v1/vendor/photos/ph_123" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"alt": "Rooftop terrace at dusk",
"tags": [
"rome",
"terrace"
]
}'{ "ok": true, "photo": { "id": "ph_123", "alt": "Rooftop terrace at dusk", "tags": ["rome", "terrace"] } }/api/v1/vendor/photos/{id}Delete a photo409 in_use (with usedBy) while offerings show the photo. Add ?force=1 to delete anyway and remove it from those offerings.
forceDelete even when offerings use it.
curl -X DELETE "https://plannyfy.com/api/v1/vendor/photos/ph_123?force=1" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{ "ok": true }Plannyfy Escrow & Billing API
Start escrow checkout sessions for your own trips and read your saved payment methods. Saving or changing payment settings is only available in the Plannyfy app.
/api/v1/escrow/checkoutInitialize Plannyfy Escrow Checkout & Payout PlanResolves the winning `price` or `value` allocation mix for a trip, calculates per-category platform commission and optional 100% fee-back trip protection, and creates the Plannyfy Escrow Checkout session and per-vendor payout plan.
tripIdID of the open trip being locked into escrow.
pickSelected winning mix (`price` for Best Price or `value` for Best Value).
withInsuranceWhen true, adds optional 100% fee-back cancellation protection.
paymentMethodId / paymentMethodTypeSelected or newly added customer payment method authorized for the 48h post-trip escrow hold.
curl -X POST "https://plannyfy.com/api/v1/escrow/checkout" \
-H "Content-Type: application/json" \
-d '{
"tripId": "trip-italy-anniversary",
"pick": "value",
"withInsurance": true,
"paymentMethodId": "pm-visa-4242",
"paymentMethodType": "card",
"paymentMethodLabel": "Visa Infinite •••• 4242"
}'{
"tripId": "trip-italy-anniversary",
"pick": "value",
"packageTotalUsd": 8820,
"withInsurance": true,
"insuranceFeeUsd": 353,
"grandTotalUsd": 9173,
"paymentMethodId": "pm-visa-4242",
"paymentMethodType": "card",
"paymentMethodLabel": "Visa Infinite •••• 4242",
"totalPlatformFeeUsd": 1014,
"blendedCommissionPct": 11.5,
"vendorTransfers": [
{
"vendorId": "vendor-atlas-italia",
"vendorName": "Atlas Italia Luxury DMC",
"grossAmountUsd": 4950,
"platformFeeUsd": 594,
"netVendorPayoutUsd": 4356,
"releaseStatus": "held_until_trip_start"
}
]
}/api/v1/billing/payment-methodsAdd Payment Method or Payout Account (App Session)Adds a customer payment method (`card`, `ach_bank`, `express_wallet`) or vendor payout receiving account (`bank_transfer`, `instant_debit`, `swift_wire`) from the signed-in Plannyfy web app. API keys receive `403 session_only`; use `GET /api/v1/billing/payment-methods` (`billing:read`) to list saved methods.
roleSelects customer payment wallet (`card`, `ach_bank`, `express_wallet`) or vendor payout receiving rails (`bank_transfer`, `instant_debit`, `swift_wire`).
type / methodTypePayment method or payout receiving rail type.
curl -X POST "https://plannyfy.com/api/v1/billing/payment-methods" \
-H "Content-Type: application/json" \
-d '{
"role": "customer",
"type": "card",
"brand": "Visa Infinite",
"last4": "4242",
"expiry": "08/28",
"isDefault": true
}'{
"ok": true,
"version": "v1",
"role": "customer",
"defaultPaymentMethodId": "pm-visa-4242"
}/api/v1/billing/payment-methodsList payment methodsA customer's saved payment methods (never full card numbers), or a vendor's payout receiving accounts and invoice settings.
roleDefaults to your account type.
curl -X GET "https://plannyfy.com/api/v1/billing/payment-methods" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{ "ok": true, "version": "v1", "role": "customer", "defaultPaymentMethodId": "pm-visa-4242", "paymentMethods": [{ "id": "pm-visa-4242", "type": "card", "brand": "Visa", "last4": "4242", "expiry": "08/28", "isDefault": true }] }/api/v1/billing/payment-methodsSwitch the payment method on an invoiceChanges the payment method attached to a customer escrow receipt or a vendor commission invoice.
tripId / invoiceIdThe receipt (customer) or invoice (vendor) to change.
paymentMethodIdThe method to use.
curl -X PATCH "https://plannyfy.com/api/v1/billing/payment-methods" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"tripId": "trip-italy-anniversary",
"paymentMethodId": "pm-mc-8819"
}'{ "ok": true, "version": "v1" }Opportunities, Bids & Availability API
Find the open trips and events that match your services, place/revise/withdraw/confirm bids with server-side price limits, and manage your availability rules and blackout dates.
/api/v1/vendor/opportunitiesList matched opportunitiesOpen trips and events that match your services and regions, best match first. Traveler-safe: a market benchmark and a competition band, never the traveler's budget, a target price or a rival's price. Events carry guestCount and sanitized eventDetails (no invoice note, no street address).
matchedfalse lists every open opportunity, not only matches.
includeBidInclude opportunities you already have a live bid on.
packageTypeOnly this kind of opportunity.
categoryOnly trips with an item in this category (hotel, catering, ...).
qTitle or destination contains.
limit / cursorPagination: send page.nextCursor as cursor.
curl -X GET "https://plannyfy.com/api/v1/vendor/opportunities?matched=true&limit=20" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"vendorId": "vendor-atlas-dmc",
"opportunities": [
{
"id": "trip-wedding-lake-como",
"packageType": "wedding",
"title": "Lake Como wedding weekend",
"destination": "Lake Como, Italy",
"startDate": "2027-06-11",
"endDate": "2027-06-13",
"guestCount": 80,
"biddingClosesAt": "2026-11-20T18:00:00.000Z",
"revision": 2,
"marketTotalUsd": 41200,
"competition": "light",
"alreadyBid": false,
"matchesServices": true,
"eventDetails": { "subtype": "wedding", "venueStatus": "booked", "venueLocation": { "city": "Como", "country": "Italy" }, "guestRange": { "min": 70, "max": 90 } },
"items": [
{ "id": "item-catering", "category": "catering", "title": "Wedding dinner", "quantity": 1, "unit": "per_person", "perGuest": true, "referencePriceUsd": 9600 }
]
}
],
"page": { "limit": 20, "nextCursor": null, "hasMore": false, "total": 1 },
"next": null
}/api/v1/vendor/opportunities/{id}Get an opportunityOne open opportunity with its items (unit-pricing fields for events), offerValidityHours, your ownBidId if you already bid, and offeringCandidates: the offerings of yours that fit each item, ready to link with offeringId when you bid. Closed, draft, own and unknown trips are 404.
idThe opportunity (trip) id.
curl -X GET "https://plannyfy.com/api/v1/vendor/opportunities/trip-wedding-lake-como" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"opportunity": {
"id": "trip-wedding-lake-como",
"revision": 2,
"guestCount": 80,
"offerValidityHours": 72,
"items": [{ "id": "item-catering", "category": "catering", "unit": "per_person", "perGuest": true }],
"offeringCandidates": [
{ "itemId": "item-catering", "offeringId": "pf-como-menu", "title": "Lakeside tasting menu", "suggestedPriceUsd": 8800, "minGuests": 60 }
]
}
}/api/v1/vendor/bidsList your bidsYour own bids. Live bids come most-at-risk first, past ones newest first. Each live bid carries a Bid Health band, never a rival's price or the traveler's budget. Lines include your verified offering link and its frozen snapshot.
stateWhich bids to list.
tripIdOnly bids on this trip.
limit / cursorPagination.
curl -X GET "https://plannyfy.com/api/v1/vendor/bids?state=live" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"bids": [
{
"id": "bid-1042",
"tripId": "trip-wedding-lake-como",
"state": "live",
"status": "winning",
"kind": "itemized",
"total": 8800,
"lines": [{ "itemId": "item-catering", "price": 8800, "unit": "per_person", "unitPrice": 110, "minGuests": 60, "inclusions": "Four courses, wine pairing", "offering": { "id": "pf-como-menu", "source": "manual" } }],
"validUntil": "2026-11-23T18:00:00.000Z",
"health": { "band": "strong", "position": "in_winning_mix", "hoursRemaining": 71.5, "expiringSoon": false }
}
],
"page": { "limit": 50, "nextCursor": null, "hasMore": false, "total": 1 },
"next": null
}/api/v1/vendor/bidsPlace a bidPlaces a live bid with exactly the composer's rules: the trip is open, not yours and unchanged since tripRevision; you have no other live bid on it; every line passes the price limits (not more than 50% above the market reference), unit pricing and minimum guests; every offeringId is verified as yours. Send an Idempotency-Key so a retry cannot place the bid twice. There is no draft for a bid that does not exist yet: save a draft of a live bid with PATCH and publish: false.
Idempotency-KeySame key and body: the first answer is replayed for 24 hours. Same key, different body: 409.
tripIdThe opportunity id.
kindA bundle needs at least two lines.
lines[]1-50 lines. Event items priced by unit: send unit and unitPrice and the server computes price.
floorPriceYour private minimum. Never shown to anyone else.
tripRevisionThe opportunity revision you priced against; a mismatch is 409 trip_changed.
curl -X POST "https://plannyfy.com/api/v1/vendor/bids" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Idempotency-Key: 7f1b8f0e-1d1c-4c4e-8a77-0a4f6a1f5c2e" \
-H "Content-Type: application/json" \
-d '{
"tripId": "trip-wedding-lake-como",
"tripRevision": 2,
"lines": [
{
"itemId": "item-catering",
"unit": "per_person",
"unitPrice": 110,
"minGuests": 60,
"inclusions": "Four courses, wine pairing",
"offeringId": "pf-como-menu"
}
]
}'{
"ok": true,
"version": "v1",
"bid": { "id": "bid-1042", "tripId": "trip-wedding-lake-como", "state": "live", "kind": "itemized", "total": 8800, "validUntil": "2026-11-23T18:00:00.000Z" }
}
// 409 { "ok": false, "error": { "code": "bid_exists", "message": "You already have a live bid on this trip. ...", "requestId": "req_..." }, "bidId": "bid-1042" }
// 422 { "ok": false, "error": { "code": "validation_failed", "message": "\"Wedding dinner\" is more than 50% above market price. ...", "requestId": "req_..." } }/api/v1/vendor/bids/{id}Get a bidOne of your bids, live or past. Someone else's bid is the same 404 as a missing one.
idThe bid id.
curl -X GET "https://plannyfy.com/api/v1/vendor/bids/bid-1042" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{ "ok": true, "version": "v1", "bid": { "id": "bid-1042", "state": "live", "status": "winning", "total": 8800 } }/api/v1/vendor/bids/{id}Revise a bid or save a draftpublish: true (default) revises the live bid with the composer's rules, including the daily price-change cap and the below-market and below-floor confirmation: such a change answers 409 confirmation_required, and goes through when repeated with confirm: true. publish: false saves the lines as your private draft; nothing goes live. Lines you leave out are removed from the bid.
linesThe full set of lines (null with publish: false discards the draft).
publishfalse saves a private draft only.
confirmConfirms a price that is far below market or below your own floor.
tripRevisionA mismatch is 409 trip_changed.
curl -X PATCH "https://plannyfy.com/api/v1/vendor/bids/bid-1042" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"publish": true,
"lines": [
{
"itemId": "item-catering",
"unit": "per_person",
"unitPrice": 104,
"minGuests": 60
}
]
}'{
"ok": true,
"version": "v1",
"published": true,
"stillPaused": false,
"bid": { "id": "bid-1042", "total": 8320, "status": "winning" }
}/api/v1/vendor/bids/{id}/withdrawWithdraw a live bidWithdraws a live bid before the customer locks the trip. Returns 409 trip_locked if the trip is already locked.
idThe bid id.
reasonOptional withdrawal reason.
curl -X POST "https://plannyfy.com/api/v1/vendor/bids/bid-1042/withdraw" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Idempotency-Key: 7f1b8f0e-1d1c-4c4e-8a77-0a4f6a1f5c2e" \
-H "Content-Type: application/json" \
-d '{
"reason": "Dates booked via direct channel"
}'{ "ok": true, "version": "v1", "bidId": "bid-1042", "withdrawn": true, "reason": "Dates booked via direct channel" }/api/v1/vendor/bids/{id}/confirmConfirm bid price after a customer trip editClears needsReprice on your live bid and aligns bidRevision with the trip's current revision after the customer edits dates, party size, or items.
idThe bid id.
curl -X POST "https://plannyfy.com/api/v1/vendor/bids/bid-1042/confirm" \ -H "Authorization: Bearer pfy_live_YOUR_KEY" \ -H "Idempotency-Key: 7f1b8f0e-1d1c-4c4e-8a77-0a4f6a1f5c2e"
{ "ok": true, "version": "v1", "bidId": "bid-1042", "confirmed": true, "needsReprice": false, "bidRevision": 3 }/api/v1/vendor/availabilityRead vendor availability rules & blackout datesReturns maxEventsPerDay, leadTimeDays, and blockedRanges for the authenticated vendor.
curl -X GET "https://plannyfy.com/api/v1/vendor/availability" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"vendorId": "vendor-atlas-dmc",
"availability": {
"maxEventsPerDay": 2,
"leadTimeDays": 7,
"blockedRanges": [{ "id": "blk-1", "startDate": "2027-08-10", "endDate": "2027-08-20", "reason": "Annual maintenance" }],
"updatedAt": "2026-10-26T12:00:00.000Z"
}
}/api/v1/vendor/availabilityUpdate vendor availability rules & blackout datesUpdates maxEventsPerDay (1-10), leadTimeDays (0-365), and/or blockedRanges (up to 100 ranges in YYYY-MM-DD).
maxEventsPerDayMaximum concurrent events or trips per day.
leadTimeDaysMinimum days of notice required before startDate.
blockedRanges[]Blackout date ranges (YYYY-MM-DD).
curl -X PUT "https://plannyfy.com/api/v1/vendor/availability" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"maxEventsPerDay": 2,
"leadTimeDays": 7,
"blockedRanges": [
{
"startDate": "2027-08-10",
"endDate": "2027-08-20",
"reason": "Summer closure"
}
]
}'{ "ok": true, "version": "v1", "vendorId": "vendor-atlas-dmc", "availability": { "maxEventsPerDay": 2, "leadTimeDays": 7, "blockedRanges": [{ "id": "blk-1", "startDate": "2027-08-10", "endDate": "2027-08-20", "reason": "Summer closure" }] } }Earnings & Payouts API
Your won bookings with commission and net, the state of every payout, and your pipeline. Only your own awarded lines; never another vendor's data.
/api/v1/vendor/earningsList won bookings and earningsYour won bookings (newest first) with awarded lines, commission, net and payout state, totals in summary, and your pipeline. Refunds and dispute outcomes lower the figures once the payout ledger records them.
payoutOnly bookings in this payout state.
limit / cursorPagination of the bookings.
curl -X GET "https://plannyfy.com/api/v1/vendor/earnings?payout=pending" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"currency": "usd",
"summary": { "totalGrossWon": 8800, "totalCommission": 968, "totalNetWon": 7832, "pendingNetAmount": 7832, "paidNetAmount": 0, "wonCount": 1 },
"bookings": [
{ "bidId": "bid-1042", "grossAmount": 8800, "commissionAmount": 968, "netPayout": 7832, "fulfillmentStatus": "upcoming", "payoutStatus": "pending", "payout": { "status": "pending", "label": "Scheduled", "note": "" }, "payoutReleasesAt": "2027-06-13" }
],
"pipeline": [],
"page": { "limit": 50, "nextCursor": null, "hasMore": false, "total": 1 },
"next": null
}/api/v1/vendor/payoutsList payoutsThe state of your payouts, newest first, in USD. Admin notes, hold reasons, raw payment-provider errors and account identifiers are never included: note is the sentence the app shows.
statusOnly payouts in this state.
tripIdOnly this trip.
limit / cursorPagination.
curl -X GET "https://plannyfy.com/api/v1/vendor/payouts?status=held" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"currency": "usd",
"payouts": [
{ "id": "po-8f2c", "tripId": "trip-wedding-lake-como", "status": "held", "label": "On hold", "note": "Finish payout setup in Billing and we'll release this.", "needsBilling": true, "grossUsd": 8800, "commissionUsd": 968, "netUsd": 7832, "eligibleAt": "2027-06-15T23:59:59.000Z" }
],
"page": { "limit": 50, "nextCursor": null, "hasMore": false, "total": 1 },
"next": null
}Customer Trips, Lock, Escrow & Concierge API
Create and publish customer trips or event packages, inspect the live #1 Best Price and #1 Best Value optimal mixes, lock winning vendor packages into escrow, confirm post-trip completion, open disputes, and exchange moderated concierge messages.
/api/v1/tripsList customer trips & event packagesLists the authenticated customer's trips (newest first) with live bid counts, winning-mix summaries, and cursor pagination.
statusFilter by lifecycle status.
packageTypeFilter by package type.
limit / cursorPagination.
curl -X GET "https://plannyfy.com/api/v1/trips?status=open&limit=20" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"trips": [
{
"id": "trip-italy-anniversary",
"title": "10-Day Amalfi & Rome Anniversary",
"destination": "Amalfi Coast & Rome, Italy",
"packageType": "travel",
"status": "open",
"priority": "balanced",
"targetBudget": 8500,
"retailEstimate": 9700,
"bidCount": 4,
"bestMixTotalUsd": 8290,
"bestMixComplete": true
}
],
"page": { "limit": 20, "nextCursor": null, "hasMore": false, "total": 1 },
"next": null
}/api/v1/tripsCreate a customer trip or event packageCreates a new trip or event package with 1-50 itinerary items. When draft is false (default), opens the reverse auction immediately and dispatches opportunity.matched webhooks to matching vendors.
Idempotency-KeyMakes retries safe for 24 hours.
destinationCity, region, or venue destination.
startDate / endDateStart and end dates.
targetBudgetSealed target budget in USD (never shown to vendors).
items[]1-50 itinerary or event line items.
draftSave as private draft before opening bidding.
curl -X POST "https://plannyfy.com/api/v1/trips" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Idempotency-Key: 7f1b8f0e-1d1c-4c4e-8a77-0a4f6a1f5c2e" \
-H "Content-Type: application/json" \
-d '{
"title": "Amalfi Anniversary Escape",
"destination": "Amalfi Coast, Italy",
"startDate": "2027-05-10",
"endDate": "2027-05-15",
"partySize": 2,
"targetBudget": 4800,
"priority": "balanced",
"items": [
{
"category": "hotel",
"title": "5-Night Boutique Cliffside Hotel",
"quantity": 1,
"referencePrice": 3600
},
{
"category": "activity",
"title": "Private Capri Sunset Yacht Charter",
"quantity": 1,
"referencePrice": 1600
}
]
}'{ "ok": true, "version": "v1", "trip": { "id": "trip-custom-901", "status": "open", "destination": "Amalfi Coast, Italy", "targetBudget": 4800, "retailEstimate": 5200 } }/api/v1/trips/{id}Get trip detail, live bids & optimal mixReturns the customer's trip with its items, the reverse-auction optimal mix (#1 Best Price and #1 Best Value picks), per-item offer rows, refunds, and disputes.
idThe trip id.
curl -X GET "https://plannyfy.com/api/v1/trips/trip-italy-anniversary" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"trip": { "id": "trip-italy-anniversary", "status": "open", "priority": "balanced", "targetBudget": 8500, "retailEstimate": 9700 },
"bidCount": 4,
"exchange": {
"picks": {
"price": { "complete": true, "total": 8290, "savingsVsRetail": 1410, "savingsPct": 15, "budgetDelta": -210 },
"value": { "complete": true, "total": 8820, "savingsVsRetail": 880, "savingsPct": 9, "budgetDelta": 320 }
},
"rows": []
},
"refunds": [],
"disputes": []
}/api/v1/trips/{id}Publish a draft, change priority, or edit an open tripPublishes a draft trip (publish: true), updates the ranking priority (price | balanced | quality), or edits trip dates, partySize, targetBudget, or items while open (incrementing revision and flagging live bids for vendor re-confirmation).
idThe trip id.
publishSet true to publish a draft trip and open bidding.
priorityUpdate the optimal-mix ranking priority.
destination / startDate / endDate / partySize / targetBudget / itemsItinerary fields to update on a draft or open trip.
curl -X PATCH "https://plannyfy.com/api/v1/trips/trip-italy-anniversary" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"priority": "quality"
}'{ "ok": true, "version": "v1", "trip": { "id": "trip-italy-anniversary", "status": "open", "priority": "quality", "revision": 2 } }/api/v1/trips/{id}/lockLock winning package mix into escrowLocks the selected optimal mix (price or value pick) into Plannyfy escrow, awards winning vendor bids, creates vendor payout ledger entries (held until 48h after trip endDate), and dispatches trip.locked webhooks.
idThe trip id.
pickWhich optimal mix to lock.
withInsuranceAdd Cancel-for-Any-Reason protection.
paymentMethodIdSaved customer payment method id.
curl -X POST "https://plannyfy.com/api/v1/trips/trip-italy-anniversary/lock" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Idempotency-Key: 7f1b8f0e-1d1c-4c4e-8a77-0a4f6a1f5c2e" \
-H "Content-Type: application/json" \
-d '{
"pick": "value",
"withInsurance": true,
"paymentMethodId": "pm-visa-4242"
}'{ "ok": true, "version": "v1", "tripId": "trip-italy-anniversary", "status": "locked", "lockedSelection": { "pick": "value", "total": 8820, "grandTotalUsd": 9173 }, "payoutEntriesCreated": 2 }/api/v1/trips/{id}/completeRecord customer post-trip completion sign-offRecords the customer's 48-hour post-trip completion sign-off: confirmed marks the trip completed so escrow payouts release on schedule; issue_reported pauses payout release and opens a formal dispute.
idThe trip id.
statusCustomer completion sign-off outcome.
noteCompletion note or issue summary.
curl -X POST "https://plannyfy.com/api/v1/trips/trip-italy-anniversary/complete" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Idempotency-Key: 7f1b8f0e-1d1c-4c4e-8a77-0a4f6a1f5c2e" \
-H "Content-Type: application/json" \
-d '{
"status": "confirmed",
"note": "Everything went smoothly across every vendor."
}'{ "ok": true, "version": "v1", "tripId": "trip-italy-anniversary", "tripStatus": "completed", "completionCheck": { "status": "confirmed" } }/api/v1/trips/{id}/cancelCancel a trip or delete a draftDeletes a draft trip, cancels an open trip, or cancels a locked trip with automatic cancellation-policy refund calculation and payout ledger reversal.
idThe trip id.
reasonCancellation reason.
curl -X POST "https://plannyfy.com/api/v1/trips/trip-italy-anniversary/cancel" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Idempotency-Key: 7f1b8f0e-1d1c-4c4e-8a77-0a4f6a1f5c2e" \
-H "Content-Type: application/json" \
-d '{
"reason": "Flight schedule changed"
}'{ "ok": true, "version": "v1", "tripId": "trip-italy-anniversary", "cancelled": true, "refundAmountUsd": 8820 }/api/v1/trips/{id}/disputesOpen an escrow dispute on a locked tripOpens a formal escrow dispute on a locked trip, pauses vendor payout release until resolved by Plannyfy Trust & Safety, and dispatches dispute.updated webhooks.
idThe trip id.
reasonShort dispute reason.
detailsDetailed explanation for Trust & Safety review.
amountInDisputeAmount in dispute in USD.
vendorIdSpecific vendor id (defaults to primary winning vendor).
curl -X POST "https://plannyfy.com/api/v1/trips/trip-italy-anniversary/disputes" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Idempotency-Key: 7f1b8f0e-1d1c-4c4e-8a77-0a4f6a1f5c2e" \
-H "Content-Type: application/json" \
-d '{
"reason": "Weather cancellation on yacht charter",
"details": "Port authority closed Capri harbor; requesting charter line refund.",
"amountInDispute": 1480
}'{ "ok": true, "version": "v1", "dispute": { "id": "dsp-901", "tripId": "trip-italy-anniversary", "status": "open", "amountInDispute": 1480 } }/api/v1/trips/{id}/messagesList moderated concierge messages on a tripLists moderated messages on a trip thread. Accessible to the trip's customer or to an approved vendor with a bid on the trip.
idThe trip id.
vendorIdOptional vendor filter for customers.
curl -X GET "https://plannyfy.com/api/v1/trips/trip-italy-anniversary/messages" \ -H "Authorization: Bearer pfy_live_YOUR_KEY"
{
"ok": true,
"version": "v1",
"tripId": "trip-italy-anniversary",
"messages": [
{ "id": "msg-1", "tripId": "trip-italy-anniversary", "vendorId": "vendor-atlas-dmc", "senderRole": "customer", "senderName": "Elena & Marcus", "body": "Can you accommodate a gluten-free tasting menu?", "createdAt": "2026-10-26T14:00:00.000Z" }
]
}/api/v1/trips/{id}/messagesSend a moderated concierge message on a tripSends a message between the customer and a bidding/locked vendor. Blocks off-platform contact details (emails, phone numbers, URLs, social handles, payment links) with 422 message_blocked_contact_info.
idThe trip id.
bodyMessage text (screened by the PII moderation guard).
vendorIdTarget vendor id (required when called by a customer).
curl -X POST "https://plannyfy.com/api/v1/trips/trip-italy-anniversary/messages" \
-H "Authorization: Bearer pfy_live_YOUR_KEY" \
-H "Idempotency-Key: 7f1b8f0e-1d1c-4c4e-8a77-0a4f6a1f5c2e" \
-H "Content-Type: application/json" \
-d '{
"vendorId": "vendor-atlas-dmc",
"body": "Can we schedule the transfer pickup for 10:30 AM?"
}'{ "ok": true, "version": "v1", "message": { "id": "msg-2", "tripId": "trip-italy-anniversary", "vendorId": "vendor-atlas-dmc", "senderRole": "customer", "body": "Can we schedule the transfer pickup for 10:30 AM?" } }Plannyfy Common Schema v1 Fields & PMS Aliases
All supported fields on plannyfy.travel.portfolio.v1 and their accepted PMS/OTA aliases.
| Canonical Field | Accepted PMS Aliases | Type | Description |
|---|---|---|---|
| externalId | sku, code | string (optional) | Your internal PMS room/tour/charter code. Used for idempotent upserts across syncs. |
| category | type | "hotel" | "activity" | "guide" | "transfer" | "train" | "flight" | Travel category. Auto-inferred from title/description if omitted. |
| title | name, offeringName | string (required) | Public title of the hotel suite, villa, yacht charter, guide, or transfer. |
| location | city, destination | string | City and country or region (e.g., 'Rome, Italy', 'Amalfi Coast, Italy'). |
| pricingUnit | unit | "per_night" | "per_person" | "per_group" | "flat" | How the base rate scales with trip nights or party size. |
| basePriceUsd | price, rate, netRate, amountUsd | number (required) | Starting bid rate in USD per pricing unit. |
| wholesaleRateUsd / subVendorMarkupPct | markupPct, subVendorName | number / number (0–60) | Sub-vendor net wholesale rate and primary vendor markup percentage (`effectiveBidUsd`). |
| seasonalRates | peakSummerUsd, shoulderUsd, lowSeasonUsd | { peakSummerUsd?: number; shoulderUsd?: number; lowSeasonUsd?: number } | Peak (Jun–Aug), Shoulder (Apr–May, Sep–Oct), and Low (Nov–Mar) rates auto-matched to trip dates. |
| floorPctOfBase | floorPct | number (60–100) | Private minimum floor as % of base rate (never exposed to customers). |
| galleryUrls | photos, images | string[] (HTTPS URLs) | Multi-photo gallery URLs rendered in the customer offer showcase. |
| youtubeUrl | videoUrl | string (YouTube URL) | 4K walkthrough video embedded inline inside the customer's offer comparison. |
| videoTitle | — | string | Caption for your embedded video tour (e.g. '4K Suite & Pool Walkthrough'). |
| socialReelUrl | reelUrl, tiktokUrl | string | Optional Instagram Reel or TikTok highlight URL. |
| highlights | features | string[] | Structured experience/property highlights (e.g. 'Heated Infinity Pool'). |
| includedPerks | perks, inclusions, amenities | string[] or comma-separated string | Included extras (e.g., 'Daily breakfast included', 'Sea-view terrace upgrade'). |
| tripAdvisorUrl / googleMapsUrl / brochurePdfUrl | — | string (HTTPS URL) | Direct verification & digital fact-sheet links attached to your bids. |
| cancellationPolicy | cancellation | "flexible_24h" | "moderate_7d" | "strict_14d" | "non_refundable" | Pass-through cancellation window honored on locked bookings. |