B2B Partner API

Sell HolidaySG attraction, tour and ticket inventory through your own platform — real-time availability, price-locked quotes, money-safe booking, and wallet or credit settlement. This reference explains the full integration end to end.

Base URL  •  https://holidaysg.com/api/b2b/v1

Overview

A REST API over HTTPS. JSON in, JSON out. Every response uses a consistent { data, meta, errors } envelope.

The API exposes HolidaySG's bookable inventory — attractions, tours and tickets — sourced from our suppliers and normalised into a single catalog. You browse products, check live availability, lock a price, and book. Money settles through your wallet or an approved credit line. SEO/marketing content is never exposed; you receive only the fields you need to transact.

PropertyValue
Base URLhttps://holidaysg.com/api/b2b/v1
ProtocolHTTPS only · TLS 1.2+
FormatJSON request & response (UTF-8)
AuthX-Api-Key header on every request (+ HMAC signature on money-moving calls)
CurrencySGD (all prices, one currency — no FX)
VersioningPath-pinned /v1; breaking changes ship as a new version

API workflow

Five steps from browsing to a confirmed ticket. Each maps to one endpoint.

1
Browse catalog
GET /products
2
Check availability
GET /availability
3
Lock a price
POST /quotes
4
Book & pay
POST /bookings
5
Fulfil & notify
webhook + GET /bookings/{id}
  1. Understand product structure and map our products to yours (Product → Option → Ticket Type).
  2. Get live availability & pricing for a date (availability) then lock the price.
  3. Provide booking information (quote + customer) to create a paid transaction (book).
  4. Receive fulfilment — the e-ticket is delivered to the customer and you are notified by webhook.

🚀 Getting access

  1. Apply for a B2B partner account. Once approved you are placed in a commission group (default 1%).
  2. We issue a sandbox API key and its signing secret. Store the secret securely — it is shown only once.
  3. Build against sandbox (no real money or inventory) and complete the certification checklist.
  4. On certification we issue a production key and, if agreed, a credit line.

Verify a key at any time:

curl https://holidaysg.com/api/b2b/v1/me -H "X-Api-Key: <your-key>"

→ { "data": { "partner": { "id": 12, "name": "Acme Travel", "tier": 2, "currency": "SGD" },
      "api_key": { "name": "Prod", "prefix": "hsg_b2b_ab12", "scopes": [], "last_used_at": "..." } } }

🧩 Environments

There is one base URL for everything. Sandbox vs production is decided by which key you send — not by a different host.

EnvironmentHowMoney & inventory
SandboxUse a sandbox key against the same base URLSimulated — bookings are recorded but no wallet is charged and no supplier ticket is issued
ProductionUse a production keyReal — wallet/credit is charged and a real ticket is issued
Because the host is identical, you flip an integration from test to live simply by swapping the key and its signing secret. Keep the two key pairs in separate config.

🔑 Authentication

Send your API key on every request.

X-Api-Key: hsg_b2b_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys are stored hashed — we never see the raw key after issue. Money-moving requests additionally require a signature.

ConditionResponse
Missing / invalid / revoked / expired key, or a source IP outside your allowlist401 AUTH_UNAUTHORIZED
Valid key calling an endpoint outside its scopes403 AUTH_SCOPE_FORBIDDEN

🎚 Scopes

Each key carries a set of scopes. An empty scope list means full access (backward-compatible default); a non-empty list is strictly enforced per endpoint.

ScopeGrants
catalog:readProducts & availability
quote:writeCreate quotes
booking:readList & retrieve bookings
booking:writeCreate bookings
booking:cancelCancel bookings
statement:readStatements

Request signing

Money-moving calls — POST /bookings and POST /bookings/{id}/cancel — require an HMAC-SHA256 signature (plus /signature/verify to test it).

X-Api-Timestamp: <unix seconds>
X-Api-Nonce:     <unique per request>
X-Api-Signature: hex( hmac_sha256(canonical, signing_secret) )

canonical = timestamp "\n" METHOD "\n" path "\n" nonce "\n" rawBody
  • path includes the leading slash, no query string (e.g. /api/b2b/v1/bookings).
  • rawBody is the exact JSON bytes you send — sign the string you transmit.
  • Timestamp must be within 5 minutes of server time; each nonce is single-use (replays are rejected).

Dry-run: POST /signature/verify

Validate your signing implementation with no side effects. Note that /signature/verify is itself a signed endpoint — send the same three signature headers you would on a booking, computed over this request's own canonical string (path = /api/b2b/v1/signature/verify). The server recomputes the signature and confirms the match.

POST /signature/verify
Headers: X-Api-Key, X-Api-Timestamp, X-Api-Nonce, X-Api-Signature
{ ...any JSON body (may be empty)... }

→ 200 { "data": { "verified": true,
        "echo": { "method": "POST", "path": "/api/b2b/v1/signature/verify",
                  "timestamp": "...", "nonce": "...", "body_sha256": "..." } } }
Calling /signature/verify — or any signed endpoint — without the three signature headers returns 401 SIGNATURE_MISSING. A bad signature is 401 SIGNATURE_INVALID, a timestamp more than 5 minutes off is 401 SIGNATURE_TIMESTAMP_SKEW, and a reused nonce is 409 SIGNATURE_REPLAY. All are rejected before the request runs, so /signature/verify is the safe place to shake these out.

Idempotency

Booking is safe to retry. Send a unique Idempotency-Key header on POST /bookings.

Idempotency-Key: 2f9c1e77-....-per-booking-attempt
  • First call → 201 and the booking is created.
  • Any retry with the same key200 returning the original booking. You are never charged twice.
  • Reuse the same key only for the same logical booking. Use a fresh key for a new booking.

Rate limits

Per-partner, by tier. Every response carries the current window.

TierRequests / min
1 (default)60
2300
3600
41200
X-RateLimit-Limit: 60     # your tier's per-minute quota
X-RateLimit-Remaining: 57
Retry-After: 12           # present on 429

Over the limit → 429 RATE_LIMIT_EXCEEDED. Back off and honour Retry-After.

Envelope & pagination

Every response — success or error — has the same shape.

{ "data": ... ,
  "meta": { "request_id": "01J...", "timestamp": "2026-08-15T07:57:00+00:00" },
  "errors": [ { "code": "...", "message": "..." } ] }

Quote request_id when contacting support — it identifies the exact call in our logs.

Cursor pagination

List endpoints are cursor-paginated. meta carries the cursors; pass one back as ?cursor=.

"meta": { "per_page": 20, "next_cursor": "eyJpZCI6...", "prev_cursor": null }

GET /products?per_page=20&cursor=eyJpZCI6...

Errors

Errors return the standard envelope with a machine code in errors[].code and a matching HTTP status.

CodeHTTPMeaning
AUTH_UNAUTHORIZED401Bad / missing / IP-blocked key
AUTH_SCOPE_FORBIDDEN403Key lacks the required scope
SIGNATURE_MISSING401A signed endpoint was called without the X-Api-Timestamp/X-Api-Nonce/X-Api-Signature headers
SIGNATURE_INVALID401Signature does not match the canonical string
SIGNATURE_TIMESTAMP_SKEW401Timestamp is more than 5 minutes from server time
SIGNATURE_REPLAY409Nonce already used within the signing window
VALIDATION_FAILED422Bad or missing parameters
RATE_LIMIT_EXCEEDED429Slow down; honour Retry-After
PRODUCT_NOT_FOUND404No such product
SOURCE_UNAVAILABLE503Availability source temporarily down — retry
QUOTE_EXPIRED409Quote expired or already used
INSUFFICIENT_FUNDS402Wallet too low — no money moved
CREDIT_LIMIT_EXCEEDED402Over credit line — no money moved
BOOKING_CONFIRM_FAILED502Supplier rejected at confirm — fully refunded
BOOKING_NOT_CANCELLABLE409Already cancelled or past cut-off — no money moved

Product structure

Every product is organised as Product → Option → Ticket Type. You map these ids to your own catalog, then reference them when quoting and booking.

Product— the attraction/experience  ·  product_id: hsgp_1
└─Option— a variant (e.g. 1-Day Pass)  ·  option_id: opt_60
    └─Ticket Type— the priced unit (ADULT / CHILD)  ·  ticket_type_id: tt_94
LevelRepresentsYou use it to…
ProductThe bookable attraction, tour or ticketList & display; the unit of a catalog page
OptionA bookable variant of the product (duration, package, session)Show the choices for a product/date
Ticket TypeThe smallest priced unit within an option (pax category)Quote & book — you pass ticket_type_id + quantity
Availability tells you which options and ticket types are bookable for a given date — always drive quoting from a live availability response rather than cached ids.

Catalog

GET/productsList active, bookable products. Cursor-paginated.
GET/products/{product_id}A single product with its options.

Query parameters

ParamDescription
countryoptFilter by country, e.g. Singapore
cityoptFilter by city
category_idoptFilter by category
is_bookableopttrue to return only live-bookable products
per_page / cursoroptPagination (see envelope)
curl "https://holidaysg.com/api/b2b/v1/products?country=Singapore&per_page=20" -H "X-Api-Key: <key>"
from_price in the catalog is indicative only and must not be sold on. Always take the real price from a quote.

📅 Availability

GET/products/{id}/availabilityLive bookability for a date, with option / ticket-type detail.
ParamDescription
datereqVisit date, YYYY-MM-DD, today or later
paxoptParty size to check capacity against

Returns the options and their ticket_type_ids that are bookable for that date — this is where you get the ticket_type_id to quote and book (they are not on the catalog/product record). Availability is fetched live from the source at request time — if the source is unreachable you get 503 SOURCE_UNAVAILABLE (retry), never a stale “available”.

A missing or malformed date, or a date the product does not operate on (many run only on certain weekdays), returns 422 VALIDATION_FAILED — not an empty list. Read the operating days from the product before probing, or step the date forward until availability returns available: true.
curl "https://holidaysg.com/api/b2b/v1/products/hsgp_1/availability?date=2026-08-20&pax=2" -H "X-Api-Key: <key>"

💲 Pricing model

Simple and transparent: one currency, your price only. You never see supplier nett cost or the margin — your commission is already baked into what you pay.

FieldMeaning
unit_priceYour all-in price per ticket, in SGD, with your commission applied
sub_totalunit_price × quantity for a line
totalSum of all line sub-totals — the amount debited on booking
currencyAlways SGD

There is no separate tax or FX step — total is exactly what leaves your wallet/credit. Your own selling price and margin to your customer are yours to set on top.

🔒 Quotes

A quote locks the price for 15 minutes and returns a single-use quote_id you pass to booking. This guarantees the price you saw is the price you pay.

POST/quotesPrice-lock a set of ticket types for a date.
POST /quotes
{ "product_id": "hsgp_1",
  "date": "2026-08-20",
  "items": [ { "ticket_type_id": "tt_94", "quantity": 2 } ] }

→ { "data": {
     "quote_id": "q_XXXX", "product_id": "hsgp_1", "date": "2026-08-20", "currency": "SGD",
     "lines": [ { "ticket_type_id":"tt_94", "name":"ADULT", "quantity":2, "unit_price":56.41, "sub_total":112.82 } ],
     "total": 112.82,
     "expires_at": "2026-08-20T07:57:00+00:00" } }
  • A quote is consumed by exactly one successful booking; a used or expired quote → 409 QUOTE_EXPIRED.
  • Re-quote if the customer takes longer than the 15-minute window.

🔁 Booking lifecycle

One call books money-safely. Internally we debit first, then confirm with the supplier, and compensate automatically if anything fails — so you are never left paying for an unconfirmed ticket.

A
reserved
debited, confirming
B
confirmed
ticket being issued
C
cancelled
refunded to source
StatusHTTPMeaning
reservedTransient — payment taken, confirming with supplier
confirmed201Booked & paid; e-ticket being fulfilled
confirmed (replay)200Idempotent retry returned the original booking
pending202Accepted; outcome being reconciled — watch the webhook
failed_pending_refund502Supplier rejected after debit; refund issued back to source
cancelled200Cancelled by you; refunded
Money safety: if confirmation fails or is ambiguous, the debit is reversed automatically. A 402 (insufficient funds / credit) means nothing was ever charged. You can rely on “charged ⇔ confirmed (or refunded)”.

🎫 Create a booking

POST/bookingsSigned + Idempotency-Key. Consumes a quote, charges wallet or credit, books the supplier.
FieldDescription
quote_idreqA live, unused quote
payment_methodoptwallet (default) or credit
customer.namereqLead traveller name
customer.emailreqWhere the e-ticket is sent
POST /bookings
Headers: X-Api-Key, X-Api-Timestamp, X-Api-Nonce, X-Api-Signature, Idempotency-Key
{ "quote_id": "q_XXXX", "payment_method": "wallet",
  "customer": { "name": "Jane Tan", "email": "jane@example.com" } }

→ 201 { "data": { "booking_id": "b_XXXX", "status": "confirmed",
        "total": 112.82, "currency": "SGD",
        "customer": { "name": "Jane Tan", "email": "jane@example.com" },
        "created_at": "2026-08-15T07:57:00+00:00", "note": null } }

See the lifecycle for the 200 / 202 / 402 / 502 paths.

🔎 Retrieve bookings

GET/bookingsYour bookings, newest first. Cursor-paginated.
GET/bookings/{booking_id}A single booking and its current status.
FieldDescription
booking_idYour reference for the booking
statusSee lifecycle statuses
total / currencyAmount charged, in SGD
customer{ name, email }
created_atISO-8601 timestamp

Poll GET /bookings/{id} to follow a 202 pending booking to its terminal state, or rely on the webhook.

Cancel & refunds

POST/bookings/{booking_id}/cancelSigned. Cancels and refunds to the paying account.
  • 200 cancelled — refund returned to the source of payment (wallet, or credit reversed). Idempotent: cancelling an already-cancelled booking returns 200 without a second refund.
  • 409 BOOKING_NOT_CANCELLABLE — already cancelled, or past the supplier cut-off. No money moves.
Refunds always return to the account that paid: wallet bookings refund to the wallet; credit bookings reverse the credit charge. Cut-off windows follow the underlying supplier's policy for that product.

📩 Vouchers & fulfilment

HolidaySG fulfils the ticket — you don't have to.

On confirmed, the e-ticket / voucher is issued by the supplier and delivered by email to the customer.email on the booking. You can:

The booking response returns the booking record (id, status, total, customer) — it does not embed the raw QR/PDF. If your product experience needs to render the voucher in-app, ask your account manager to enable voucher-payload delivery for your key.

🔔 Webhooks

We push booking updates to your endpoint so you don't have to poll. Deliveries retry with backoff and dead-letter after repeated failures.

Event (X-Webhook-Event)Fires when
BOOKING_TRANSACTION_UPDATEA booking is confirmed, refunded, or otherwise changes state

Verifying a webhook

Each POST to your URL carries a signature over the raw request body, keyed by your webhook secret:

X-Webhook-Event:     BOOKING_TRANSACTION_UPDATE
X-Webhook-Signature: base64( hmac_sha256(rawBody, webhook_secret) )

# verify (pseudo)
expected = base64(hmac_sha256(request.rawBody, webhook_secret))
if !constant_time_equal(expected, header["X-Webhook-Signature"]) -> reject 400
  • Respond 2xx quickly to acknowledge; non-2xx (or timeout) triggers retries.
  • Treat delivery as at-least-once — dedupe on the booking id + state.

👛 Wallet & credit

Two ways to settle. Both are debited atomically at booking time.

MethodHow it works
WalletPre-fund by card; each booking debits the balance; refunds credit it back. Too low → 402 INSUFFICIENT_FUNDS (nothing charged).
CreditBook now, settle later against an approved limit; refunds reverse the charge. Over limit → 402 CREDIT_LIMIT_EXCEEDED.

Top-ups are done by card in the partner portal (not via this API — card handling stays on our PCI surface).

📄 Statements

GET/statementsYour monthly statements.
GET/statements/{YYYY-MM}One period: bookings, refunds, credit charged/settled, closing outstanding.

Statements are the source of truth for reconciliation. They are generated at month close and available for every period you were active.

🧪 Sandbox & certification

Build safely on a sandbox key — bookings are simulated (no real inventory, no real money) — then certify to go live.

GET/certificationYour integration checklist and whether the key is sandbox.
{ "data": { "sandbox_key": true, "certified": false, "completed": 4, "total": 7,
    "steps": [ {"key":"auth","done":true}, {"key":"catalog","done":true},
               {"key":"availability","done":true}, {"key":"quote","done":true},
               {"key":"signing","done":false}, {"key":"booking","done":false}, {"key":"cancel","done":false} ] } }
Going live: exercise every step — auth · catalog · availability · quote · signing · booking · cancel. The checklist is computed from your real request log. Once certified is true, your account manager issues a production key (and credit line, if agreed).

📘 Glossary

TermMeaning
ProductA bookable attraction, tour or ticket in the catalog
OptionA variant of a product (duration/package/session)
Ticket typeThe smallest priced unit within an option (pax category)
QuoteA 15-minute, single-use price lock
Nett costSupplier cost — never exposed to partners
CommissionYour margin band, baked into unit_price
Idempotency keyA per-attempt token that makes booking retries safe
NonceA single-use value that stops signed-request replays

Need a key, a higher tier, a credit line, or voucher-payload delivery? Contact your HolidaySG account manager.

Ask me anything!
Luna — HolidaySG

We use cookies

We use cookies to enhance your browsing experience, serve personalised content, and analyse our traffic. By clicking "Accept All", you consent to our use of cookies. Read our Cookie Policy