JetWize Partner API

Sandbox Production v1.0 REST

Integrasikan sistem Anda dengan platform pemesanan tiket penerbangan JetWize. API ini memungkinkan Anda mencari penerbangan, membuat booking, dan memproses pembayaran secara programatik.

Overview

Base URL (Sandbox)https://api.jetwize.com/api/v1/partner/v1
Base URL (Production)https://api.jetwize.com/api/v1/partner/v1
FormatJSON (application/json)
Timeout30 detik

Endpoint sandbox dan production menggunakan URL yang sama. Perbedaannya ditentukan oleh jenis API key yang digunakan (jwz_test_* atau jwz_live_*).

SDK & Tools

Impor koleksi Postman atau spesifikasi OpenAPI, set variabel apiKey ke sandbox key Anda (jwz_test_…), lalu jalankan dari atas ke bawah.

⬇ Postman Collection ⬇ OpenAPI 3.0 (JSON)

Di Postman: Import file koleksi di atas (sudah ada variabel {{baseUrl}} + {{apiKey}}), atau tempel URL partner-openapi.json untuk auto-generate koleksi dari OpenAPI.

Quickstart Sandbox — search → book → pay → e-ticket

Salin-tempel ke terminal. Sandbox mengembalikan inventori mock deterministik dan menyimulasikan pembayaran + penerbitan (tanpa uang/supplier nyata).

KEY=jwz_test_xxx        # sandbox key Anda
BASE=https://api.jetwize.com/api/v1/partner/v1

# 1) Cari penerbangan (sandbox → offer mock)
curl -s -X POST $BASE/flights/search -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"origin":"CGK","destination":"DPS","departDate":"2026-07-15","passengers":{"adult":1,"child":0,"infant":0},"cabinClass":"ECONOMY"}'
# → salin results[0].offerId

# 2) Buat booking
curl -s -X POST $BASE/bookings -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"offerId":"OFFER_ID","contactEmail":"[email protected]","contactPhone":"+628123456789","passengers":[{"title":"MR","firstName":"Budi","lastName":"Santoso","dateOfBirth":"1990-01-15","nationality":"ID","type":"ADULT"}]}'
# → salin id booking

# 3) Bayar (sandbox: disimulasikan + tiket langsung terbit)
curl -s -X POST $BASE/bookings/BOOKING_ID/pay -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{}'

# 4) Ambil e-ticket
curl -s $BASE/bookings/BOOKING_ID/ticket -H "X-API-Key: $KEY"

Belum punya sandbox key? Buat di portal JetWize → API Keys → "Minta API Key" (sandbox instan), atau pakai Playground untuk menjalankan request ini langsung dari dashboard.

Environments

🧪 Sandbox (jwz_test_...)

Production (jwz_live_...)
Semua transaksi nyata — menggunakan supplier flight aktif, debit wallet nyata. Pastikan integrasi sudah teruji di sandbox sebelum switch ke production.

Authentication

Semua request ke Partner API memerlukan API key. Gunakan salah satu metode berikut:

Option 1 — Header X-API-Key (Recommended)
X-API-Key: jwz_test_a1b2c3d4e5f6...
Option 2 — Authorization Bearer
Authorization: Bearer jwz_test_a1b2c3d4e5f6...

Mendapatkan API Key

API key dikelola melalui dashboard JetWize oleh admin agensi Anda:

  1. Login ke jetwize.com
  2. Buka Settings → API Keys
  3. Klik Buat API Key Baru
  4. Pilih environment: Sandbox atau Production
⚠️ Jaga kerahasiaan API key Anda. Jangan expose di frontend/client-side code atau commit ke repository. Gunakan environment variables.

Common Errors

Semua error mengembalikan format JSON yang konsisten:

{
  "error": {
    "code": "BOOKING_EXPIRED",
    "message": "Booking hold telah kadaluarsa",
    "details": { "bookingId": "..." },
    "traceId": "req-abc123"
  }
}

Tabel di bawah ini berisi error yang bisa terjadi di endpoint mana pun (auth, validasi, server-side). Untuk error spesifik per-endpoint, lihat bagian Possible Errors di setiap endpoint.

HTTP StatusError CodeKeterangan
401MISSING_API_KEYHeader X-API-Key tidak ada
401INVALID_API_KEYAPI key tidak valid atau dinonaktifkan
401EXPIRED_API_KEYAPI key sudah kadaluarsa
401AGENCY_INACTIVEAgensi tidak aktif / suspended
403FORBIDDENAPI key tidak punya hak akses ke resource ini
500INTERNAL_ERRORServer error, retry dengan exponential backoff
503SERVICE_UNAVAILABLEService sementara tidak tersedia (maintenance)

GET /me — Info API Key

GET /partner/v1/me Info API key, agensi, saldo wallet, dan limit kredit
Response
{
  "apiKey": {
    "id": "clx123...",
    "name": "My Integration",
    "environment": "SANDBOX"
  },
  "agency": {
    "id": "clx456...",
    "name": "PT Maju Travel"
  },
  "wallet": {
    "balance": 999999999,
    "holdBalance": 0,
    "currency": "IDR"
  },
  "credit": {
    "enabled": false,
    "limit": 0,
    "used": 0,
    "available": 0
  },
  "sandbox": true
}

credit menampilkan limit kredit postpaid agensi (available = limit − used).

Cari penerbangan tersedia berdasarkan rute, tanggal, dan jumlah penumpang. Endpoint ini di-aggregate dari semua supplier maskapai aktif dan return list offer yang siap di-booking.

POST /partner/v1/flights/search Cari penerbangan tersedia

Request Body

FieldTypeKeterangan
origin*stringKode IATA bandara asal. Contoh: CGK
destination*stringKode IATA bandara tujuan. Contoh: DPS
departDate*stringTanggal berangkat format YYYY-MM-DD
returnDatestringTanggal kembali (untuk pulang-pergi)
passengers*object{ adult, child, infant }
cabinClassstringECONOMY / BUSINESS / FIRST. Default: ECONOMY
Request Example
POST /api/v1/partner/v1/flights/search
X-API-Key: jwz_test_...

{
  "origin": "CGK",
  "destination": "DPS",
  "departDate": "2026-06-15",
  "passengers": { "adult": 2, "child": 0, "infant": 0 },
  "cabinClass": "ECONOMY"
}
Response
{
  "results": [
    {
      "id": "off_5vT2kQx9Jd4mZ0aBc1De2Fg3Hh4Ii5Jj6",
      "segments": [
        {
          "flightNumber": "GA405",
          "carrier": "GA",
          "carrierName": "Garuda Indonesia",
          "origin": "CGK",
          "destination": "DPS",
          "departureTime": "2026-06-15T06:00:00.000Z",
          "arrivalTime": "2026-06-15T08:55:00.000Z",
          "duration": 115,
          "cabinClass": "ECONOMY",
          "baggageAllowance": "20kg"
        }
      ],
      "stops": 0,
      "totalDuration": 115,
      "basePrice": 850000,
      "tax": 93500,
      "totalPrice": 943500,
      "currency": "IDR",
      "seatsAvailable": 9
    }
  ],
  "count": 5,
  "sandbox": true
}
Possible Errors — Flight Search
HTTPError CodeKeterangan
400INVALID_QUERYOrigin / destination / departDate format salah atau tidak ada
400INVALID_DATETanggal berangkat di masa lalu, atau returnDate < departDate
400INVALID_ROUTEOrigin = destination, atau IATA code tidak dikenali
400INVALID_PASSENGER_COUNTTotal pax 0, atau infant > adult (rule lap-infant)
404NO_FARE_FOUNDTidak ada fare di rute+tanggal ini dari supplier aktif
502SUPPLIER_ERRORSemua supplier down — coba lagi nanti
503SUPPLIER_UNAVAILABLESebagian supplier rate-limited (LTB / quota circuit open)

Airport Search

Autocomplete bandara/kota berdasarkan kode IATA, nama bandara, kota, atau negara. Berguna untuk membangun input pencarian penerbangan.

GET /partner/v1/airports?q={query}&limit={n} Cari bandara / kota

Query Parameters

FieldTypeKeterangan
q*stringKata kunci: kode IATA, nama bandara, kota, atau negara. Contoh: DPS, Jakarta, bali
limitnumberMaksimum hasil (1–50). Default 20
Request Example
GET /api/v1/partner/v1/airports?q=DPS&limit=10
X-API-Key: jwz_test_...
Response
{
  "query": "DPS",
  "count": 1,
  "airports": [
    {
      "code": "DPS",
      "name": "Ngurah Rai International",
      "city": "Bali",
      "country": "Indonesia",
      "countryCode": "ID",
      "region": "Jawa"
    }
  ]
}

Price Check

Verifikasi ulang harga + availability offer sebelum buat booking. Wajib dipanggil antara flights/search dan POST /bookings — harga supplier bisa berubah dalam hitungan menit. Kalau priceChanged: true, tampilkan harga baru ke user untuk konfirmasi sebelum booking.

POST /partner/v1/flights/price-check Verifikasi harga sebelum booking

Request Body

FieldTypeKeterangan
offerId*stringID offer dari hasil /flights/search
Request Example
POST /api/v1/partner/v1/flights/price-check
X-API-Key: jwz_test_...

{ "offerId": "off_5vT2kQx9Jd4mZ0aBc1De2Fg3Hh4Ii5Jj6" }
Response
{
  "offerId": "off_5vT2kQx9Jd4mZ0aBc1De2Fg3Hh4Ii5Jj6",
  "priceChanged": false,
  "previousPrice": null,
  "currentPrice": 943500,
  "currency": "IDR",
  "stillAvailable": true
}

Response Fields

FieldTypeKeterangan
priceChangedbooleantrue kalau harga supplier berubah sejak search
previousPricenumber / nullHarga lama (kalau berubah)
currentPricenumberHarga terkini, gunakan ini untuk booking
stillAvailablebooleanfalse = seat habis / offer kadaluarsa, jangan lanjut booking
Possible Errors — Price Check
HTTPError CodeKeterangan
400INVALID_OFFER_IDToken offer tidak valid / kedaluwarsa — lakukan search ulang
404OFFER_NOT_FOUNDOffer expired / tidak ada di cache — re-search dulu
410OFFER_EXPIREDCache TTL 5 menit terlewati — re-search dulu
502SUPPLIER_ERRORSupplier verify endpoint gagal — retry atau re-search

Fare Rules

Ambil status refundable, fare brand, dan alokasi bagasi per segmen untuk satu offer — tampilkan ke pelanggan sebelum booking. Offer kedaluwarsa ~30 menit setelah search.

GET /partner/v1/flights/:offerId/fare-rules Aturan tarif & bagasi offer
Response
{
  "offerId": "off_5vT2kQx9Jd4mZ0aBc1De2Fg3Hh4Ii5Jj6",
  "fareBrand": "Economy Basic",
  "refundable": false,
  "currency": "IDR",
  "expiresAt": "2026-06-15T07:30:00.000Z",
  "baggage": [
    {
      "segment": "CGK-DPS",
      "flightNo": "GA405",
      "cabinClass": "ECONOMY",
      "allowance": "20kg"
    }
  ]
}
Possible Errors — Fare Rules
HTTPError CodeKeterangan
400OFFER_NOT_FOUNDOffer expired / tidak ada di cache — re-search dulu

Bookings

POST /partner/v1/bookings Buat booking baru (hold 30 menit)

Request Body

FieldTypeKeterangan
offerId*stringID offer dari hasil search
passengers*arrayData penumpang (lihat di bawah)
contactPhone*stringNomor telepon kontak, 7–20 karakter (flat field, bukan nested)
contactEmailstringEmail kontak (opsional, flat field)

Passenger Object

FieldTypeKeterangan
title*stringMR / MRS / MS / MSTR
firstName*stringNama depan (huruf kapital, sesuai paspor)
lastName*stringNama belakang
dateOfBirth*stringFormat YYYY-MM-DD
nationality*stringKode negara ISO-2. Contoh: ID
passportNostringNomor paspor (wajib untuk penerbangan internasional)
passportExpirystringTanggal kadaluarsa paspor YYYY-MM-DD
type*stringADULT / CHILD / INFANT
Request Example
POST /api/v1/partner/v1/bookings
X-API-Key: jwz_test_...

{
  "offerId": "off_5vT2kQx9Jd4mZ0aBc1De2Fg3Hh4Ii5Jj6",
  "passengers": [
    {
      "title": "MR",
      "firstName": "BUDI",
      "lastName": "SANTOSO",
      "dateOfBirth": "1990-01-15",
      "nationality": "ID",
      "passportNo": "A1234567",
      "passportExpiry": "2030-01-01",
      "type": "ADULT"
    }
  ],
  "contactPhone": "+6281234567890",
  "contactEmail": "[email protected]"
}
Response
{
  "id": "clx789...",
  "bookingCode": "JWZ-20260615-ABC123",
  "status": "PENDING_PAYMENT",
  "expiresAt": "2026-06-15T07:30:00.000Z",
  "totalAmount": 943500,
  "currency": "IDR"
}
Possible Errors — Create Booking
HTTPError CodeKeterangan
400INVALID_PASSENGER_DATAField passenger missing/invalid (mis. passportExpiry < depart date)
400PASSENGER_COUNT_MISMATCHJumlah passengers tidak match dengan yang di offer
400PRICE_CHANGEDHarga supplier berubah — re-run price-check dulu
400SINGLE_NAME_NOT_ALLOWEDMaskapai ini tidak menerima penumpang mononym (single-name)
404OFFER_NOT_FOUNDofferId expired — re-search dulu
409SEATS_UNAVAILABLEKursi habis sejak search
502SUPPLIER_PNR_FAILEDSupplier reject PNR creation
GET /partner/v1/bookings/:id Detail booking
Possible Errors — Get Booking
HTTPError CodeKeterangan
404BOOKING_NOT_FOUNDBooking ID tidak ada / sudah dihapus
403BOOKING_ACCESS_DENIEDBooking milik agensi lain
GET /partner/v1/bookings List booking agensi (dengan pagination + filter)

Query Parameters

ParamTypeKeterangan
statusstringFilter: PENDING_PAYMENT, PAID, ISSUED, CANCELLED, REFUNDED
cursorstringPagination cursor dari response sebelumnya
limitnumberDefault 20, max 100
Possible Errors — List Bookings
HTTPError CodeKeterangan
400INVALID_CURSORCursor format invalid atau kadaluarsa
400INVALID_STATUS_FILTERStatus filter tidak dikenali
POST /partner/v1/bookings/:id/pay Bayar dengan saldo wallet

Request Body

FieldTypeKeterangan
gateway*stringHanya WALLET di-support untuk partner API
Possible Errors — Pay Booking
HTTPError CodeKeterangan
400BOOKING_EXPIREDHold booking sudah habis (30 menit) — buat booking baru
400INVALID_STATUSBooking bukan status PENDING_PAYMENT
402INSUFFICIENT_BALANCESaldo wallet kurang dari totalAmount
409ALREADY_PAIDBooking sudah ke-bayar (idempotency)
502SUPPLIER_TICKETING_FAILEDPay ok di wallet tapi supplier issue gagal — refund otomatis dijalankan
POST /partner/v1/bookings/:id/cancel Batalkan booking (sebelum tiket terbit)
Possible Errors — Cancel Booking
HTTPError CodeKeterangan
400CANNOT_CANCELBooking sudah ISSUED — pakai refund flow, bukan cancel
409ALREADY_CANCELLEDBooking sudah dibatalkan sebelumnya
502SUPPLIER_CANCEL_FAILEDSupplier menolak cancel — hubungi support

Refund (booking yang sudah ISSUED)

Untuk booking berstatus ISSUED. Cek estimasi dulu via refund-quote, lalu ajukan via refund. Hanya booking milik agensi Anda. Sebagian supplier memproses refund secara async — konfirmasi akhir lewat webhook booking.refunded.

GET /partner/v1/bookings/:id/refund-quote Estimasi refund (refundable? nilai maksimum)
Response
{
  "bookingId": "clx789...",
  "bookingCode": "JWZ-20260615-ABC123",
  "status": "ISSUED",
  "refundable": true,
  "currency": "IDR",
  "maxRefundAmount": 943500,
  "note": "Estimasi maksimum. Nilai final dikurangi biaya sesuai aturan tarif maskapai/supplier."
}
POST /partner/v1/bookings/:id/refund Ajukan refund booking issued

Request Body

FieldTypeKeterangan
reason*stringAlasan refund, minimal 10 karakter
amountnumberOpsional. Refund parsial; default seluruh totalAmount
Request Example
POST /api/v1/partner/v1/bookings/clx789.../refund
X-API-Key: jwz_live_...

{ "reason": "Pembatalan oleh penumpang karena perubahan jadwal" }
Response
{
  "bookingId": "clx789...",
  "status": "ISSUED",
  "message": "Refund request submitted to supplier. Confirmation will arrive via webhook.",
  "refundId": "rfnd_..."
}
Possible Errors — Refund
HTTPError CodeKeterangan
400BOOKING_NOT_FOUNDBooking tidak ada atau bukan milik agensi Anda
400REASON_REQUIREDAlasan refund kurang dari 10 karakter
409NOT_ISSUEDHanya booking ISSUED yang bisa di-refund (pre-issue pakai cancel)
GET /partner/v1/bookings/:id/ticket URL download e-ticket (signed, berlaku 15 menit)
Response
{
  "url": "https://storage.jetwize.com/eticket/...?signature=...&expires=...",
  "expiresAt": "2026-06-15T12:15:00.000Z"
}
Possible Errors — Get E-Ticket
HTTPError CodeKeterangan
400TICKET_NOT_ISSUEDBooking belum status ISSUED — pay dulu
404TICKET_NOT_FOUNDE-ticket PDF belum di-generate (job queue masih running)

Ancillaries

Tambahkan bagasi, makanan, atau kursi ke booking yang sudah terbit (status ISSUED). Ketersediaan tergantung dukungan maskapai/supplier — bila tidak didukung, respons katalog mengembalikan supported: false beserta info maskapai; tampilkan fallback ke pengguna Anda dan arahkan ke call center maskapai. Semua harga di-reprice di server dari katalog — harga yang Anda kirim diabaikan.

GET /partner/v1/bookings/:id/ancillaries/catalog Katalog ancillary yang bisa dibeli (bagasi & makanan)

Query Parameters

ParamTypeKeterangan
kindstringFilter: BAGGAGE / MEAL / SEAT. Kosongkan untuk semua.
Response
{
  "supported": true,
  "airline": { "code": "JT", "name": "Lion Air" },
  "reason": null,
  "options": [
    { "id": "BAG-20", "kind": "BAGGAGE", "label": "Bagasi tambahan 20kg",
      "price": 290000, "currency": "IDR", "scope": "PER_SEGMENT", "weightKg": 20 },
    { "id": "MEAL-VGML", "kind": "MEAL", "label": "Vegetarian (VGML)",
      "price": 65000, "currency": "IDR", "scope": "PER_JOURNEY", "mealCode": "VGML" },
    { "id": "MEAL-MOML", "kind": "MEAL", "label": "Moslem Meal (MOML)",
      "price": 0, "currency": "IDR", "scope": "PER_JOURNEY", "mealCode": "MOML" }
  ]
}

price: 0 berarti gratis (complimentary). Bila maskapai belum mendukung: { "supported": false, "reason": "UNSUPPORTED", "airline": { ... }, "options": [] }reason dapat berupa UNSUPPORTED atau NOT_ISSUED.

Pemilihan Kursi (Seat Booking)

Alur pesan kursi untuk booking yang sudah ISSUED:

  1. GET /bookings/:id/seatmap — ambil peta kursi (kursi AVAILABLE/OCCUPIED + harga).
  2. Bentuk optionId = SEAT-<seatNumber> (mis. kursi 12ASEAT-12A). Alternatif: ambil dari GET /ancillaries/catalog?kind=SEAT.
  3. POST /bookings/:id/ancillaries dengan item { optionId: "SEAT-12A", kind: "SEAT", passengerId } — satu kursi per penumpang; harga di-reprice di server lalu debit wallet/kredit.

Bila maskapai belum mendukung pemilihan kursi, seatmap mengembalikan supported:false (reason UNSUPPORTED/NOT_ISSUED) — tampilkan fallback.

GET /partner/v1/bookings/:id/seatmap Peta kursi pesawat untuk pemilihan kursi
Response
{
  "supported": true,
  "columns": ["A", "B", "C", "", "D", "E", "F"],
  "rows": [
    { "row": 1, "seats": [
      { "seatNumber": "1A", "status": "AVAILABLE", "exitRow": false, "price": 100000, "currency": "IDR" },
      { "seatNumber": "1B", "status": "OCCUPIED",  "exitRow": false, "price": 100000 }
    ]}
  ],
  "currentSeats": [ { "passengerId": "clxpax1...", "seatNumber": null } ]
}

price > 0 = kursi berbayar (preferred/exit row). Kursi yang sudah dimiliki penumpang pada booking ini ditandai OCCUPIED. Bila tidak didukung maskapai, tampilkan "Seat selection not available for this flight".

POST /partner/v1/bookings/:id/ancillaries Beli ancillary pada booking ISSUED (debit wallet real-time)

Request Body

FieldTypeKeterangan
paymentstringWALLET (default) / CREDIT (Kredit Tagihan, bila aktif)
items*arrayDaftar item (lihat di bawah), maks 50

Item Object

FieldTypeKeterangan
optionId*stringDari katalog (BAG-20, MEAL-VGML) atau kursi: SEAT-12A
kind*stringBAGGAGE / MEAL / SEAT
passengerId*stringID penumpang dari detail booking (satu kursi per penumpang)
segmentIndexnumberIndeks segmen (default 0)
Request Example
POST /api/v1/partner/v1/bookings/clx789.../ancillaries
X-API-Key: jwz_live_...
Idempotency-Key: 9f1c2e58-...

{
  "payment": "WALLET",
  "items": [
    { "optionId": "BAG-20",    "kind": "BAGGAGE", "passengerId": "clxpax1..." },
    { "optionId": "MEAL-VGML", "kind": "MEAL",    "passengerId": "clxpax1..." },
    { "optionId": "SEAT-12A",  "kind": "SEAT",    "passengerId": "clxpax1..." }
  ]
}
Response
{
  "purchaseId": "9b2f...",
  "total": 505000,
  "currency": "IDR",
  "payment": "WALLET",
  "items": [
    { "id": "anc1...", "kind": "BAGGAGE", "label": "Bagasi tambahan 20kg", "unitPrice": 290000, "ssrCode": "XBAG 20KG" },
    { "id": "anc2...", "kind": "MEAL",    "label": "Vegetarian (VGML)",    "unitPrice": 65000,  "ssrCode": "VGML" },
    { "id": "anc3...", "kind": "SEAT",    "label": "Kursi 12A",            "unitPrice": 150000, "ssrCode": "RQST 12A" }
  ]
}
Possible Errors — Purchase Ancillaries
HTTPError CodeKeterangan
409INVALID_STATUSBooking belum ISSUED
422ANCILLARY_NOT_SUPPORTEDMaskapai belum mendukung — details.airline berisi info maskapai
400ANCILLARY_OPTION_NOT_FOUNDoptionId tidak ada di katalog
409SEAT_UNAVAILABLEKursi sudah terisi — muat ulang seat map
400SEAT_PER_PAXLebih dari satu kursi untuk penumpang yang sama
400INSUFFICIENT_BALANCESaldo wallet tidak cukup
502ANCILLARY_SUPPLIER_FAILEDMaskapai menolak — dana otomatis dikembalikan ke wallet

Sandbox: di environment sandbox, pembelian disimulasikan (tidak ada debit nyata) dan respons berisi { "sandbox": true }. Idempotency: kirim header Idempotency-Key — retry dengan key yang sama mengembalikan respons yang sama tanpa debit ganda.

GET /partner/v1/bookings/:id/ancillaries Daftar ancillary yang sudah dibeli pada booking
Response
{
  "data": [
    { "id": "anc1...", "kind": "BAGGAGE", "label": "Bagasi tambahan 20kg",
      "passengerId": "clxpax1...", "unitPrice": "290000", "currency": "IDR",
      "ssrCode": "XBAG 20KG", "postBooking": true, "createdAt": "2026-06-11T01:30:00.000Z" }
  ]
}

Reschedule

Pindahkan penumpang ke penerbangan lain pada rute yang sama (tanggal baru). Sistem menagih max(0, selisih tarif) + biaya ubah sesuai fare rules, lalu membatalkan tiket lama dan menerbitkan PNR baru. Perubahan tidak diizinkan dalam 24 jam sebelum keberangkatan. Bila tarif baru lebih murah, selisihnya tidak dikembalikan — hanya biaya ubah yang ditagih.

GET /partner/v1/bookings/:id/reschedule/options Penerbangan pengganti untuk tanggal baru

Query Parameters

ParamTypeKeterangan
date*stringTanggal keberangkatan baru YYYY-MM-DD (min H+24)
Response
{
  "supported": true,
  "airline": { "code": "JT", "name": "Lion Air" },
  "reason": null,
  "options": [
    { "id": "RSC-ABC123-2026-07-01-0", "flightNumber": "JT370",
      "origin": "BTH", "destination": "CGK",
      "departureTime": "2026-07-01T06:00:00.000Z", "arrivalTime": "2026-07-01T07:45:00.000Z",
      "fareDifference": 120000, "changeFee": 250000,
      "currency": "IDR", "seatsAvailable": 5 }
  ]
}

fareDifference negatif = penerbangan baru lebih murah (selisih tidak direfund). reason bila supported:false: WINDOW_CLOSED (<24 jam sebelum berangkat), UNSUPPORTED (maskapai belum mendukung), atau NOT_ISSUED.

POST /partner/v1/bookings/:id/reschedule Eksekusi: void tiket lama, terbitkan PNR baru

Request Body

FieldTypeKeterangan
optionId*stringDari reschedule/options
date*stringTanggal yang sama dengan saat mengambil options
paymentstringWALLET (default) / CREDIT
Request Example
POST /api/v1/partner/v1/bookings/clx789.../reschedule
X-API-Key: jwz_live_...
Idempotency-Key: 4d8a31c0-...

{
  "optionId": "RSC-ABC123-2026-07-01-0",
  "date": "2026-07-01",
  "payment": "WALLET"
}
Response
{
  "oldPnr": "ABC123",
  "newPnr": "XYZ789",
  "flightNumber": "JT370",
  "departureTime": "2026-07-01T06:00:00.000Z",
  "arrivalTime": "2026-07-01T07:45:00.000Z",
  "fareDifference": 120000,
  "changeFee": 250000,
  "amountCharged": 370000,
  "currency": "IDR"
}
Possible Errors — Reschedule
HTTPError CodeKeterangan
409RESCHEDULE_WINDOW_CLOSEDPerubahan tidak diizinkan dalam 24 jam keberangkatan
409INVALID_STATUSBooking belum ISSUED
422RESCHEDULE_NOT_SUPPORTEDMaskapai belum mendukung jadwal ulang via API
400RESCHEDULE_OPTION_NOT_FOUNDoptionId kedaluwarsa — muat ulang options
400INSUFFICIENT_BALANCESaldo wallet tidak cukup
502RESCHEDULE_SUPPLIER_FAILEDMaskapai menolak — dana otomatis dikembalikan

Sandbox: disimulasikan, respons berisi { "sandbox": true } tanpa perubahan nyata. Setelah sukses, ambil ulang GET /bookings/:id — PNR dan jadwal sudah diperbarui, status tetap ISSUED sehingga ancillary/reschedule berikutnya tetap tersedia.

Auto-Rebook

Pertahankan booking yang masih ditahan (PENDING_PAYMENT) secara otomatis: sistem memperpanjang hold ke supplier sebelum kedaluwarsa — cek ketersediaan + harga, lalu cancel hold lama dan rebook yang baru tiap siklus — hingga batas maksimum (default 24 jam). Fitur ini tidak menerbitkan tiket dan tidak menarik pembayaran; hanya menjaga hold tetap hidup.

Opt-in per booking, dan harus diizinkan per supplier oleh operator. Di lingkungan SANDBOX siklus berjalan di supplier mock (aman, tanpa inventory nyata). Bila harga naik melebihi ambang yang Anda tetapkan, siklus PAUSE menunggu persetujuan.

POST /partner/v1/bookings/:id/auto-rebook/enable Aktifkan siklus auto-rebook

Request Body (opsional)

FieldTypeKeterangan
maxHoldMinutesintegerTotal jendela tahan, 60–2880 menit (1–48 jam). Default 1440 (24 jam).
priceThresholdPctnumberJika harga renewal naik > persen ini, siklus PAUSE menunggu persetujuan. Kosongkan = tidak pernah pause.
Request Example
POST /api/v1/partner/v1/bookings/clx789.../auto-rebook/enable
X-API-Key: jwz_live_...

{
  "maxHoldMinutes": 1440,
  "priceThresholdPct": 10
}
Response
{ "status": "ACTIVE", "cycleId": "clxcyc...", "nextAttemptAt": "2026-06-15T05:25:00.000Z" }
GET /partner/v1/bookings/:id/auto-rebook/history Status siklus + riwayat percobaan (snapshot harga)
Response
{
  "cycle": {
    "status": "ACTIVE",
    "maxHoldMinutes": 1440,
    "priceThresholdPct": 10,
    "expiresAt": "2026-06-16T04:00:00.000Z",
    "nextAttemptAt": "2026-06-15T05:25:00.000Z",
    "attemptCount": 1,
    "lastPrice": 1500000
  },
  "attempts": [
    { "attemptNumber": 1, "attemptedAt": "2026-06-15T04:55:00.000Z", "result": "SUCCESS",
      "prevBookingRef": "BKG-AB12CD", "newBookingRef": "BKG-EF34GH",
      "prevPrice": 1500000, "newPrice": 1500000, "priceDelta": 0, "priceDeltaPct": 0 }
  ]
}

result tiap percobaan: SUCCESS, SEAT_UNAVAILABLE (kursi habis → hold dibiarkan kedaluwarsa, booking jadi HOLD_EXPIRED), PRICE_THRESHOLD_EXCEEDED (siklus PAUSE), API_ERROR (gangguan sementara, dicoba lagi), atau HOLD_LOST (cancel berhasil tapi rebook gagal — booking HOLD_LOST, perlu pesan ulang manual).

POST /partner/v1/bookings/:id/auto-rebook/approve Setujui harga & lanjutkan siklus yang PAUSED

Dipakai saat siklus PAUSED karena kenaikan harga melebihi priceThresholdPct. Mengembalikan { "status": "ACTIVE", "nextAttemptAt": "..." }.

POST /partner/v1/bookings/:id/auto-rebook/disable Hentikan siklus (hold saat ini dipertahankan)

Mengembalikan { "status": "STOPPED", "bookingId": "...", "lastAttemptAt": "..." }.

Possible Errors — Auto-Rebook
HTTPError CodeKeterangan
409AUTO_REBOOK_NOT_ELIGIBLEBooking tidak PENDING_PAYMENT (sudah dibayar/dibatalkan/terbit)
409AUTO_REBOOK_ALREADY_ACTIVESiklus sudah aktif untuk booking ini
409AUTO_REBOOK_SUPPLIER_DISABLEDAuto-rebook belum diaktifkan untuk supplier booking ini
409AUTO_REBOOK_NOT_ACTIVEdisable: tidak ada siklus aktif/paused
409AUTO_REBOOK_NOT_PAUSEDapprove: siklus tidak sedang menunggu persetujuan harga

Wallet

Saldo deposit + limit kredit agensi: balance = saldo deposit, credit.available = sisa kredit (limit − used).

GET /partner/v1/wallet Saldo wallet & limit kredit agensi
{
  "balance": 5000000,
  "holdBalance": 943500,
  "currency": "IDR",
  "credit": {
    "enabled": true,
    "limit": 50000000,
    "used": 12000000,
    "available": 38000000
  },
  "sandbox": false
}
Possible Errors — Wallet
HTTPError CodeKeterangan
404WALLET_NOT_FOUNDAgensi belum punya wallet — hubungi admin

Booking Flow

0. GET  /airports?q=...                → (opsional) autocomplete bandara untuk input
1. POST /flights/search                → Dapat daftar offer + offerId
2. GET  /flights/:offerId/fare-rules   → (opsional) tampilkan bagasi & refundable
3. POST /flights/price-check           → Verifikasi harga masih valid
4. POST /bookings                      → Buat booking (hold 30 menit)
   └─ Response: bookingId, expiresAt, totalAmount
5. GET  /wallet                        → Cek saldo / sisa kredit cukup
6. POST /bookings/:id/pay              → Bayar dengan wallet
   └─ Jika bayar dari luar → topup wallet dulu via dashboard
7. GET  /bookings/:id                  → Poll status (PAID → ISSUED)
8. GET  /bookings/:id/ticket           → Download e-ticket

Pasca-issue (opsional):
   GET  /bookings/:id/refund-quote     → Estimasi refund
   POST /bookings/:id/refund           → Ajukan refund (konfirmasi via webhook)
💡 Tip: Booking expire dalam 30 menit. Jika expire sebelum dibayar, ulangi dari step 1.

Skenario Booking (8 Kasus)

Panduan langkah demi langkah untuk membuat booking lewat Partner API untuk 8 skenario: OW/RT × Direct/Transfer × dengan/tanpa bagasi.

#JenisBagasi
1One-Way (OW) DirectDengan bagasi
2One-Way (OW) DirectTanpa bagasi
3Round-Trip (RT) DirectDengan bagasi
4Round-Trip (RT) DirectTanpa bagasi
5One-Way (OW) Transfer (transit)Dengan bagasi
6One-Way (OW) Transfer (transit)Tanpa bagasi
7Round-Trip (RT) Transfer (transit)Dengan bagasi
8Round-Trip (RT) Transfer (transit)Tanpa bagasi
Ke-8 skenario memakai endpoint dan payload yang sama. Yang membedakan hanya parameter search dan offer mana yang Anda pilih dari hasil pencarian. Langkah create → pay → e-ticket identik untuk semuanya.

Selain 8 skenario bentuk-offer di atas, ada 4 skenario operasional (9–12) di bagian Skenario Lanjutan di bawah: multi-penumpang, internasional (paspor), cancel & refund, dan ancillary.

Konektivitas ke sistem JetWize

Partner API sudah tersambung penuh ke inti JetWize secara code (bukan mock/stub):

Cara mengenali jenis offer

Setiap hasil search adalah sebuah FlightOffer. Tiga sifat berikut menentukan skenario:

💡 Offer id bersifat opaque. Nilai id berupa token off_… yang menyembunyikan supplier. Perlakukan sebagai string buram — simpan & kirim balik apa adanya pada price-check/booking; jangan mem-parsing prefiksnya.

1. Direct vs Transfer — pakai stops / segments

// DIRECT (nonstop): satu segmen, stops = 0
{ "stops": 0, "segments": [ { "origin": "CGK", "destination": "DPS", ... } ] }

// TRANSFER (transit): >1 segmen, stops = segments.length - 1
{ "stops": 1, "segments": [
    { "origin": "CGK", "destination": "SUB", ... },   // leg 1
    { "origin": "SUB", "destination": "DPS", ... }     // leg 2 (transit di SUB)
] }

2. Dengan vs Tanpa bagasi — pakai branded fare

Bagasi di JetWize adalah bagian dari fare brand, bukan add-on saat search. Satu penerbangan yang sama bisa muncul sebagai beberapa offer (masing-masing offerId berbeda) yang berbeda fareBrand dan segments[].baggageAllowance:

// offer "tanpa bagasi"
{ "id": "off_9pR3wLm4Xa7bY1cV2dU8eT0fS6gQ5hP2j", "fareBrand": "Economy Basic",
  "segments": [ { "baggageAllowance": "Tanpa bagasi (kabin 7kg)", ... } ] }

// offer "dengan bagasi" (penerbangan sama, brand beda)
{ "id": "off_5vT2kQx9Jd4mZ0aBc1De2Fg3Hh4Ii5Jj6", "fareBrand": "Economy Value",
  "segments": [ { "baggageAllowance": "20kg", ... } ] }
💡 Butuh bagasi ekstra setelah issued? Itu jalur ancillary: POST /bookings/:id/ancillaries dengan kind: "BAGGAGE" (dihargai ulang oleh server).

3. One-Way vs Round-Trip — pakai returnDate

// Round-trip DIRECT: 2 segmen (outbound + return), stops tetap 0 per arah
{ "segments": [
    { "origin": "CGK", "destination": "DPS", ... },   // outbound
    { "origin": "DPS", "destination": "CGK", ... }     // return
] }

Alur Umum Booking

Berlaku untuk semua skenario. Tiap skenario di bawah hanya mengganti langkah search + pemilihan offer.

Langkah 1 — Search

curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/flights/search \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -d '{ "origin":"CGK", "destination":"DPS", "departDate":"2026-07-15",
        "passengers": { "adult": 1, "child": 0, "infant": 0 },
        "cabinClass":"ECONOMY" }'
FieldWajibKeterangan
origin / destinationKode IATA 3 huruf (uppercase)
departDateYYYY-MM-DD
returnDateIsi untuk round-trip
passengers{ adult, child, infant }
cabinClassECONOMY | BUSINESS | FIRST (default ECONOMY)
routeALL (default) | DIRECT (hanya penerbangan langsung)
airlinesArray kode maskapai untuk memfilter
Response
{
  "results": [
    // lihat contoh FlightOffer lengkap ("id", "fareBrand", "stops",
    // "segments[]", "totalPrice", dst.) di tiap skenario di bawah
    { "id": "off_5vT2kQx9Jd4mZ0aBc1De2Fg3Hh4Ii5Jj6" }
  ],
  "count": 1,
  "sandbox": false
}

Langkah 2 — Pilih offer

Pilih offerId sesuai skenario (lihat aturan di bagian "Cara mengenali jenis offer" di atas).

Langkah 3 — Price-check

curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/flights/price-check \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -d '{ "offerId": "<OFFER_ID_TERPILIH>" }'

Respons memuat priceChanged, currentPrice, stillAvailable, baggageAllowance. Jika priceChanged: true, tampilkan harga baru ke pengguna sebelum lanjut.

Response
{
  "offerId": "off_5vT2kQx9Jd4mZ0aBc1De2Fg3Hh4Ii5Jj6",
  "priceChanged": false,
  "currentPrice": 1250000,
  "currency": "IDR",
  "stillAvailable": true,
  "baggageAllowance": "20kg"
}

Jika harga berubah, respons juga menyertakan "previousPrice":

{
  "offerId": "off_5vT2kQx9Jd4mZ0aBc1De2Fg3Hh4Ii5Jj6",
  "priceChanged": true,
  "previousPrice": 1250000,
  "currentPrice": 1310000,
  "currency": "IDR",
  "stillAvailable": true,
  "baggageAllowance": "20kg"
}

Langkah 4 — Create booking (hold)

⚠️ Field kontak FLAT — gunakan contactPhone (WAJIB, 7–20 karakter) dan contactEmail (opsional) langsung di root body. Jangan kirim objek contact { } bertingkat — akan diabaikan dan menyebabkan error validasi (contactPhone hilang).
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -d '{
    "offerId": "<OFFER_ID_TERPILIH>",
    "contactPhone": "+628123456789",
    "contactEmail": "[email protected]",
    "passengers": [
      { "title":"MR", "firstName":"BUDI", "lastName":"SANTOSO",
        "dateOfBirth":"1990-01-15", "nationality":"ID", "type":"ADULT" }
    ]
  }'
Field passengerWajibKeterangan
titleMR | MRS | MS | MSTR
firstName
lastNameOpsional (nama tunggal/mononym)
dateOfBirthYYYY-MM-DD
nationalityISO 2 huruf, mis. ID
passportNo / passportExpiry–*Wajib untuk penerbangan internasional
typeADULT | CHILD | INFANT
Response
{
  "id": "clxbkg1abcdef",
  "bookingCode": "JWZ-20260707-ABC123",
  "pnr": null,
  "status": "PENDING_PAYMENT",
  "totalAmount": 1250000,
  "currency": "IDR",
  "markup": 50000,
  "agentMarkup": 0,
  "bookingFee": 10000,
  "issuanceFee": 5000,
  "commission": 0,
  "flightData": {},
  "expiresAt": "2026-07-07T10:30:00.000Z",
  "issuedAt": null,
  "passengers": [
    {
      "id": "clxpax1abcdef",
      "title": "MR",
      "firstName": "Budi",
      "lastName": "Santoso",
      "dateOfBirth": "1990-01-15",
      "nationality": "ID",
      "type": "ADULT",
      "ticketNumber": null
    }
  ],
  "createdAt": "2026-07-07T09:00:00.000Z",
  "updatedAt": "2026-07-07T09:00:00.000Z"
}

Langkah 5 — Pay (wallet) → issue

curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/pay \
  -H "X-API-Key: jwz_live_xxx"
Response (production) — hasil pembayaran, bukan objek booking penuh
{
  "paymentId": "clxpay1abcdef",
  "status": "SUCCESS",
  "message": "Wallet payment successful"
}

Issuance (PAID → ISSUED, pengisian pnr & ticketNumber) berjalan sebagai bagian dari alur ini juga, tapi tidak ikut dikembalikan di respons /pay — ambil status & data terbaru via GET /bookings/:id:

{
  "id": "clxbkg1abcdef",
  "bookingCode": "JWZ-20260707-ABC123",
  "pnr": "XZ9K3P",
  "status": "ISSUED",
  "totalAmount": 1250000,
  "currency": "IDR",
  "markup": 50000,
  "agentMarkup": 0,
  "bookingFee": 10000,
  "issuanceFee": 5000,
  "commission": 0,
  "flightData": {},
  "expiresAt": "2026-07-07T10:30:00.000Z",
  "issuedAt": "2026-07-07T09:05:00.000Z",
  "passengers": [
    {
      "id": "clxpax1abcdef",
      "title": "MR",
      "firstName": "Budi",
      "lastName": "Santoso",
      "dateOfBirth": "1990-01-15",
      "nationality": "ID",
      "type": "ADULT",
      "ticketNumber": "997-1234567890"
    }
  ],
  "createdAt": "2026-07-07T09:00:00.000Z",
  "updatedAt": "2026-07-07T09:05:00.000Z"
}

Sandbox berbeda dari production — sandbox membungkus booking penuh langsung di respons /pay: { "ok": true, "sandbox": true, "message": "Pembayaran disimulasikan — tiket sandbox diterbitkan.", "booking": { /* booking status ISSUED seperti contoh GET /bookings/:id di atas */ } }.

Langkah 6 — Ambil e-ticket

curl -s https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/ticket \
  -H "X-API-Key: jwz_live_xxx"

Production mengembalikan { "ticketUrl": "...", "bookingCode": "..." } (URL bertanda tangan, kedaluwarsa 15 menit). Cek status kapan saja: GET /bookings/:id.

Response
{
  "ticketUrl": "/api/v1/bookings/clxbkg1abcdef/eticket",
  "bookingCode": "JWZ-20260707-ABC123"
}

Sandbox tidak menerbitkan e-ticket nyata: { "sandbox": true, "message": "E-ticket tidak tersedia di sandbox." }.

8 Skenario

Untuk tiap skenario: jalankan search berikut, lalu pilih offer sesuai aturan, lalu ikuti Alur Umum langkah 3–6 (price-check → create → pay → ticket).

Skenario 1 — OW Direct, dengan bagasi

Search Body
{ "origin":"CGK", "destination":"DPS", "departDate":"2026-07-15",
  "passengers":{ "adult":1,"child":0,"infant":0 }, "cabinClass":"ECONOMY", "route":"DIRECT" }

Pilih offer: stops === 0 dan fareBrand/baggageAllowance menunjukkan ada bagasi (mis. "20kg"). Bandingkan brand via GET /flights/:offerId/fare-rules bila ragu.

Response — offer yang cocok (dari results[])
{
  "id": "off_5vT2kQx9Jd4mZ0aBc1De2Fg3Hh4Ii5Jj6",
  "fareBrand": "Economy Value",
  "stops": 0,
  "segments": [
    {
      "flightNumber": "GA-402",
      "carrier": "GA",
      "carrierName": "Garuda Indonesia",
      "origin": "CGK",
      "destination": "DPS",
      "departureTime": "2026-07-14T23:00:00.000Z",
      "arrivalTime": "2026-07-15T01:55:00.000Z",
      "duration": 115,
      "aircraft": "Boeing 737-800",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "20kg"
    }
  ],
  "totalDuration": 115,
  "basePrice": 1100000,
  "tax": 150000,
  "totalPrice": 1250000,
  "currency": "IDR",
  "seatsAvailable": 6,
  "expiresAt": "2026-07-07T14:00:00.000Z"
  // results[] juga berisi brand lain utk penerbangan sama — ini yg PUNYA bagasi
}

Skenario 2 — OW Direct, tanpa bagasi

Search Body
{ "origin":"CGK", "destination":"DPS", "departDate":"2026-07-15",
  "passengers":{ "adult":1,"child":0,"infant":0 }, "cabinClass":"ECONOMY", "route":"DIRECT" }

Pilih offer: stops === 0 dan pilih brand tanpa bagasi (baggageAllowance seperti "Tanpa bagasi" / "Kabin saja") — biasanya fareBrand "Basic"/termurah untuk penerbangan yang sama.

Response — offer yang cocok (dari results[])
{
  "id": "off_9pR3wLm4Xa7bY1cV2dU8eT0fS6gQ5hP2j",
  "fareBrand": "Economy Basic",
  "stops": 0,
  "segments": [
    {
      "flightNumber": "GA-402",
      // sama seperti skenario 1 — penerbangan identik, brand lebih murah
      "carrier": "GA",
      "carrierName": "Garuda Indonesia",
      "origin": "CGK",
      "destination": "DPS",
      "departureTime": "2026-07-14T23:00:00.000Z",
      "arrivalTime": "2026-07-15T01:55:00.000Z",
      "duration": 115,
      "aircraft": "Boeing 737-800",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "Tanpa bagasi (kabin 7kg)"
    }
  ],
  "totalDuration": 115,
  "basePrice": 920000,
  "tax": 130000,
  "totalPrice": 1050000,
  "currency": "IDR",
  "seatsAvailable": 9,
  "expiresAt": "2026-07-07T14:00:00.000Z"
}

Skenario 3 — RT Direct, dengan bagasi

Search Body
{ "origin":"CGK", "destination":"DPS", "departDate":"2026-07-15", "returnDate":"2026-07-20",
  "passengers":{ "adult":1,"child":0,"infant":0 }, "cabinClass":"ECONOMY", "route":"DIRECT" }

Pilih offer: offer round-trip (segments[] berisi kedua leg, CGK→DPS lalu DPS→CGK), tiap arah stops 0, dan brand dengan bagasi. Satu offerId = satu booking = satu PNR untuk PP.

Response — offer yang cocok (dari results[])
{
  "id": "off_3kN8oI2uH5yG7fD1eC9bA0zX4wV6tU3s",
  "fareBrand": "Economy Value",
  "stops": 0,
  "segments": [
    {
      "flightNumber": "GA-402",
      "carrier": "GA",
      "carrierName": "Garuda Indonesia",
      "origin": "CGK",
      "destination": "DPS",
      "departureTime": "2026-07-14T23:00:00.000Z",
      "arrivalTime": "2026-07-15T01:55:00.000Z",
      "duration": 115,
      "aircraft": "Boeing 737-800",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "20kg"
      // outbound
    },
    {
      "flightNumber": "GA-405",
      "carrier": "GA",
      "carrierName": "Garuda Indonesia",
      "origin": "DPS",
      "destination": "CGK",
      "departureTime": "2026-07-19T23:30:00.000Z",
      "arrivalTime": "2026-07-20T00:50:00.000Z",
      "duration": 140,
      "aircraft": "Boeing 737-800",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "20kg"
      // return
    }
  ],
  "totalDuration": 255,
  "basePrice": 2150000,
  "tax": 300000,
  "totalPrice": 2450000,
  "currency": "IDR",
  "seatsAvailable": 5,
  "expiresAt": "2026-07-07T14:00:00.000Z"
}

Skenario 4 — RT Direct, tanpa bagasi

Search Body
{ "origin":"CGK", "destination":"DPS", "departDate":"2026-07-15", "returnDate":"2026-07-20",
  "passengers":{ "adult":1,"child":0,"infant":0 }, "cabinClass":"ECONOMY", "route":"DIRECT" }

Pilih offer: offer round-trip langsung dengan brand tanpa bagasi.

Response — offer yang cocok (dari results[])
{
  "id": "off_7qM1lK4jH8gF2eD5cB9aZ3yX6wV0uT4r",
  "fareBrand": "Economy Basic",
  "stops": 0,
  "segments": [
    {
      "flightNumber": "GA-402",
      "carrier": "GA",
      "carrierName": "Garuda Indonesia",
      "origin": "CGK",
      "destination": "DPS",
      "departureTime": "2026-07-14T23:00:00.000Z",
      "arrivalTime": "2026-07-15T01:55:00.000Z",
      "duration": 115,
      "aircraft": "Boeing 737-800",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "Tanpa bagasi (kabin 7kg)"
      // outbound
    },
    {
      "flightNumber": "GA-405",
      "carrier": "GA",
      "carrierName": "Garuda Indonesia",
      "origin": "DPS",
      "destination": "CGK",
      "departureTime": "2026-07-19T23:30:00.000Z",
      "arrivalTime": "2026-07-20T00:50:00.000Z",
      "duration": 140,
      "aircraft": "Boeing 737-800",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "Tanpa bagasi (kabin 7kg)"
      // return
    }
  ],
  "totalDuration": 255,
  "basePrice": 1800000,
  "tax": 250000,
  "totalPrice": 2050000,
  "currency": "IDR",
  "seatsAvailable": 8,
  "expiresAt": "2026-07-07T14:00:00.000Z"
}

Skenario 5 — OW Transfer, dengan bagasi

Search Body
{ "origin":"CGK", "destination":"DPS", "departDate":"2026-07-15",
  "passengers":{ "adult":1,"child":0,"infant":0 }, "cabinClass":"ECONOMY", "route":"ALL" }

Pilih offer: stops >= 1 (mis. segments[] = CGK→SUB→DPS), brand dengan bagasi. baggageAllowance berlaku per segmen — cek semuanya via /fare-rules.

Response — offer yang cocok (dari results[])
{
  "id": "off_2aF6dS9gJ3kM7pQ1rT5vX8zB0cE4hL7n",
  "fareBrand": "Economy Value",
  "stops": 1,
  "segments": [
    {
      "flightNumber": "QG-620",
      "carrier": "QG",
      "carrierName": "Citilink",
      "origin": "CGK",
      "destination": "SUB",
      "departureTime": "2026-07-14T22:00:00.000Z",
      "arrivalTime": "2026-07-15T00:20:00.000Z",
      "duration": 110,
      "aircraft": "Airbus A320",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "20kg"
      // leg 1
    },
    {
      "flightNumber": "QG-740",
      "carrier": "QG",
      "carrierName": "Citilink",
      "origin": "SUB",
      "destination": "DPS",
      "departureTime": "2026-07-15T02:10:00.000Z",
      "arrivalTime": "2026-07-15T03:35:00.000Z",
      "duration": 85,
      "aircraft": "Airbus A320",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "20kg"
      // leg 2, transit di SUB
    }
  ],
  "totalDuration": 315,
  "basePrice": 850000,
  "tax": 130000,
  "totalPrice": 980000,
  "currency": "IDR",
  "seatsAvailable": 4,
  "expiresAt": "2026-07-07T14:00:00.000Z"
}

Skenario 6 — OW Transfer, tanpa bagasi

Search Body
{ "origin":"CGK", "destination":"DPS", "departDate":"2026-07-15",
  "passengers":{ "adult":1,"child":0,"infant":0 }, "cabinClass":"ECONOMY", "route":"ALL" }

Pilih offer: stops >= 1 dan brand tanpa bagasi.

Response — offer yang cocok (dari results[])
{
  "id": "off_8wY4uR7tP1oL5jH9gF3eD6cB2aZ0xV5m",
  "fareBrand": "Economy Basic",
  "stops": 1,
  "segments": [
    {
      "flightNumber": "QG-620",
      "carrier": "QG",
      "carrierName": "Citilink",
      "origin": "CGK",
      "destination": "SUB",
      "departureTime": "2026-07-14T22:00:00.000Z",
      "arrivalTime": "2026-07-15T00:20:00.000Z",
      "duration": 110,
      "aircraft": "Airbus A320",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "Tanpa bagasi (kabin 7kg)"
      // leg 1
    },
    {
      "flightNumber": "QG-740",
      "carrier": "QG",
      "carrierName": "Citilink",
      "origin": "SUB",
      "destination": "DPS",
      "departureTime": "2026-07-15T02:10:00.000Z",
      "arrivalTime": "2026-07-15T03:35:00.000Z",
      "duration": 85,
      "aircraft": "Airbus A320",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "Tanpa bagasi (kabin 7kg)"
      // leg 2, transit di SUB
    }
  ],
  "totalDuration": 315,
  "basePrice": 710000,
  "tax": 110000,
  "totalPrice": 820000,
  "currency": "IDR",
  "seatsAvailable": 7,
  "expiresAt": "2026-07-07T14:00:00.000Z"
}

Skenario 7 — RT Transfer, dengan bagasi

Search Body
{ "origin":"CGK", "destination":"DPS", "departDate":"2026-07-15", "returnDate":"2026-07-20",
  "passengers":{ "adult":1,"child":0,"infant":0 }, "cabinClass":"ECONOMY", "route":"ALL" }

Pilih offer: offer round-trip yang minimal satu arahnya transit (stops >= 1), brand dengan bagasi. segments[] memuat leg outbound (bisa >1 segmen) lalu leg return.

Response — offer yang cocok (dari results[])
{
  "id": "off_4nC7bV1xZ5aS9dF3gH6jK0lM8pQ2rT6w",
  "fareBrand": "Economy Value",
  "stops": 1,
  // stops mencerminkan arah yg transit; pisahkan arah via transisi origin/destination
  "segments": [
    {
      "flightNumber": "QG-620",
      "carrier": "QG",
      "carrierName": "Citilink",
      "origin": "CGK",
      "destination": "SUB",
      "departureTime": "2026-07-14T22:00:00.000Z",
      "arrivalTime": "2026-07-15T00:20:00.000Z",
      "duration": 110,
      "aircraft": "Airbus A320",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "20kg"
      // outbound leg 1
    },
    {
      "flightNumber": "QG-740",
      "carrier": "QG",
      "carrierName": "Citilink",
      "origin": "SUB",
      "destination": "DPS",
      "departureTime": "2026-07-15T02:10:00.000Z",
      "arrivalTime": "2026-07-15T03:35:00.000Z",
      "duration": 85,
      "aircraft": "Airbus A320",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "20kg"
      // outbound leg 2, transit di SUB
    },
    {
      "flightNumber": "GA-405",
      "carrier": "GA",
      "carrierName": "Garuda Indonesia",
      "origin": "DPS",
      "destination": "CGK",
      "departureTime": "2026-07-19T23:30:00.000Z",
      "arrivalTime": "2026-07-20T00:50:00.000Z",
      "duration": 140,
      "aircraft": "Boeing 737-800",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "20kg"
      // return, direct
    }
  ],
  "totalDuration": 570,
  "basePrice": 1900000,
  "tax": 280000,
  "totalPrice": 2180000,
  "currency": "IDR",
  "seatsAvailable": 4,
  "expiresAt": "2026-07-07T14:00:00.000Z"
}

Skenario 8 — RT Transfer, tanpa bagasi

Search Body
{ "origin":"CGK", "destination":"DPS", "departDate":"2026-07-15", "returnDate":"2026-07-20",
  "passengers":{ "adult":1,"child":0,"infant":0 }, "cabinClass":"ECONOMY", "route":"ALL" }

Pilih offer: offer round-trip dengan transit, brand tanpa bagasi.

Response — offer yang cocok (dari results[])
{
  "id": "off_6yU3iO8pL2kJ5hG9fD4sA1zX7cV0bN3q",
  "fareBrand": "Economy Basic",
  "stops": 1,
  "segments": [
    {
      "flightNumber": "QG-620",
      "carrier": "QG",
      "carrierName": "Citilink",
      "origin": "CGK",
      "destination": "SUB",
      "departureTime": "2026-07-14T22:00:00.000Z",
      "arrivalTime": "2026-07-15T00:20:00.000Z",
      "duration": 110,
      "aircraft": "Airbus A320",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "Tanpa bagasi (kabin 7kg)"
      // outbound leg 1
    },
    {
      "flightNumber": "QG-740",
      "carrier": "QG",
      "carrierName": "Citilink",
      "origin": "SUB",
      "destination": "DPS",
      "departureTime": "2026-07-15T02:10:00.000Z",
      "arrivalTime": "2026-07-15T03:35:00.000Z",
      "duration": 85,
      "aircraft": "Airbus A320",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "Tanpa bagasi (kabin 7kg)"
      // outbound leg 2, transit di SUB
    },
    {
      "flightNumber": "GA-405",
      "carrier": "GA",
      "carrierName": "Garuda Indonesia",
      "origin": "DPS",
      "destination": "CGK",
      "departureTime": "2026-07-19T23:30:00.000Z",
      "arrivalTime": "2026-07-20T00:50:00.000Z",
      "duration": 140,
      "aircraft": "Boeing 737-800",
      "cabinClass": "ECONOMY",
      "baggageAllowance": "Tanpa bagasi (kabin 7kg)"
      // return, direct
    }
  ],
  "totalDuration": 570,
  "basePrice": 1560000,
  "tax": 220000,
  "totalPrice": 1780000,
  "currency": "IDR",
  "seatsAvailable": 6,
  "expiresAt": "2026-07-07T14:00:00.000Z"
}

Skenario Lanjutan (9–12)

Selain 8 skenario bentuk-offer di atas, berikut 4 skenario operasional yang sering dijumpai partner: campuran tipe penumpang (dewasa/anak/bayi), penerbangan internasional yang mewajibkan dokumen paspor, pembatalan & refund pasca-booking, dan pembelian ancillary (bagasi/kursi/makan) setelah tiket terbit.

Skenario 9 — Multi-penumpang (Dewasa + Anak + Bayi)

Search Body
{ "origin":"CGK", "destination":"DPS", "departDate":"2026-07-15",
  "passengers":{ "adult":2,"child":1,"infant":1 }, "cabinClass":"ECONOMY", "route":"DIRECT" }

Pilih offer seperti Skenario 1 (stops === 0, brand dengan bagasi). Yang berbeda ada di langkah create: kirim 4 objek passengers[] — 2 ADULT, 1 CHILD (lahir sekitar 2018), 1 INFANT (lahir sekitar 2025, di bawah 2 tahun, lap infant tanpa kursi sendiri).

⚠️ Harga per-tipe berbeda — bayi termurah (tanpa kursi), anak di antara dewasa dan bayi. Jumlah bayi lazimnya ≤ jumlah dewasa (aturan lap-infant maskapai: satu bayi dipangku satu dewasa).
Request — Create Booking
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -d '{
    "offerId": "<OFFER_ID_TERPILIH>",
    "contactPhone": "+628123456789",
    "contactEmail": "[email protected]",
    "passengers": [
      { "title":"MR", "firstName":"BUDI", "lastName":"SANTOSO",
        "dateOfBirth":"1990-01-15", "nationality":"ID", "type":"ADULT" },
      { "title":"MRS", "firstName":"SITI", "lastName":"SANTOSO",
        "dateOfBirth":"1992-03-20", "nationality":"ID", "type":"ADULT" },
      { "title":"MSTR", "firstName":"ADI", "lastName":"SANTOSO",
        "dateOfBirth":"2018-06-10", "nationality":"ID", "type":"CHILD" },
      { "title":"MSTR", "firstName":"BAYU", "lastName":"SANTOSO",
        "dateOfBirth":"2025-09-01", "nationality":"ID", "type":"INFANT" }
    ]
  }'
Response (201)
{
  "id": "clxbkg1abcdef",
  "bookingCode": "JWZ-20260707-ABC123",
  "pnr": null,
  "status": "PENDING_PAYMENT",
  // 2 dewasa + 1 anak + 1 bayi (base per-dewasa ~1.000.000 + tax/fee)
  "totalAmount": 2850000,
  "currency": "IDR",
  "markup": 100000,
  "bookingFee": 10000,
  "issuanceFee": 5000,
  "commission": 0,
  "expiresAt": "2026-07-07T10:30:00.000Z",
  "issuedAt": null,
  "passengers": [
    { "id": "clxpax1abcdef", "type": "ADULT", "ticketNumber": null },
    { "id": "clxpax2abcdef", "type": "ADULT", "ticketNumber": null },
    { "id": "clxpax3abcdef", "type": "CHILD", "ticketNumber": null },
    { "id": "clxpax4abcdef", "type": "INFANT", "ticketNumber": null }
    // tiap entri juga membawa title, firstName, lastName, dateOfBirth, nationality
  ],
  "createdAt": "2026-07-07T09:00:00.000Z",
  "updatedAt": "2026-07-07T09:00:00.000Z"
}

Skenario 10 — Internasional (paspor WAJIB)

Search Body
{ "origin":"CGK", "destination":"SIN", "departDate":"2026-07-15",
  "passengers":{ "adult":1,"child":0,"infant":0 }, "cabinClass":"ECONOMY" }

JetWize mendeteksi rute internasional bila ada segmen yang menyentuh bandara non-Indonesia (countryCodeID) — di sini CGK→SIN. Untuk rute internasional, setiap penumpang wajib menyertakan passportNo (≥ 6 karakter), passportExpiry, dan nationality saat create.

Error (HTTP 400) — create tanpa paspor
{
  "error": {
    "code": "PASSPORT_REQUIRED",
    "message": "Rute internasional wajib menyertakan paspor + tanggal berlaku untuk semua penumpang. Lengkapi: JOHN DOE (no. paspor & tanggal berlaku paspor).",
    "details": {
      "route": "INTERNATIONAL",
      "incompletePassengers": ["JOHN DOE (no. paspor & tanggal berlaku paspor)"]
    },
    "traceId": "req_abc123"
  }
}
Error (HTTP 400) — create dengan paspor kadaluarsa
{
  "error": {
    "code": "PASSPORT_EXPIRED",
    "message": "Paspor sudah kadaluarsa sebelum tanggal keberangkatan: JOHN DOE (kadaluarsa 2026-05-01). Perbarui paspor terlebih dahulu sebelum booking.",
    "details": {
      "departDate": "2026-07-15",
      "expiredPassengers": [ { "name": "JOHN DOE", "expiry": "2026-05-01" } ],
      "expiringSoonPassengers": []
    }
  }
}

Paspor yang berlaku < 6 bulan setelah tanggal keberangkatan muncul di expiringSoonPassengers — ini peringatan saja, tidak memblokir booking.

Request — Create Booking (paspor lengkap)
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -d '{
    "offerId": "<OFFER_ID_TERPILIH>",
    "contactPhone": "+628123456789",
    "contactEmail": "[email protected]",
    "passengers": [
      { "title":"MR", "firstName":"JOHN", "lastName":"DOE",
        "dateOfBirth":"1990-01-15", "nationality":"ID",
        "passportNo":"A1234567", "passportExpiry":"2030-01-01", "type":"ADULT" }
    ]
  }'
Response (201)
{
  "id": "clxbkg1abcdef",
  "bookingCode": "JWZ-20260707-ABC123",
  "pnr": null,
  "status": "PENDING_PAYMENT",
  "totalAmount": 1250000,
  "currency": "IDR",
  "expiresAt": "2026-07-07T10:30:00.000Z",
  "issuedAt": null,
  "passengers": [
    {
      "id": "clxpax1abcdef",
      "title": "MR",
      "firstName": "John",
      "lastName": "Doe",
      "dateOfBirth": "1990-01-15",
      "nationality": "ID",
      "passportNo": "A1234567",
      "passportExpiry": "2030-01-01",
      "type": "ADULT",
      "ticketNumber": null
    }
  ],
  "createdAt": "2026-07-07T09:00:00.000Z",
  "updatedAt": "2026-07-07T09:00:00.000Z"
}
💡 passportNo disimpan terenkripsi di database — di respons API tetap ditampilkan apa adanya untuk konfirmasi ke partner.

Skenario 11 — Cancel & Refund

Dua alur terpisah tergantung status booking.

Cancel (sebelum issued — status DRAFT / PENDING_PAYMENT / PAID)

Request
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/cancel \
  -H "X-API-Key: jwz_live_xxx"
Response
{
  "bookingId": "clxbkg1abcdef",
  "status": "CANCELLED"
}

Hold dilepas dan dana (bila sudah didebit dari wallet) dikembalikan. Setelah ISSUED tidak bisa cancel — pakai alur refund di bawah.

Refund (setelah issued) — dua langkah

Request — Langkah 1: cek estimasi refund
curl -s https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/refund-quote \
  -H "X-API-Key: jwz_live_xxx"
Response
{
  "bookingId": "clxbkg1abcdef",
  "bookingCode": "JWZ-20260707-ABC123",
  "status": "ISSUED",
  "refundable": true,
  "currency": "IDR",
  "maxRefundAmount": 1065000,
  "note": "Estimasi maksimum. Nilai final dikurangi biaya sesuai aturan tarif maskapai/supplier."
}

Bila booking belum ISSUED, respons menunjukkan "refundable": false, "maxRefundAmount": 0 dan note mengarahkan ke /cancel alih-alih refund.

Request — Langkah 2: submit refund
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/refund \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -d '{ "reason": "Pembatalan oleh penumpang karena perubahan jadwal" }'

reason wajib, minimal 10 karakter. amount opsional (default: estimasi maksimum dari refund-quote).

Response (production, supplier async)
{
  "bookingId": "clxbkg1abcdef",
  "status": "ISSUED",
  "message": "Refund request submitted to supplier. Confirmation will arrive via webhook.",
  "refundId": "rfnd_xxx"
}

Refund via WALLET (atau supplier non-async) diselesaikan langsung. Sebagian supplier memproses refund secara async (konfirmasi via webhook) — status booking tetap ISSUED sampai konfirmasi webhook diterima, lalu berubah ke REFUNDED.

Response (sandbox)
{
  "ok": true,
  "sandbox": true,
  "message": "Refund disimulasikan di sandbox. Tidak ada dana yang dikembalikan."
}

Skenario 12 — Ancillary pasca-issue (bagasi / kursi / makan)

Hanya berlaku untuk booking berstatus ISSUED. Tiga langkah: lihat katalog, cek seatmap (untuk kursi), lalu beli.

Langkah 1 — Katalog ancillary

Request
curl -s "https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/ancillaries/catalog?kind=BAGGAGE" \
  -H "X-API-Key: jwz_live_xxx"
Response
{
  "supported": true,
  "airline": { "code": "JT", "name": "Lion Air" },
  "options": [
    {
      "id": "BAG-20",
      "kind": "BAGGAGE",
      "label": "Bagasi 20kg",
      "price": 250000,
      "currency": "IDR",
      "scope": "PER_SEGMENT",
      "available": true
    }
  ]
}

Bila maskapai belum mendukung ancillary: { "supported": false, "reason": "UNSUPPORTED" } (atau "reason": "NOT_ISSUED" bila booking belum terbit).

Langkah 2 — Seatmap (khusus kursi)

Request
curl -s https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/seatmap \
  -H "X-API-Key: jwz_live_xxx"
Response
{
  "supported": true,
  "columns": [ "A", "B", "C", "", "D", "E", "F" ],
  "rows": [
    {
      "row": 12,
      "seats": [
        { "seatNumber": "12A", "status": "AVAILABLE", "exitRow": false, "price": 150000, "currency": "IDR" },
        { "seatNumber": "12B", "status": "OCCUPIED", "exitRow": false }
      ]
    }
  ]
}

"" di columns menandai posisi lorong. Bangun optionId kursi sebagai SEAT-<seatNumber> (mis. kursi 12ASEAT-12A).

Langkah 3 — Beli ancillary

Request
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/ancillaries \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -d '{
    "payment": "WALLET",
    "items": [
      { "optionId": "BAG-20", "kind": "BAGGAGE", "passengerId": "clxpax1abcdef", "segmentIndex": 0 },
      { "optionId": "SEAT-12A", "kind": "SEAT", "passengerId": "clxpax1abcdef" }
    ]
  }'

passengerId diambil dari GET /bookings/:id (field passengers[].id).

Response (production)
{
  "purchaseId": "anc_xxx",
  "total": 400000,
  "currency": "IDR",
  "items": [
    { "optionId": "BAG-20", "kind": "BAGGAGE", "passengerId": "clxpax1abcdef", "price": 250000 },
    { "optionId": "SEAT-12A", "kind": "SEAT", "passengerId": "clxpax1abcdef", "seatNumber": "12A", "price": 150000 }
  ]
}
Response (sandbox)
{
  "ok": true,
  "sandbox": true,
  "message": "Pembelian ancillary disimulasikan di sandbox. Tidak ada debit nyata."
}

Pola optionId per jenis: bagasi BAG-<n>, makan MEAL-<code> (mis. MEAL-VGML), kursi SEAT-<seatNumber>.

⚠️ Error kursi yang umum: HTTP 409 SEAT_UNAVAILABLE (kursi sudah terisi/dipesan orang lain) dan HTTP 400 SEAT_PER_PAX (maksimum 1 kursi per penumpang per segmen).

Skenario Lanjutan (13–16) — Harga, Idempotency & After-Sales

Empat skenario lanjutan berikutnya: markup agen & voucher diskon saat create, retry aman lewat idempotency key, jadwal ulang penerbangan (reschedule), dan perpanjangan hold otomatis (auto-rebook) sebelum booking kedaluwarsa.

Skenario 13 — Agent Markup & Voucher Diskon

Dua field opsional di body create booking (POST /bookings) yang memengaruhi harga akhir:

Keduanya bisa dikombinasikan dalam satu request create.

Request — Create Booking (dengan markup + voucher)
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -d '{
    "offerId": "<OFFER_ID_TERPILIH>",
    "contactPhone": "+628123456789",
    "contactEmail": "[email protected]",
    "passengers": [
      { "title":"MR", "firstName":"BUDI", "lastName":"SANTOSO",
        "dateOfBirth":"1990-01-15", "nationality":"ID", "type":"ADULT" }
    ],
    "agentMarkup": { "type": "PERCENT", "value": 5, "max": 100000 },
    "voucherCode": "PROMO10"
  }'
Response (201)
{
  "id": "clxbkg1abcdef",
  "bookingCode": "JWZ-20260707-ABC123",
  "status": "PENDING_PAYMENT",
  "agentMarkup": 50000,
  "voucherCode": "PROMO10",
  "voucherDiscount": 100000,
  "totalAmount": 950000,
  // total = fare + fees + markup agen − diskon voucher
  "currency": "IDR",
  "expiresAt": "2026-07-07T10:30:00.000Z"
}

Markup menaikkan total (profit agen, ikut tertagih ke pelanggan); voucher menurunkan total (diskon). Bila voucherCode tidak valid/sudah kedaluwarsa, booking tetap dibuat — tanpa diskon ("voucherDiscount": 0) atau ditolak dengan error validasi voucher, tergantung jenis kegagalannya.

Skenario 14 — Idempotency (retry aman)

Semua POST yang memindahkan uang / membuat resource menerima header Idempotency-Key: <string unik Anda> — utamanya POST /bookings, POST /bookings/:id/pay, POST /bookings/:id/ancillaries, dan POST /bookings/:id/reschedule.

Kirim key unik per operasi logis. Bila request dengan key yang sama diulang (mis. karena timeout jaringan), server mengembalikan response yang sama persis dari yang pertama — server tidak membuat booking/charge ganda. Panggilan pertama → 201 Created; pengulangan identik → 200 OK (replay).

Request (panggilan pertama)
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -H "Idempotency-Key: idem-booking-2026-07-15-budi-001" \
  -d '{
    "offerId": "<OFFER_ID_TERPILIH>",
    "contactPhone": "+628123456789",
    "contactEmail": "[email protected]",
    "passengers": [
      { "title":"MR", "firstName":"BUDI", "lastName":"SANTOSO",
        "dateOfBirth":"1990-01-15", "nationality":"ID", "type":"ADULT" }
    ]
  }'
Response pertama (201, booking baru)
{
  "id": "clxbkg1abcdef",
  "bookingCode": "JWZ-20260707-ABC123",
  "status": "PENDING_PAYMENT",
  "totalAmount": 1000000,
  "currency": "IDR",
  "expiresAt": "2026-07-07T10:30:00.000Z"
}
Request retry (key sama, mis. setelah timeout)
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -H "Idempotency-Key: idem-booking-2026-07-15-budi-001" \
  -d '{ /* body identik dengan panggilan pertama */ }'
Response retry (200, booking YANG SAMA — bukan booking kedua)
{
  "id": "clxbkg1abcdef",
  "bookingCode": "JWZ-20260707-ABC123",
  "status": "PENDING_PAYMENT",
  "totalAmount": 1000000,
  "currency": "IDR",
  "expiresAt": "2026-07-07T10:30:00.000Z"
}

bookingCode/id identik dengan respons pertama — bukti tidak ada booking kedua yang tercipta.

💡 Rekomendasi: generate 1 key per pesanan (mis. UUID) dan pakai ulang key itu untuk retry pesanan tsb; ganti key hanya untuk pesanan baru. Simpan mapping key → pesanan di sisi Anda.

Skenario 15 — Reschedule (ganti penerbangan, rute sama)

Hanya untuk booking berstatus ISSUED, dan lebih dari 24 jam sebelum keberangkatan. Dua langkah: lihat opsi, lalu eksekusi.

Request — Langkah 1: opsi reschedule
curl -s "https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/reschedule/options?date=2026-08-01" \
  -H "X-API-Key: jwz_live_xxx"
Response
{
  "supported": true,
  "reason": null,
  "airline": { "code": "GA", "name": "Garuda Indonesia" },
  "options": [
    {
      "optionId": "RSO-GA402-20260801",
      "flightNumber": "GA-402",
      "departureTime": "2026-08-01T01:00:00.000Z",
      "arrivalTime": "2026-08-01T04:05:00.000Z",
      "fareDifference": 150000,
      "changeFee": 100000,
      "currency": "IDR"
    }
  ]
}

Bila booking belum ISSUED: { "supported": false, "reason": "NOT_ISSUED" }; bila kurang dari 24 jam sebelum keberangkatan: "reason": "WINDOW_CLOSED"; maskapai belum mendukung reschedule: "reason": "UNSUPPORTED". Tanggal tidak valid → error INVALID_DATE.

Request — Langkah 2: eksekusi reschedule
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/reschedule \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -H "Idempotency-Key: idem-reschedule-clxbkg1abcdef-001" \
  -d '{
    "optionId": "RSO-GA402-20260801",
    "date": "2026-08-01",
    "payment": "WALLET"
  }'

Menagih max(0, fareDifference) + changeFee, void tiket lama, lalu terbitkan PNR baru.

Response (production)
{
  "oldPnr": "FT12345K001824",
  "newPnr": "FT98765K118240",
  "flightNumber": "GA-402",
  "departureTime": "2026-08-01T01:00:00.000Z",
  "arrivalTime": "2026-08-01T04:05:00.000Z",
  "fareDifference": 150000,
  "changeFee": 100000,
  "amountCharged": 250000,
  "currency": "IDR"
}

Catatan: bila selisih tarif negatif (penerbangan baru lebih murah), selisih itu tidak dikembalikan — hanya changeFee yang tetap ditagih.

Response (sandbox)
{
  "ok": true,
  "sandbox": true,
  "message": "Jadwal ulang disimulasikan di sandbox. Tidak ada perubahan nyata."
}
Error (HTTP 4xx) — di luar window 24 jam
{
  "error": {
    "code": "RESCHEDULE_WINDOW_CLOSED",
    "message": "Reschedule window has closed",
    "details": { "bookingId": "clxbkg1abcdef" },
    "traceId": "..."
  }
}

Skenario 16 — Auto-Rebook (perpanjang hold otomatis sebelum kedaluwarsa)

Untuk booking PENDING_PAYMENT yang belum dibayar; digerbang per-supplier (tidak semua supplier mendukung). Memperpanjang hold secara otomatis sebelum expiresAt — tiap siklus: cek ketersediaan + harga, lalu cancel → rebook. Fitur ini tidak menerbitkan tiket dan tidak menarik pembayaran. Opt-in per booking.

Request — Aktifkan
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/auto-rebook/enable \
  -H "X-API-Key: jwz_live_xxx" -H "Content-Type: application/json" \
  -d '{ "maxHoldMinutes": 1440, "priceThresholdPct": 10 }'

maxHoldMinutes antara 60–2880, default 1440 (24 jam). priceThresholdPct opsional — bila harga renewal naik lebih dari ambang ini, siklus PAUSE menunggu approval; dihilangkan berarti siklus tidak pernah pause karena harga.

Response
{
  "status": "ACTIVE",
  "cycleId": "arc_abc123",
  "nextAttemptAt": "2026-07-15T05:25:00.000Z"
}
Error (HTTP 409)
{
  "error": {
    "code": "AUTO_REBOOK_NOT_ELIGIBLE",
    "message": "Booking is not eligible for auto-rebook",
    "details": { "bookingId": "clxbkg1abcdef" },
    "traceId": "..."
  }
}

Kode error 409 lain yang mungkin muncul: AUTO_REBOOK_ALREADY_ACTIVE (siklus sudah aktif) dan AUTO_REBOOK_SUPPLIER_DISABLED (supplier belum mendukung fitur ini). AUTO_REBOOK_NOT_ELIGIBLE muncul bila booking bukan PENDING_PAYMENT.

Request — Riwayat & status siklus
curl -s https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/auto-rebook/history \
  -H "X-API-Key: jwz_live_xxx"
Response
{
  "cycle": {
    "status": "ACTIVE",
    "maxHoldMinutes": 1440,
    "priceThresholdPct": 10,
    "expiresAt": "2026-07-16T04:00:00.000Z",
    "nextAttemptAt": "2026-07-15T05:25:00.000Z",
    "attemptCount": 1,
    "lastPrice": 1500000
  },
  "attempts": [
    {
      "attemptNumber": 1,
      "attemptedAt": "2026-07-15T04:55:00.000Z",
      "result": "SUCCESS",
      "prevBookingRef": "BKG-AB12CD",
      "newBookingRef": "BKG-EF34GH",
      "prevPrice": 1500000,
      "newPrice": 1500000,
      "priceDelta": 0,
      "priceDeltaPct": 0
    }
  ]
}

Nilai result yang mungkin: SUCCESS, SEAT_UNAVAILABLE, API_ERROR, PRICE_THRESHOLD_EXCEEDED, HOLD_LOST.

Request — Nonaktifkan
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/auto-rebook/disable \
  -H "X-API-Key: jwz_live_xxx"
Response
{
  "status": "STOPPED",
  "bookingId": "clxbkg1abcdef",
  "lastAttemptAt": "2026-07-15T04:55:00.000Z"
}

Hold aktif yang sudah ada tetap dipertahankan — disable hanya menghentikan siklus perpanjangan otomatisnya. Error (HTTP 409): AUTO_REBOOK_NOT_ACTIVE (siklus belum/tidak aktif).

Request — Setujui siklus yang PAUSED (harga renewal naik di atas ambang)
curl -s -X POST https://api.jetwize.com/api/v1/partner/v1/bookings/<BOOKING_ID>/auto-rebook/approve \
  -H "X-API-Key: jwz_live_xxx"
Response
{
  "status": "ACTIVE",
  "nextAttemptAt": "2026-07-15T05:40:00.000Z"
}

Error (HTTP 409): AUTO_REBOOK_NOT_PAUSED (siklus tidak dalam status paused).

Sandbox vs Production — apa yang bisa diuji

MockSupplier (kunci jwz_test_...) sengaja disederhanakan agar deterministik untuk menguji mekanika API (auth, search, create, pay, e-ticket). Yang dikembalikan sandbox:

SkenarioSandboxProduction
1. OW Direct + bagasi✅ bisa diuji end-to-end
2. OW Direct tanpa bagasi⚠️ brand tanpa bagasi tak muncul di sandbox
3–4. RT Direct⚠️ leg return tak muncul di sandbox
5–8. Transfer & RT Transfer⚠️ offer transfer tak muncul di sandbox
Untuk menguji skenario 2–8 secara penuh, gunakan kunci PRODUCTION (supplier nyata yang mengembalikan branded fare, transit, dan leg return). Sandbox tetap berguna untuk memvalidasi integrasi kode Anda (header, bentuk payload, penanganan error, alur create→pay→ticket) tanpa biaya.

Error umum pada alur booking

codeArtiTindakan
MISSING_API_KEYHeader key tidak ada / bukan jwz_Kirim X-API-Key: jwz_...
INVALID_API_KEY / EXPIRED_API_KEYKey tidak valid/kadaluarsaRegenerasi key
AGENCY_INACTIVEAgensi tidak aktifHubungi admin JetWize
SANDBOX_OFFER_REQUIREDSandbox key hanya dapat membuat booking dari token offer hasil search sandboxSearch dulu dengan kunci sandbox, lalu book token offer hasil search tsb
INVALID_OFFER_IDToken offer tidak valid / kedaluwarsaLakukan search ulang lalu pakai token baru
BOOKING_EXPIREDHold sudah lewat expiresAtUlangi dari search
INSUFFICIENT_BALANCEWallet tidak cukup saat payTop-up wallet, lalu pay

Referensi cepat endpoint booking

LangkahEndpoint
SearchPOST /flights/search
Price-checkPOST /flights/price-check
Inspeksi bagasi/brandGET /flights/:offerId/fare-rules
Create bookingPOST /bookings
Pay → issuePOST /bookings/:id/pay
E-ticketGET /bookings/:id/ticket
Detail/statusGET /bookings/:id
Batal (pra-issue)POST /bookings/:id/cancel
Bagasi ekstra (pasca-issue)POST /bookings/:id/ancillaries (kind: BAGGAGE)

SDK — 8 Skenario (Node.js & Python)

Contoh kode lengkap yang menjalankan semua 8 skenario secara loop. Setiap bahasa menyertakan client yang dapat digunakan ulang (helper search, priceCheck, createBooking, pay, getTicket) beserta helper pemilihan offer (direct/transfer, dengan/tanpa bagasi), lalu satu driver yang mengiterasi ke-8 kasus.

⚠️ Sandbox vs Production: Skenario 2–8 (tanpa bagasi / RT / transfer) hanya mengembalikan variasi nyata di kunci PRODUCTION — MockSupplier sandbox hanya menghasilkan satu penerbangan langsung dengan brand bagasi "20kg". Gunakan kunci sandbox (jwz_test_...) untuk memvalidasi mekanika integrasi, lalu kunci production (jwz_live_...) untuk menguji semua 8 skenario secara penuh.

Node.js (fetch)

Client & helpers — jetwize-client.js
// jetwize-client.js
// Reusable JetWize Partner API client (Node.js 18+ native fetch)

const BASE = 'https://api.jetwize.com/api/v1/partner/v1';
const API_KEY = process.env.JETWIZE_API_KEY; // jwz_test_... atau jwz_live_...

if (!API_KEY) throw new Error('Set JETWIZE_API_KEY environment variable');

// ---------------------------------------------------------------------------
// Low-level helper
// ---------------------------------------------------------------------------
async function request(method, path, body) {
  const headers = { 'X-API-Key': API_KEY };
  if (body !== undefined) headers['Content-Type'] = 'application/json';
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers,
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) {
    const code = json?.error?.code ?? res.status;
    throw new Error(`[${code}] ${json?.error?.message ?? res.statusText}`);
  }
  return json;
}

// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
/** Search flights. Pass returnDate for round-trip; omit for one-way.
 *  route: "DIRECT" | "ALL" (default "ALL")  */
async function search(params) {
  return request('POST', '/flights/search', {
    origin:      params.origin,
    destination: params.destination,
    departDate:  params.departDate,
    returnDate:  params.returnDate,           // undefined = one-way
    passengers:  params.passengers ?? { adult: 1, child: 0, infant: 0 },
    cabinClass:  params.cabinClass  ?? 'ECONOMY',
    route:       params.route       ?? 'ALL',
  });
}

/** Confirm price is still valid before booking. */
async function priceCheck(offerId) {
  return request('POST', '/flights/price-check', { offerId });
}

/** Create a booking (hold, ~30 min).
 *  contactPhone is REQUIRED (flat field — never nested contact{}). */
async function createBooking({ offerId, contactPhone, contactEmail, passengers }) {
  return request('POST', '/bookings', {
    offerId,
    contactPhone,                             // REQUIRED — flat field
    ...(contactEmail ? { contactEmail } : {}),
    passengers,
  });
}

/** Pay (wallet) and trigger issuance saga. */
async function pay(bookingId) {
  return request('POST', `/bookings/${bookingId}/pay`);
}

/** Retrieve e-ticket signed URL (expires 15 min). */
async function getTicket(bookingId) {
  return request('GET', `/bookings/${bookingId}/ticket`);
}

// ---------------------------------------------------------------------------
// Offer-selection predicates
// ---------------------------------------------------------------------------
/** true if offer is a nonstop flight (zero transit stops). */
const isDirect   = (offer) => offer.stops === 0;

/** true if offer has at least one transit stop. */
const isTransfer = (offer) => offer.stops >= 1;

/** Heuristic: inspect each segment's baggageAllowance string.
 *  Treats values suggesting NO checked baggage as without-baggage.
 *  NOTE: baggageAllowance is a supplier free-text string — confirm edge
 *  cases via GET /flights/:offerId/fare-rules for authoritative data. */
function hasBaggage(offer) {
  const NO_BAGGAGE = /tanpa|no.?baggage|0\s*kg|kabin.?saja|cabin.?only/i;
  return offer.segments.every((seg) => {
    const ba = seg.baggageAllowance ?? '';
    return ba.trim() !== '' && !NO_BAGGAGE.test(ba);
  });
}

module.exports = { search, priceCheck, createBooking, pay, getTicket,
                   isDirect, isTransfer, hasBaggage };
Driver — run-all-cases.js
// run-all-cases.js — iterates over all 8 booking scenarios
'use strict';
const { search, priceCheck, createBooking, pay, getTicket,
        isDirect, isTransfer, hasBaggage } = require('./jetwize-client');

// ---------------------------------------------------------------------------
// Case table — 8 scenarios: OW/RT × Direct/Transfer × with/without baggage
// ---------------------------------------------------------------------------
// Each case defines:
//   label        — human-readable name
//   searchParams — params for search() (returnDate present = RT; route = DIRECT|ALL)
//   pickOffer    — predicate to select the correct offer from results[]
// ---------------------------------------------------------------------------
const CASES = [
  {
    label: '1. OW Direct — dengan bagasi',
    searchParams: {
      origin: 'CGK', destination: 'DPS', departDate: '2026-07-15',
      route: 'DIRECT',
    },
    pickOffer: (o) => isDirect(o) && hasBaggage(o),
  },
  {
    label: '2. OW Direct — tanpa bagasi',
    searchParams: {
      origin: 'CGK', destination: 'DPS', departDate: '2026-07-15',
      route: 'DIRECT',
    },
    pickOffer: (o) => isDirect(o) && !hasBaggage(o),
  },
  {
    label: '3. RT Direct — dengan bagasi',
    searchParams: {
      origin: 'CGK', destination: 'DPS', departDate: '2026-07-15',
      returnDate: '2026-07-20', route: 'DIRECT',
    },
    pickOffer: (o) => isDirect(o) && hasBaggage(o),
  },
  {
    label: '4. RT Direct — tanpa bagasi',
    searchParams: {
      origin: 'CGK', destination: 'DPS', departDate: '2026-07-15',
      returnDate: '2026-07-20', route: 'DIRECT',
    },
    pickOffer: (o) => isDirect(o) && !hasBaggage(o),
  },
  {
    label: '5. OW Transfer — dengan bagasi',
    searchParams: {
      origin: 'CGK', destination: 'DPS', departDate: '2026-07-15',
      route: 'ALL',
    },
    pickOffer: (o) => isTransfer(o) && hasBaggage(o),
  },
  {
    label: '6. OW Transfer — tanpa bagasi',
    searchParams: {
      origin: 'CGK', destination: 'DPS', departDate: '2026-07-15',
      route: 'ALL',
    },
    pickOffer: (o) => isTransfer(o) && !hasBaggage(o),
  },
  {
    label: '7. RT Transfer — dengan bagasi',
    searchParams: {
      origin: 'CGK', destination: 'DPS', departDate: '2026-07-15',
      returnDate: '2026-07-20', route: 'ALL',
    },
    pickOffer: (o) => isTransfer(o) && hasBaggage(o),
  },
  {
    label: '8. RT Transfer — tanpa bagasi',
    searchParams: {
      origin: 'CGK', destination: 'DPS', departDate: '2026-07-15',
      returnDate: '2026-07-20', route: 'ALL',
    },
    pickOffer: (o) => isTransfer(o) && !hasBaggage(o),
  },
];

// Shared passenger used for all cases
const PASSENGERS = [
  { title: 'MR', firstName: 'BUDI', lastName: 'SANTOSO',
    dateOfBirth: '1990-01-15', nationality: 'ID', type: 'ADULT' },
];

// ---------------------------------------------------------------------------
// Shared booking flow: priceCheck → createBooking → pay → getTicket
// (identical for all 8 cases — only the offer changes)
// ---------------------------------------------------------------------------
async function runFlow(offer, caseLabel) {
  console.log(`\n  [price-check] offerId=${offer.id}`);
  const pc = await priceCheck(offer.id);
  if (!pc.stillAvailable) throw new Error('Offer tidak tersedia lagi');
  if (pc.priceChanged) {
    console.log(`  [price-check] HARGA BERUBAH: ${pc.currentPrice} — lanjut otomatis`);
  }

  console.log('  [create booking]');
  const booking = await createBooking({
    offerId:      offer.id,
    contactPhone: '+628123456789',            // REQUIRED flat field
    contactEmail: '[email protected]',      // optional
    passengers:   PASSENGERS,
  });
  console.log(`  bookingId=${booking.id}  code=${booking.bookingCode}  expires=${booking.expiresAt}`);

  console.log('  [pay]');
  const result = await pay(booking.id);
  console.log(`  status=${result.booking?.status ?? result.status}`);

  console.log('  [e-ticket]');
  const ticket = await getTicket(booking.id);
  console.log(`  ticketUrl=${ticket.ticketUrl}`);

  return { booking, ticket };
}

// ---------------------------------------------------------------------------
// Main: loop over all 8 cases
// ---------------------------------------------------------------------------
(async () => {
  for (const c of CASES) {
    console.log(`\n=== ${c.label} ===`);
    try {
      const { results, sandbox } = await search(c.searchParams);
      if (sandbox) {
        console.log('  [sandbox] MockSupplier: skenario 2-8 mungkin tidak tersedia');
      }

      const offer = results.find(c.pickOffer);
      if (!offer) {
        console.warn(`  SKIP — tidak ada offer yang cocok (normal di sandbox untuk kasus ini)`);
        continue;
      }
      console.log(`  offer: id=${offer.id}  stops=${offer.stops}  baggage=${offer.segments[0]?.baggageAllowance}`);

      await runFlow(offer, c.label);
      console.log(`  DONE`);
    } catch (err) {
      console.error(`  ERROR: ${err.message}`);
    }
  }
})();

Python (requests)

Client & helpers — jetwize_client.py
# jetwize_client.py
# Reusable JetWize Partner API client (Python 3.8+)

import os
import re
import requests as _requests

BASE = "https://api.jetwize.com/api/v1/partner/v1"
_API_KEY = os.environ.get("JETWIZE_API_KEY")  # jwz_test_... atau jwz_live_...

if not _API_KEY:
    raise RuntimeError("Set JETWIZE_API_KEY environment variable")

_SESSION = _requests.Session()
_SESSION.headers.update({"X-API-Key": _API_KEY, "Content-Type": "application/json"})


# ---------------------------------------------------------------------------
# Low-level helper
# ---------------------------------------------------------------------------
def _req(method: str, path: str, body: dict | None = None) -> dict:
    r = _SESSION.request(method, f"{BASE}{path}", json=body, timeout=30)
    try:
        data = r.json()
    except Exception:
        data = {}
    if not r.ok:
        code = data.get("error", {}).get("code", r.status_code)
        msg  = data.get("error", {}).get("message", r.reason)
        raise RuntimeError(f"[{code}] {msg}")
    return data


# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
def search(origin: str, destination: str, depart_date: str,
           return_date: str | None = None,
           route: str = "ALL",
           cabin_class: str = "ECONOMY",
           passengers: dict | None = None) -> dict:
    """Search flights. Pass return_date for round-trip; omit for one-way.
    route: "DIRECT" | "ALL"  (default "ALL")"""
    body = {
        "origin":      origin,
        "destination": destination,
        "departDate":  depart_date,
        "passengers":  passengers or {"adult": 1, "child": 0, "infant": 0},
        "cabinClass":  cabin_class,
        "route":       route,
    }
    if return_date:
        body["returnDate"] = return_date  # round-trip
    return _req("POST", "/flights/search", body)


def price_check(offer_id: str) -> dict:
    """Confirm price is still valid before booking."""
    return _req("POST", "/flights/price-check", {"offerId": offer_id})


def create_booking(offer_id: str, contact_phone: str,
                   passengers: list, contact_email: str | None = None) -> dict:
    """Create a booking (hold, ~30 min).
    contact_phone is REQUIRED (flat field — never nested contact{})."""
    body = {
        "offerId":      offer_id,
        "contactPhone": contact_phone,        # REQUIRED — flat field
        "passengers":   passengers,
    }
    if contact_email:
        body["contactEmail"] = contact_email
    return _req("POST", "/bookings", body)


def pay(booking_id: str) -> dict:
    """Pay (wallet) and trigger issuance saga."""
    return _req("POST", f"/bookings/{booking_id}/pay")


def get_ticket(booking_id: str) -> dict:
    """Retrieve e-ticket signed URL (expires 15 min)."""
    return _req("GET", f"/bookings/{booking_id}/ticket")


# ---------------------------------------------------------------------------
# Offer-selection predicates
# ---------------------------------------------------------------------------
def is_direct(offer: dict) -> bool:
    """True if offer is a nonstop flight (zero transit stops)."""
    return offer.get("stops", 0) == 0


def is_transfer(offer: dict) -> bool:
    """True if offer has at least one transit stop."""
    return offer.get("stops", 0) >= 1


_NO_BAGGAGE = re.compile(
    r"tanpa|no.?baggage|0\s*kg|kabin.?saja|cabin.?only", re.IGNORECASE
)

def has_baggage(offer: dict) -> bool:
    """Heuristic: inspect each segment's baggageAllowance string.
    Treats values implying NO checked baggage as without-baggage.
    NOTE: baggageAllowance is a supplier free-text string — confirm edge
    cases via GET /flights/:offerId/fare-rules for authoritative data."""
    for seg in offer.get("segments", []):
        ba = (seg.get("baggageAllowance") or "").strip()
        if not ba or _NO_BAGGAGE.search(ba):
            return False
    return True
Driver — run_all_cases.py
# run_all_cases.py — iterates over all 8 booking scenarios
from jetwize_client import (
    search, price_check, create_booking, pay, get_ticket,
    is_direct, is_transfer, has_baggage,
)

# ---------------------------------------------------------------------------
# Case table — 8 scenarios: OW/RT × Direct/Transfer × with/without baggage
# ---------------------------------------------------------------------------
# Each entry:
#   label        — human-readable name
#   search_kw    — kwargs for search() (return_date present = RT; route = DIRECT|ALL)
#   pick         — predicate to select the correct offer from results
# ---------------------------------------------------------------------------
CASES = [
    {
        "label": "1. OW Direct — dengan bagasi",
        "search_kw": {"origin": "CGK", "destination": "DPS",
                      "depart_date": "2026-07-15", "route": "DIRECT"},
        "pick": lambda o: is_direct(o) and has_baggage(o),
    },
    {
        "label": "2. OW Direct — tanpa bagasi",
        "search_kw": {"origin": "CGK", "destination": "DPS",
                      "depart_date": "2026-07-15", "route": "DIRECT"},
        "pick": lambda o: is_direct(o) and not has_baggage(o),
    },
    {
        "label": "3. RT Direct — dengan bagasi",
        "search_kw": {"origin": "CGK", "destination": "DPS",
                      "depart_date": "2026-07-15", "return_date": "2026-07-20",
                      "route": "DIRECT"},
        "pick": lambda o: is_direct(o) and has_baggage(o),
    },
    {
        "label": "4. RT Direct — tanpa bagasi",
        "search_kw": {"origin": "CGK", "destination": "DPS",
                      "depart_date": "2026-07-15", "return_date": "2026-07-20",
                      "route": "DIRECT"},
        "pick": lambda o: is_direct(o) and not has_baggage(o),
    },
    {
        "label": "5. OW Transfer — dengan bagasi",
        "search_kw": {"origin": "CGK", "destination": "DPS",
                      "depart_date": "2026-07-15", "route": "ALL"},
        "pick": lambda o: is_transfer(o) and has_baggage(o),
    },
    {
        "label": "6. OW Transfer — tanpa bagasi",
        "search_kw": {"origin": "CGK", "destination": "DPS",
                      "depart_date": "2026-07-15", "route": "ALL"},
        "pick": lambda o: is_transfer(o) and not has_baggage(o),
    },
    {
        "label": "7. RT Transfer — dengan bagasi",
        "search_kw": {"origin": "CGK", "destination": "DPS",
                      "depart_date": "2026-07-15", "return_date": "2026-07-20",
                      "route": "ALL"},
        "pick": lambda o: is_transfer(o) and has_baggage(o),
    },
    {
        "label": "8. RT Transfer — tanpa bagasi",
        "search_kw": {"origin": "CGK", "destination": "DPS",
                      "depart_date": "2026-07-15", "return_date": "2026-07-20",
                      "route": "ALL"},
        "pick": lambda o: is_transfer(o) and not has_baggage(o),
    },
]

# Shared passenger used for all cases
PASSENGERS = [
    {"title": "MR", "firstName": "BUDI", "lastName": "SANTOSO",
     "dateOfBirth": "1990-01-15", "nationality": "ID", "type": "ADULT"},
]


# ---------------------------------------------------------------------------
# Shared booking flow: price_check → create_booking → pay → get_ticket
# (identical for all 8 cases — only the offer changes)
# ---------------------------------------------------------------------------
def run_flow(offer: dict) -> dict:
    print(f"  [price-check] offerId={offer['id']}")
    pc = price_check(offer["id"])
    if not pc.get("stillAvailable"):
        raise RuntimeError("Offer tidak tersedia lagi")
    if pc.get("priceChanged"):
        print(f"  [price-check] HARGA BERUBAH: {pc['currentPrice']} — lanjut otomatis")

    print("  [create booking]")
    booking = create_booking(
        offer_id=offer["id"],
        contact_phone="+628123456789",        # REQUIRED flat field
        contact_email="[email protected]",  # optional
        passengers=PASSENGERS,
    )
    print(f"  bookingId={booking['id']}  code={booking['bookingCode']}  expires={booking['expiresAt']}")

    print("  [pay]")
    result = pay(booking["id"])
    status = (result.get("booking") or {}).get("status") or result.get("status")
    print(f"  status={status}")

    print("  [e-ticket]")
    ticket = get_ticket(booking["id"])
    print(f"  ticketUrl={ticket['ticketUrl']}")

    return {"booking": booking, "ticket": ticket}


# ---------------------------------------------------------------------------
# Main: loop over all 8 cases
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    for c in CASES:
        print(f"\n=== {c['label']} ===")
        try:
            data = search(**c["search_kw"])
            results = data.get("results", [])
            if data.get("sandbox"):
                print("  [sandbox] MockSupplier: skenario 2-8 mungkin tidak tersedia")

            offer = next((o for o in results if c["pick"](o)), None)
            if offer is None:
                print("  SKIP — tidak ada offer yang cocok (normal di sandbox untuk kasus ini)")
                continue

            ba0 = (offer["segments"][0].get("baggageAllowance") or "-")
            print(f"  offer: id={offer['id']}  stops={offer['stops']}  baggage={ba0}")

            run_flow(offer)
            print("  DONE")
        except Exception as exc:
            print(f"  ERROR: {exc}")

Webhooks

JetWize mengirimkan notifikasi ke URL webhook Anda saat status booking berubah. Daftarkan webhook URL di dashboard Settings → Webhooks.

Event Types

EventKeterangan
booking.issuedTiket berhasil diterbitkan
booking.cancelledBooking dibatalkan
booking.failedPenerbitan tiket gagal
booking.refundedRefund diproses
Webhook Payload
POST https://your-server.com/webhook
Content-Type: application/json
X-JetWize-Signature: sha256=...

{
  "event": "booking.issued",
  "bookingId": "clx789...",
  "bookingCode": "JWZ-20260615-ABC123",
  "status": "ISSUED",
  "tickets": ["0123456789"],
  "issuedAt": "2026-06-15T07:15:00.000Z"
}

Code Examples

JavaScript / Node.js

const API = 'https://api.jetwize.com/api/v1/partner/v1';
const KEY = process.env.JETWIZE_API_KEY; // jwz_test_... atau jwz_live_...

async function searchFlights(origin, destination, date) {
  const res = await fetch(`${API}/flights/search`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': KEY,
    },
    body: JSON.stringify({
      origin, destination,
      departDate: date,
      passengers: { adult: 1, child: 0, infant: 0 },
      cabinClass: 'ECONOMY',
    }),
  });
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

// Full booking flow
async function bookFlight() {
  // 1. Search
  const { results } = await searchFlights('CGK', 'DPS', '2026-06-15');
  const offer = results[0];

  // 2. Price check
  const priceCheck = await fetch(`${API}/flights/price-check`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-API-Key': KEY },
    body: JSON.stringify({ offerId: offer.id }),
  }).then(r => r.json());
  if (!priceCheck.stillAvailable) throw new Error('Penerbangan tidak tersedia');

  // 3. Create booking
  const booking = await fetch(`${API}/bookings`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-API-Key': KEY },
    body: JSON.stringify({
      offerId: offer.id,
      passengers: [{ title: 'MR', firstName: 'BUDI', lastName: 'SANTOSO',
        dateOfBirth: '1990-01-15', nationality: 'ID', type: 'ADULT' }],
      contactPhone: '+6281234567890',
      contactEmail: '[email protected]',
    }),
  }).then(r => r.json());

  // 4. Pay
  const payment = await fetch(`${API}/bookings/${booking.id}/pay`, {
    method: 'POST',
    headers: { 'X-API-Key': KEY },
  }).then(r => r.json());

  return { booking, payment };
}

Python

import os, requests

API = 'https://api.jetwize.com/api/v1/partner/v1'
HEADERS = {
    'Content-Type': 'application/json',
    'X-API-Key': os.environ['JETWIZE_API_KEY']
}

def search_flights(origin, destination, depart_date):
    r = requests.post(f'{API}/flights/search', json={
        'origin': origin, 'destination': destination,
        'departDate': depart_date,
        'passengers': {'adult': 1, 'child': 0, 'infant': 0},
        'cabinClass': 'ECONOMY'
    }, headers=HEADERS)
    r.raise_for_status()
    return r.json()['results']

def create_booking(offer_id, passengers, contact_phone, contact_email=None):
    body = {
        'offerId': offer_id,
        'passengers': passengers,
        'contactPhone': contact_phone,  # REQUIRED — flat field
    }
    if contact_email:
        body['contactEmail'] = contact_email
    r = requests.post(f'{API}/bookings', json=body, headers=HEADERS)
    r.raise_for_status()
    return r.json()

def pay_booking(booking_id):
    r = requests.post(f'{API}/bookings/{booking_id}/pay', headers=HEADERS)
    r.raise_for_status()
    return r.json()

# Usage
offers = search_flights('CGK', 'DPS', '2026-06-15')
booking = create_booking(offers[0]['id'],
    [{'title': 'MR', 'firstName': 'BUDI', 'lastName': 'SANTOSO',
      'dateOfBirth': '1990-01-15', 'nationality': 'ID', 'type': 'ADULT'}],
    contact_phone='+6281234567890',   # REQUIRED flat field
    contact_email='[email protected]'    # optional
)
payment = pay_booking(booking['id'])

PHP

<?php
$API = 'https://api.jetwize.com/api/v1/partner/v1';
$KEY = getenv('JETWIZE_API_KEY');

function jetwize_request($method, $path, $body = null) {
    global $API, $KEY;
    $ch = curl_init("$API$path");
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            "X-API-Key: $KEY",
        ],
        CURLOPT_POSTFIELDS => $body ? json_encode($body) : null,
        CURLOPT_TIMEOUT    => 30,
    ]);
    $res  = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($code >= 400) throw new Exception("API Error $code: $res");
    return json_decode($res, true);
}

// Search
$result = jetwize_request('POST', '/flights/search', [
    'origin' => 'CGK', 'destination' => 'DPS',
    'departDate' => '2026-06-15',
    'passengers' => ['adult' => 1, 'child' => 0, 'infant' => 0],
]);
$offer = $result['results'][0];

// Book & Pay
$booking = jetwize_request('POST', '/bookings', [
    'offerId'      => $offer['id'],
    'passengers'   => [['title'=>'MR','firstName'=>'BUDI','lastName'=>'SANTOSO',
                        'dateOfBirth'=>'1990-01-15','nationality'=>'ID','type'=>'ADULT']],
    'contactPhone' => '+6281234567890',   // REQUIRED — flat field
    'contactEmail' => '[email protected]',   // optional
]);
$payment = jetwize_request('POST', "/bookings/{$booking['id']}/pay");

JetWize Partner API v1 · jetwize.com · Support: [email protected]