Public REST API
One REST/JSON contract over airline NDC. Shopping, offer pricing, seats and services, the full order lifecycle and connector health — every airline normalized into one canonical shape. American Airlines is available in the sandbox today; production availability is announced per airline in the changelog.
Start here — a search that returns real offers
Two rules decide whether your first calls book or bounce.
- Search the way you sell. One-way, round trip, connections and multi-city all book. The one thing withheld from results by default is a connection where a partner airline operates a leg, because the carrier does not confirm it at booking — see the airline’s page. Mind
max_stops: absent means “no constraint”,0means “non-stop only”. - Price the offer before you book it. The offer ID from search is transient and the airline will not book it.
POST /v1/offers/pricemints a new ID, and only that one is accepted byPOST /v1/orders.
export NORBA_KEY="nbr_live_…"
curl --request POST \
--url https://api.norba.io/v1/shopping/offers \
--header "X-API-Key: $NORBA_KEY" \
--header "Content-Type: application/json" \
--data '{
"origin": "BCN",
"destination": "JFK",
"departure_date": "2026-10-20",
"adults": 1,
"cabin_class": "economy",
"currency": "EUR",
"max_stops": 0
}'A search like this returns real offers, every one with a resolved segment graph, fare family and parsed fare rules.
The booking flow, end to end
| Step | Call | What it gives you |
|---|---|---|
| 1 | POST /v1/shopping/offers | Offers with transient AA-X… IDs. |
| 2 | GET /v1/offers/{offerID}/services | Ancillary catalogue. An empty list is a normal answer. |
| 3 | GET /v1/offers/{offerID}/seats | Seat map per segment, with prices and features. |
| 4 | POST /v1/offers/price | The bookable AA-P… ID, guaranteed ~20 min. |
| 5 | POST /v1/orders | The reservation, with the carrier's PNR. Seats and ancillaries go in this call — and payment, if you want to book and ticket in one step. |
| 6 | POST /v1/orders/{id}/commit | Payment and ticketing for an order created as a hold. Read tickets[] on the response. |
T1 you sent comes back as something like PAX96101. Paid-seat assignment on an existing order is not available on every carrier; the airline’s own page says whether it is.Integrated Airlines
American Airlines supports all eleven NDC operations through the API: AirShopping, OfferPrice, ServiceList, SeatAvailability, OrderCreate, OrderRetrieve, OrderChange, OrderCancel, OrderReShop, OrderQuote and OrderCommit. Where a carrier does not offer an operation, the API answers with a clear error rather than fabricated data.
| Airline | IATA | NDC | Bookable coverage |
|---|---|---|---|
| American Airlines | AA | 24.1 | Global — one-way, round trip and multi-city bookable |
New carriers are announced in the changelog as they become available.
Conventions
| Convention | Details |
|---|---|
| Base URL | https://api.norba.io — every path below is prefixed with /v1/. |
| Auth | X-API-Key: <your-key> on every single endpoint except GET /v1/health and GET /v1/health/ready. Not a Bearer token. |
| Times | RFC 3339 (2026-10-20T08:30:00Z). Calendar dates in requests are plain YYYY-MM-DD. |
| Money | Totals are plain numbers plus a currency field; Money objects are { "amount": 380.62, "currency": "EUR" }. |
| IDs | search_id is a ULID (srch_…). Offer and order IDs are airline-owned opaque strings — never parse them. |
| Errors | NDC endpoints return { "error": "<human sentence>" } — one field, no code, no request_id. Aviation Data returns { data, meta, errors[] }. |
| Case | Request bodies are snake_case throughout. The payment / change / reshop / commit blocks accepted CamelCase in early versions; single-word CamelCase keys still decode. |
payment (on order create), and the whole bodies of PUT /v1/orders/{id}, POST /v1/orders/{id}/commit and POST /v1/orders/{id}/reshop, used to be CamelCase and silently dropped any snake_case key you sent. They are now snake_case like the rest of the API.Single-word keys are unaffected in both directions, so
Action and Amount still decode. What changed is the multi-word ones: SegmentIDs, NewDepartureDate and ExpiryMonth no longer match — send segment_ids, new_departure_date and expiry_month. That break is deliberate: it turns a field that used to disappear without a word into one that visibly does not exist.# AA offer IDs contain | and . — percent-encode them in a URL path.
# AA-P2E80EC16-51FD-48AD-924B-1|P2E80EC16-51FD-48AD-924B-1-1
# becomes
# AA-P2E80EC16-51FD-48AD-924B-1%7CP2E80EC16-51FD-48AD-924B-1-1
curl --request GET \
--url "https://api.norba.io/v1/offers/AA-P2E80EC16-51FD-48AD-924B-1%7CP2E80EC16-51FD-48AD-924B-1-1/seats" \
--header "X-API-Key: $NORBA_KEY"Public endpoints (no auth)
| Method | Path | Description |
|---|---|---|
| GET | /v1/health | Liveness probe. Returns 200 { "status": "ok" }. One of only two endpoints that do not need an API key (the other is the readiness probe below) — and it deliberately checks nothing, so a 200 does not imply airline connectivity. |
| GET | /v1/health/ready | Readiness probe. Answers 503 when the service cannot actually serve traffic. This is what a deploy gate should look at. |
Shopping
| Method | Path | Description |
|---|---|---|
| POST | /v1/shopping/offers | Search flights across every connected airline. One-way, round trip and 2–5 bound multi-city; passenger mix, cabin, currency and max_stops. Rate limited to 60/min per agency by default — see Rate limits and quota below. |
| POST | /v1/shopping/offers/stream | The same search as Server-Sent Events, so you can render offers as each airline answers. Events: search → offers (0..n) → done | error. |
| POST | /v1/offers/price | Confirm the price and mint the bookable offer ID. Mandatory before booking — the shopped ID is rejected by the airline. |
| POST | /v1/offers/quote | The whole checkout total — fare plus any seats and ancillaries you intend to buy, priced by the same engine that will charge the order. Cheap to call on every basket change: no airline call, no reservation. |
| GET | /v1/offers/{offerID}/services | Ancillary catalogue for an offer — or for an existing order, by passing the order ID. An empty list is a normal, successful answer: some fares carry no à-la-carte services. |
| GET | /v1/offers/{offerID}/seats | Seat map with rows, availability, price bands and decoded features, per segment. Accepts an order ID too. |
SearchRequest body
{
"origin": "BCN", // IATA code; required unless "legs" is used
"destination": "JFK",
"departure_date": "2026-10-20", // YYYY-MM-DD; a past date is refused with 400
"return_date": "2026-10-27", // optional — makes it a round trip (2 segments)
"adults": 1, // must be >= 1
"children": 0, // ages 2-11
"infants": 0, // under 2, no seat; more than "adults" is a 400
"cabin_class": "economy", // economy | premium_economy | business | first
"currency": "EUR", // ISO 4217
"max_stops": 0 // absent = no constraint; 0 = non-stop only
}0 means “non-stop only”. Sending 0 by accident makes a route the airline serves with one connection look like a route it does not fly at all.Multi-city itineraries
{
"adults": 1,
"cabin_class": "economy",
"legs": [
{ "origin": "MAD", "destination": "JFK", "departure_date": "2026-10-20" },
{ "origin": "JFK", "destination": "LAX", "departure_date": "2026-10-24" },
{ "origin": "LAX", "destination": "MAD", "departure_date": "2026-10-28" }
]
}2–5 bounds, flown in order, chronological dates, each leg’s origin and destination must differ. legs replaces origin/destination/departure_date/return_date, and one offer covers the whole itinerary.
Orders
| Method | Path | Description |
|---|---|---|
| POST | /v1/orders | Create an order from a PRICED offer plus passengers and contact — with seats and ancillaries in the same call. Returns the order with the carrier's PNR, created as an unpaid hold whose expires_at is the airline's payment time limit. |
| GET | /v1/orders | List orders stored for your agency, newest first. Filters: status, airline, pnr. Keyset pagination — limit (1-100, default 20) and cursor, not page numbers. Needs a key with an agency behind it. |
| GET | /v1/orders/{orderID} | Full order state — passengers with airline-assigned IDs, itinerary, seats, ancillaries, tickets and payments. Answered from Norba's copy; add ?refresh=true to ask the airline and store what it returns. An id your agency does not hold is 404. |
| PUT | /v1/orders/{orderID} | Raw OrderChange. action: ticketing | ancillaries | reissue | cancel_segments | complete_payment. The airline is inferred from your stored order; ?airline= is optional and only checked for a mismatch. |
| DELETE | /v1/orders/{orderID} | Cancel at the airline and verify it actually closed. Returns 204 with no body. Airline inferred from the stored order, same as the other servicing calls below. |
| POST | /v1/orders/{orderID}/quote | Reprice the order: original fare, new fare, penalty, difference. An unchanged order returns an even quote. |
| POST | /v1/orders/{orderID}/commit | Settle payment on a hold and finalise an upsell. This is the payment endpoint — there is no /pay. Read tickets[] on the response: until it is non-empty, no ticket exists. |
| POST | /v1/orders/{orderID}/reshop | Alternatives for a voluntary change (new dates) or a cancellation quote (empty body or partial_cancel). Each reshop offer's total_amount is the difference against the order — positive to pay, negative owed back. |
Order create request
{
"offer_id": "AA-P2E80EC16-51FD-48AD-924B-1|P2E80EC16-51FD-48AD-924B-1-1",
"airline": "AA",
"passengers": [
{
"id": "T1",
"type": "ADT",
"first_name": "MARIA",
"last_name": "GARCIA",
"date_of_birth": "1985-04-12",
"gender": "F",
"document": {
"type": "P",
"number": "XDA123456",
"issuing_country": "ES",
"expiry_date": "2032-05-30",
"nationality": "ES"
},
"contact": { "email": "traveller@example.com", "phone": "34600111222" }
}
],
"contact": { "email": "traveller@example.com", "phone": "34600111222" },
"payment": { "method": "CARD", "currency": "EUR", "amount": 380.62 }
}Note first_name/last_name (not given_name/family_name), issuing_country for the document, and thepayment block, which is optional — omit it entirely to create an unpaid hold, which is the normal agency flow.
Servicing
The structured post-booking surface is a quote → confirm pair for changes and cancellations, plus refund endpoints. There are no /orders/{id}/seats, /ancillaries or /ssrs endpoints — seats and ancillaries are sold inside POST /v1/orders, and travel documents go in the passenger objects there.
| Method | Path | Description |
|---|---|---|
| POST | /v1/orders/{orderID}/change | Quote a voluntary change. Returns a ChangeQuote. The body is accepted but not acted on — the whole order is repriced. |
| POST | /v1/orders/{orderID}/change/confirm | Accept a reshop offer by offer_id and reissue. When the change carries an add-collect, use PUT /v1/orders/{orderID} with action reissue and a payment block instead, so the difference is paid in the same call. |
| POST | /v1/orders/{orderID}/cancel | Quote a cancellation — refund and penalty amounts, valid 30 minutes. Changes nothing despite being a POST. Derived from an order reprice, not an airline refund quote. |
| POST | /v1/orders/{orderID}/cancel/confirm | Cancel and return the updated order with status: cancelled. Use this over DELETE when you want the order object back. |
| GET | /v1/orders/{orderID}/refund-eligibility | Whether a refund is possible and for how much. A GET, so it is not blocked by the booking feature gate. Derived, and the deadline is a flat 24 hours. |
| POST | /v1/orders/{orderID}/refund | Cancel the order and record it as refunded. Returns 202. No money is moved and no amount is quoted — reconcile settlement separately. |
?airline= is still accepted for backward compatibility, but it is now optional and only checked for agreement — a value that names a different carrier than the one holding the booking is refused with 400 airline does not match the order, since that is either a client bug or an attempt to point a servicing call at an airline that never issued it.Connector health
| Method | Path | Description |
|---|---|---|
| GET | /v1/monitoring/airlines | Per-connector health board, refreshed on a schedule rather than probed per request. The check never reaches an airline, so polling it does not touch your look-to-book ratio — but each call is a metered data request like any other. |
| GET | /v1/monitoring/airlines/{iataCode} | The same health record for one carrier. |
Worth wiring up early: flight search answers 200 with an empty offers array both when there is genuinely no availability and when the route filter dropped the carrier, so search alone cannot tell you a connector is unreachable.
Error codes
NDC endpoints return, at minimum, a single-field body — { "error": "<human sentence>" } — carrying the airline’s own message on a 502, with no request_id in the body; correlate with the X-Request-Id header instead. A few responses add a stable field on top of it: an unrecognised order id carries code: "order_not_found" (and did_you_mean when one of your own orders is a close typo), and the quota/feature-gate responses covered below carry reason. Aviation Data endpoints are different again: they use the envelope { data, meta, errors[] } with errors[{ code, message }] inside it.
| HTTP | Class | Meaning |
|---|---|---|
| 400 | Validation | Your request body failed handler validation, before any airline is called. The message says which field: adults must be >= 1, origin must be a 3-letter IATA code, origin and destination must differ, departure_date must not be in the past, infants must not outnumber adults. |
| 401 | Auth | missing X-API-Key or invalid api key. Also returned by GET /v1/orders when the key has no agency behind it. |
| 402 | Quota | A standing check failed — reason is payment_required, payment_method_required, delinquent or past_due. Carries reason, message, plan_code and the billing period. See Billing & Payments. |
| 403 | Email not verified | reason: email_verification_required — the account has not verified its email address, so it gets no free allowance. Open the verification link, or add a card. This is the 403 a new integrator hits first. Same structured body as 402. |
| 403 | Unknown plan | reason: unknown_plan — the account's plan code matches nothing in the pricing catalogue. Same structured body as 402. |
| 404 | Not found | code: order_not_found — no order with that id in your agency's store, whatever ?airline= you send. Carries did_you_mean when one of your own orders is a one-character typo away. Or offer no longer available at airline. |
| 429 | Rate limit | More than 60 searches in the last minute (Retry-After: 60), or reason allowance_burst — more calls in two minutes than the free requests you have left (Retry-After: 120). Nothing is owed; retry. |
| 502 | Airline error | The carrier refused the request; the message is the airline's own text, e.g. [230000002] Invalid or Expired Offer. Also covers all airline connections failed. |
| 503 | Feature gate | coming_soon — order writes are disabled in this environment. Carries Retry-After: 3600. Reads keep working. |
BARCELONA, a past departure date and more infants than adults are all refused by the handler with 400 and a message naming the field — origin and destination must differ, origin must be a 3-letter IATA code, departure_date must not be in the past, infants must not outnumber adults. Nothing reaches a carrier first, so 502 all airline connections failed — please retry later is a genuine carrier or connectivity failure and should be treated as one. A route the carrier simply does not fly is neither: the route filter drops the connector and the search answers 200 with offers: null — note null, not an empty array, so guard before reading .length. Validate airport codes yourself against the airports endpoint.Rate limits and quota
| Endpoint | Limit | Scope |
|---|---|---|
| POST /v1/shopping/offers, /stream | 60 / min (default) | Per agency, sliding window — adjustable per account |
| POST /v1/shopping/offers, /stream | Daily + monthly ceiling, look-to-book policy | Contractual limits some airlines require, not a capacity limit — most agencies have none set |
| POST /v1/orders | Monthly order ceiling | Contractual, when agreed for the account — most agencies have none |
| /v1/aviation/*, /v1/reference/* | No rate limit | They never reach an airline — but every call is still a metered data request: it counts against the allowance, and €0.0025 past it |
| Everything else | no explicit limit | Metered against your plan quota |
There is no platform capacity limit. What is enforced on shopping and booking exists because airlines police the look-to-book ratio — searches per booking — and throttle or fine agencies that drift: the per-minute search rate, the daily/monthly search and order ceilings, and the look-to-book policy (an X-Norba-L2B warning header before any throttle) are the same idea over different windows. An identical search repeated within a short window is served from cache and never reaches the airline, so caching works in your favour here. On 429 the response carries a Retry-After header.
What consumes the quota, what a 402 means, how the free allowance and any card on file interact with booking, and how usage settles into an invoice each month are covered in full on Billing & Payments. The session-authenticated routes that manage a card and read invoices — /v1/dev/billing/* and the public GET /v1/dev/pricing rate card — are covered there too; they take a developer-portal session cookie, not an X-API-Key, so they are not part of this page’s contract.
Full machine-readable contract
The complete OpenAPI 3.0 document is published at /openapi.yaml and covers every schema referenced here, including the Aviation Data envelope. Generate a client from it, or load it into your API tooling.