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.
◉ 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.
| Property | Value |
|---|---|
| Base URL | https://holidaysg.com/api/b2b/v1 |
| Protocol | HTTPS only · TLS 1.2+ |
| Format | JSON request & response (UTF-8) |
| Auth | X-Api-Key header on every request (+ HMAC signature on money-moving calls) |
| Currency | SGD (all prices, one currency — no FX) |
| Versioning | Path-pinned /v1; breaking changes ship as a new version |
➜ API workflow
Five steps from browsing to a confirmed ticket. Each maps to one endpoint.
- Understand product structure and map our products to yours (Product → Option → Ticket Type).
- Get live availability & pricing for a date (availability) then lock the price.
- Provide booking information (quote + customer) to create a paid transaction (book).
- Receive fulfilment — the e-ticket is delivered to the customer and you are notified by webhook.
🚀 Getting access
- Apply for a B2B partner account. Once approved you are placed in a commission group (default 1%).
- We issue a sandbox API key and its signing secret. Store the secret securely — it is shown only once.
- Build against sandbox (no real money or inventory) and complete the certification checklist.
- 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.
| Environment | How | Money & inventory |
|---|---|---|
| Sandbox | Use a sandbox key against the same base URL | Simulated — bookings are recorded but no wallet is charged and no supplier ticket is issued |
| Production | Use a production key | Real — wallet/credit is charged and a real ticket is issued |
🔑 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.
| Condition | Response |
|---|---|
| Missing / invalid / revoked / expired key, or a source IP outside your allowlist | 401 AUTH_UNAUTHORIZED |
| Valid key calling an endpoint outside its scopes | 403 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.
| Scope | Grants |
|---|---|
catalog:read | Products & availability |
quote:write | Create quotes |
booking:read | List & retrieve bookings |
booking:write | Create bookings |
booking:cancel | Cancel bookings |
statement:read | Statements |
✍ 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
pathincludes the leading slash, no query string (e.g./api/b2b/v1/bookings).rawBodyis 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": "..." } } }
/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 →
201and the booking is created. - Any retry with the same key →
200returning 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.
| Tier | Requests / min |
|---|---|
| 1 (default) | 60 |
| 2 | 300 |
| 3 | 600 |
| 4 | 1200 |
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.
| Code | HTTP | Meaning |
|---|---|---|
AUTH_UNAUTHORIZED | 401 | Bad / missing / IP-blocked key |
AUTH_SCOPE_FORBIDDEN | 403 | Key lacks the required scope |
SIGNATURE_MISSING | 401 | A signed endpoint was called without the X-Api-Timestamp/X-Api-Nonce/X-Api-Signature headers |
SIGNATURE_INVALID | 401 | Signature does not match the canonical string |
SIGNATURE_TIMESTAMP_SKEW | 401 | Timestamp is more than 5 minutes from server time |
SIGNATURE_REPLAY | 409 | Nonce already used within the signing window |
VALIDATION_FAILED | 422 | Bad or missing parameters |
RATE_LIMIT_EXCEEDED | 429 | Slow down; honour Retry-After |
PRODUCT_NOT_FOUND | 404 | No such product |
SOURCE_UNAVAILABLE | 503 | Availability source temporarily down — retry |
QUOTE_EXPIRED | 409 | Quote expired or already used |
INSUFFICIENT_FUNDS | 402 | Wallet too low — no money moved |
CREDIT_LIMIT_EXCEEDED | 402 | Over credit line — no money moved |
BOOKING_CONFIRM_FAILED | 502 | Supplier rejected at confirm — fully refunded |
BOOKING_NOT_CANCELLABLE | 409 | Already 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_id: hsgp_1option_id: opt_60ticket_type_id: tt_94| Level | Represents | You use it to… |
|---|---|---|
| Product | The bookable attraction, tour or ticket | List & display; the unit of a catalog page |
| Option | A bookable variant of the product (duration, package, session) | Show the choices for a product/date |
| Ticket Type | The smallest priced unit within an option (pax category) | Quote & book — you pass ticket_type_id + quantity |
▤ Catalog
Query parameters
| Param | Description | |
|---|---|---|
country | opt | Filter by country, e.g. Singapore |
city | opt | Filter by city |
category_id | opt | Filter by category |
is_bookable | opt | true to return only live-bookable products |
per_page / cursor | opt | Pagination (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
| Param | Description | |
|---|---|---|
date | req | Visit date, YYYY-MM-DD, today or later |
pax | opt | Party 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”.
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.
| Field | Meaning |
|---|---|
unit_price | Your all-in price per ticket, in SGD, with your commission applied |
sub_total | unit_price × quantity for a line |
total | Sum of all line sub-totals — the amount debited on booking |
currency | Always 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 /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.
| Status | HTTP | Meaning |
|---|---|---|
| reserved | — | Transient — payment taken, confirming with supplier |
| confirmed | 201 | Booked & paid; e-ticket being fulfilled |
| confirmed (replay) | 200 | Idempotent retry returned the original booking |
| pending | 202 | Accepted; outcome being reconciled — watch the webhook |
| failed_pending_refund | 502 | Supplier rejected after debit; refund issued back to source |
| cancelled | 200 | Cancelled by you; refunded |
402 (insufficient funds / credit) means nothing was ever charged. You can rely on “charged ⇔ confirmed (or refunded)”.🎫 Create a booking
Idempotency-Key. Consumes a quote, charges wallet or credit, books the supplier.| Field | Description | |
|---|---|---|
quote_id | req | A live, unused quote |
payment_method | opt | wallet (default) or credit |
customer.name | req | Lead traveller name |
customer.email | req | Where 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
| Field | Description |
|---|---|
booking_id | Your reference for the booking |
status | See lifecycle statuses |
total / currency | Amount charged, in SGD |
customer | { name, email } |
created_at | ISO-8601 timestamp |
Poll GET /bookings/{id} to follow a 202 pending booking to its terminal state, or rely on the webhook.
↩ Cancel & refunds
- 200
cancelled— refund returned to the source of payment (wallet, or credit reversed). Idempotent: cancelling an already-cancelled booking returns200without a second refund. - 409
BOOKING_NOT_CANCELLABLE— already cancelled, or past the supplier cut-off. No money moves.
📩 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:
- Follow fulfilment via the booking
status(GET /bookings/{id}), and - Receive a webhook when the transaction updates.
🔔 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_UPDATE | A 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
2xxquickly 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.
| Method | How it works |
|---|---|
| Wallet | Pre-fund by card; each booking debits the balance; refunds credit it back. Too low → 402 INSUFFICIENT_FUNDS (nothing charged). |
| Credit | Book 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
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.
{ "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} ] } }
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
| Term | Meaning |
|---|---|
| Product | A bookable attraction, tour or ticket in the catalog |
| Option | A variant of a product (duration/package/session) |
| Ticket type | The smallest priced unit within an option (pax category) |
| Quote | A 15-minute, single-use price lock |
| Nett cost | Supplier cost — never exposed to partners |
| Commission | Your margin band, baked into unit_price |
| Idempotency key | A per-attempt token that makes booking retries safe |
| Nonce | A 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.