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.
Health check
Check service availability (authenticated):
curl https://holidaysg.com/api/b2b/v1/ping -H "X-Api-Key: <your-key>"
→ { "data": { "status": "ok", "service": "HolidaySG B2B API", "version": "v1" } }
Verify your key
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.
- Missing the header →
400 IDEMPOTENCY_KEY_REQUIRED. - Same key with different quote/customer →
409 IDEMPOTENCY_KEY_MISMATCH.
⏱ 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.
A flood guard of 300 requests / minute per source IP also runs before authentication (invalid-key requests count). Tier 3–4 integrations calling from a single IP should contact us to align the per-IP guard with their tier.
✉ 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": 25, "next_cursor": "eyJpZCI6...", "prev_cursor": null }
GET /products?per_page=25&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 |
IDEMPOTENCY_KEY_REQUIRED | 400 | Missing Idempotency-Key header on POST /bookings |
IDEMPOTENCY_KEY_MISMATCH | 409 | Same idempotency key used with different quote or customer |
BOOKING_NOT_FOUND | 404 | No booking matches the given id for this partner |
BOOKING_RESERVE_FAILED | 502 | Upstream source could not reserve — no funds debited |
TRAVELLER_ANSWERS_REQUIRED | 422 | Ticket types require per-traveller details (passport name, gender, DOB) — see availability for required questions |
▦ 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 |
customer.phone | opt | Contact phone number (max 30 chars) |
customer.mobile_code | opt | Country dial code, e.g. +65 (max 10 chars) |
traveller_answers | opt* | Per-traveller details keyed by ticket type, with nested per-pax answers. Required when the product's availability response includes questions (e.g. passport name, gender, DOB). Omitting when required → 422 TRAVELLER_ANSWERS_REQUIRED. |
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" },
"note": null } }
See the lifecycle for the 200 / 202 / 402 / 502 paths.
🔎 Retrieve bookings
Query parameters (listing)
| Param | Description | |
|---|---|---|
per_page | opt | Results per page, 1–100. Default 25. |
cursor | opt | Cursor from a previous response's meta.next_cursor / meta.prev_cursor. |
Response fields
| 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 (listing only — not present on create/show/cancel responses) |
reserved, confirmed, cancelled, and failed_pending_refund. Transient pending bookings are not included — poll GET /bookings/{id} by 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. The listing returns the most recent 36 months.
🧪 Sandbox & certification
Build safely on a sandbox key — bookings are simulated (no real inventory, no real money) — then certify to go live.
| Sandbox key | Production key | |
|---|---|---|
| Bookings | Simulated — confirmed instantly, no supplier ticket issued | Real supplier reservation |
| Money | No wallet charge, no refund — nothing moves | Wallet is debited / refunded |
| Booking ref | Prefixed SBX- | Real supplier reference |
Certification checklist
{ "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).Sandbox test products
The sandbox contains a set of test products designed to cover all integration scenarios. Use these product ids in your sandbox calls:
| Product ID | Name | Scenario |
|---|---|---|
hsgp_test_1 | Test Attraction — Standard | Standard flow: single option, ADULT + CHILD ticket types, always available |
hsgp_test_2 | Test Attraction — Multi-option | Multiple options (Half-Day, Full-Day, VIP), each with different ticket types |
hsgp_test_3 | Test Attraction — Traveller details | Requires per-traveller answers (passport name, gender, DOB) — tests traveller_answers handling |
hsgp_test_4 | Test Attraction — Capacity limited | Max 2 pax per booking — tests capacity validation |
hsgp_test_5 | Test Attraction — Weekday only | Available Mon–Fri only — tests date validation and operating-day handling |
hsgp_test_err | Test Attraction — Error scenarios | Used with special inputs to trigger specific error codes (see test cases below) |
Mandatory test cases
Partners must complete these test scenarios before going live. Progress is tracked automatically from your sandbox API calls.
1 — Happy path: full booking lifecycle
Complete the standard flow end to end with hsgp_test_1.
| Step | Call | Expected result |
|---|---|---|
| 1. Auth | GET /ping or GET /me | 200 with partner details |
| 2. Catalog | GET /products | 200 with product list including hsgp_test_1 |
| 3. Availability | GET /products/hsgp_test_1/availability?date=<tomorrow>&pax=2 | 200 with available: true and at least one ticket_type_id |
| 4. Quote | POST /quotes with the returned ticket_type_id, quantity 2 | 200 with quote_id, total, expires_at ~15 min in the future |
| 5. Signing | POST /signature/verify with valid HMAC headers | 200 with verified: true |
| 6. Book | POST /bookings (signed + Idempotency-Key) with the quote_id | 201 with status: "confirmed", booking_id prefixed SBX- |
| 7. Cancel | POST /bookings/{id}/cancel (signed) | 200 with status: "cancelled" |
2 — Idempotent booking retry
| Step | Call | Expected result |
|---|---|---|
| 1. Book | POST /bookings with a fresh quote + Idempotency-Key: idem-test-1 | 201 confirmed |
| 2. Retry | Repeat the exact same request with the same Idempotency-Key | 200 returning the original booking — no double charge |
3 — Expired quote
| Step | Call | Expected result |
|---|---|---|
| 1. Quote | POST /quotes for hsgp_test_1 | 200 with quote_id |
| 2. Wait | Wait for the quote to expire (15 min), or use quote_id value q_expired_test | — |
| 3. Book | POST /bookings with the expired quote | 409 QUOTE_EXPIRED — no funds moved |
4 — Insufficient funds
| Step | Call | Expected result |
|---|---|---|
| 1. Book | POST /bookings with a valid quote and customer name containing INSUFFICIENT (e.g. "name": "Test INSUFFICIENT") | 402 INSUFFICIENT_FUNDS — no funds moved |
5 — Duplicate idempotency key with different payload
| Step | Call | Expected result |
|---|---|---|
| 1. Book | POST /bookings with Idempotency-Key: idem-conflict-1 and a valid quote | 201 confirmed |
| 2. Mismatch | POST /bookings with same key idem-conflict-1 but a different quote_id | 409 IDEMPOTENCY_KEY_MISMATCH |
6 — Traveller answers required
| Step | Call | Expected result |
|---|---|---|
| 1. Availability | GET /products/hsgp_test_3/availability?date=<tomorrow> | 200 with questions array on ticket types (passport name, gender, DOB) |
| 2. Quote | POST /quotes for hsgp_test_3 | 200 with quote_id |
| 3. Book without answers | POST /bookings without traveller_answers | 422 TRAVELLER_ANSWERS_REQUIRED |
| 4. Book with answers | POST /bookings with valid traveller_answers | 201 confirmed |
7 — Signature validation errors
| Scenario | How to trigger | Expected result |
|---|---|---|
| Missing signature | Call POST /bookings without X-Api-Signature headers | 401 SIGNATURE_MISSING |
| Bad signature | Send a X-Api-Signature computed with the wrong secret | 401 SIGNATURE_INVALID |
| Timestamp skew | Set X-Api-Timestamp to a value >5 minutes in the past | 401 SIGNATURE_TIMESTAMP_SKEW |
| Replay | Re-send the exact same X-Api-Nonce on a second signed request | 409 SIGNATURE_REPLAY |
8 — Cancelling a non-cancellable booking
| Step | Call | Expected result |
|---|---|---|
| 1. Cancel twice | POST /bookings/{id}/cancel on an already-cancelled booking | 200 (idempotent — no second refund) |
| 2. Past cut-off | Cancel a booking with customer name containing NOCANCEL | 409 BOOKING_NOT_CANCELLABLE — no funds moved |
9 — Scope and auth errors
| Scenario | How to trigger | Expected result |
|---|---|---|
| No API key | Call any endpoint without X-Api-Key | 401 AUTH_UNAUTHORIZED |
| Invalid key | Send a made-up key value | 401 AUTH_UNAUTHORIZED |
| Missing scope | Call POST /bookings with a key that only has catalog:read | 403 AUTH_SCOPE_FORBIDDEN |
10 — Validation and not-found errors
| Scenario | How to trigger | Expected result |
|---|---|---|
| Invalid product | GET /products/nonexistent/availability?date=2026-09-30 | 404 PRODUCT_NOT_FOUND |
| Missing date | GET /products/hsgp_test_1/availability (no date param) | 422 VALIDATION_FAILED |
| Past date | GET /products/hsgp_test_1/availability?date=2020-01-01 | 422 VALIDATION_FAILED |
| Unknown booking | GET /bookings/b_nonexistent | 404 BOOKING_NOT_FOUND |
| Missing idempotency key | POST /bookings without Idempotency-Key header | 400 IDEMPOTENCY_KEY_REQUIRED |
GET /certification at any time to see which steps remain. Once all 7 steps pass and your account manager has reviewed your integration, a production key is issued.📘 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.