Skip to Content
IntegrationsAccordion

Accordion

Accordion send a daily batch of flights completed by IBA members at US iFLY tunnels. The data comes from their Convergence extracts. The feed is one-way: we validate and store each record, and nothing is sent back except the response to the delivery.

Unlike FuzeMetrix, Convergence and Tunn3l, this is not a check-in integration. It has no member-facing effect yet — see Known limitations.

Endpoint

POST https://api.tunnelflight.com/api/external/accordion

Accordion call it once a day at 07:30 Pacific: 14:30 UTC while Pacific Daylight Time applies, 15:30 UTC after the clocks go back.

Authentication

The same HMAC scheme as the other connectors.

HeaderValue
client-idAccordion’s client id from the connector table
authorization (or token)HMAC-SHA256 of the request body exactly as sent, hex-encoded, keyed with the client’s secret
crypto.createHmac('SHA256', clientSecret).update(rawRequestBody).digest('hex')

The signature is checked against the bytes received. A signature over JSON.stringify of the parsed body is also accepted, which is how the other connectors have always been checked. Until October 2026 only that form was checked, so a batch whose JSON encoder escaped a non-ASCII name ("Jos\u00e9") was refused with 403: on 19 and 25 October 2026, for example.

That fix was not enough. On 27 October 2026 every batch containing an accented name was still refused, and every one without was accepted, so Accordion sign a different encoding of those names than they send. No booking with an accented name has been stored since go-live. Accordion have been asked to sign the bytes they send and to resend; each refusal is now logged as connector.signature_rejected and alerts (TUN-915).

Payload

A batch envelope, not a single booking:

{ "records": [ { "Orderid": 900001, "CartID": 990001, "CustomerID": 990001, "IBANumber": 12345, "FirstName": "Sam", "LastName": "Example", "Email": "sam@example.com", "Phone": "", "FlightDateTime": "2026-09-28T18:30:00-07:00", "locationCode": "TAM", "ProductName": "01 Minute Block", "Sharecode": "01BLOCK", "TotalMinutes": 15, "RecordStatus": "Created", "Instructors": [54321, 65432] } ] }
FieldRequiredNotes
OrderidYes
CartIDYesWith CustomerID, identifies the record
CustomerIDYesA shared cart item arrives as one record per member, same CartID
IBANumberYesMust be a positive number and an existing member
FlightDateTimeYesISO 8601 with an offset — see Time zones
locationCodeYesAccordion’s tunnel code, e.g. TAM, DCVA
TotalMinutesYesMinutes flown; decimals allowed
RecordStatusYesCreated, Amended or Cancelled
InstructorsNoThe instructors’ IBA numbers, e.g. [54321, 65432]. Leave it out if unknown; send [] for no instructor
FirstName, LastName, Email, Phone, ProductName, SharecodeNoStored as sent

Responses

Accordion decide success from the HTTP status and read the body only on failure, retrying once on any non-2xx. So a delivery with any rejected record answers 422, not 200 — a 200 carrying rejections would never be read, and those records never resent. The accepted records are stored either way, and because storage is idempotent the retry re-applies them harmlessly.

StatusWhenBody
200Every record stored{ valid: true, accepted, rejected: 0, rejections: [] }
422Some records rejected{ valid: false, accepted, rejected, rejections: [{ CartID, CustomerID, reason }] }
422Envelope unusable (no records array, empty, a record missing a field){ valid: false, message: "Invalid payload: …" }
400Body is not valid JSON{ valid: false, message: "Request body is not valid JSON." }
400No client-id, or no signature header{ valid: false, message }
404Unknown client-id{ valid: false, message }
403Signature does not match{ valid: false, message }
500Unexpected error{ valid: false, message }

Per-record rejection reasons:

reasonMeaning
missing_fieldA required field is null. A field left out entirely fails the whole delivery at the envelope check instead
missing_iba_numberIBANumber is not a positive number
invalid_statusRecordStatus is not one of the three values
invalid_minutesTotalMinutes is not a non-negative number
invalid_timestampFlightDateTime has no offset or does not parse
invalid_instructorsInstructors is present but not an array of positive whole numbers
unknown_memberNo member has that IBA number
storage_errorValid, but the database refused it (our problem, not theirs)

Idempotency

Records are upserted on (CartID, CustomerID). CartID alone is not unique: a cart item shared by two members arrives as two records with the same CartID. A replay or an amendment restates the row; first_seen_at never changes and last_seen_at moves on every restatement.

Time zones

Accordion send every flight on a Pacific clock, whichever tunnel it was at. Convergence converts the tunnel’s local time to Pacific, and Accordion pass the value through unchanged. The offset follows daylight saving by flight date: -07:00 during Pacific Daylight Time, -08:00 after it — so a delivery on 2 November that includes 31 October flights still carries -07:00 for those.

We apply the offset on arrival and store UTC in flight_at_utc, keeping the original offset in flight_offset for audit. Tunnel-local time is derived from the tunnel’s own time zone when read, never from the sender’s clock. A timestamp with no offset is rejected rather than guessed.

Storage

connector_accordion_booking holds one row per (CartID, CustomerID), never deleted. Alongside the payload fields it stores the resolved member_id and tunnel_id, flight_at_utc and flight_offset, is_withdrawn, and first_seen_at / last_seen_at.

Tunnel codes resolve through connector_tunnel_mapping.external_code (vendor = 'accordion'). The 36 codes were seeded by hand in migration 0013_accordion_connector.sql, matched on city and state because none match our names textually (their DCVA is our Loudoun iFLY). Five are discontinued locations kept for the year-to-date backfill. Miami, Oceanside and Wilmington are absent from Accordion’s export altogether.

Logging and audit

connector_history gets one row per request, including the full request body:

SELECT id, status, error_msg, ips, created_at, LENGTH(body) AS body_bytes FROM connector_history WHERE vendor = 'accordion' ORDER BY created_at DESC LIMIT 10;
SituationWritten bystatus / error_msg
Rejected before the handler (auth, envelope)shared/middlewares/req-validate.jsfail with the message returned
Reached the handlershared/middlewares/audit-history.js, after the response is sentsuccess, or fail with N of M records rejected; rejections holds the count per reason, e.g. {"unknown_member": 3}
Body is not valid JSONnothing: it fails before any routeno row; see http.body_parse_failed below

Structured error logs (JSON to stdout, for Datadog):

  • accordion.store_failed — one record could not be written; includes cart_id and customer_id
  • accordion.record_rejected (warn) — one record was rejected; includes cart_id, customer_id and reason
  • connector.audit_history_failed — the history row could not be written
  • connector.req_validate.insert_history_failed — the same, on the validation path
  • connector.signature_rejected (warn) — the signature did not match; includes vendor, client_id, endpoint, content_length, and whether the body had raw non-ASCII bytes (non_ascii_bytes) or \uXXXX escapes (unicode_escapes), never the body
  • http.server_error — an unexpected error answered 500; includes the error, endpoint and method
  • http.body_parse_failed (warn) — the body could not be parsed; includes endpoint, client_id, content_length and the parse position, never the body

Known limitations

  1. An unexpected error leaves no history row. If something throws in the service (for example the database dropping during the member lookup), the controller answers 500 with a generic message and logs http.server_error, but no history row is written because the audit status was never set. Accordion see a 500 and retry once.
  2. Which records were rejected is logged, not stored. connector_history.rejections holds the count per reason (TUN-954), but not the records. Each rejected record is logged as accordion.record_rejected (warn) with cart_id, customer_id and reason, so it can be found in Datadog for 15 days. Rows written before TUN-954 have rejections NULL.
  3. Large deliveries lose their history row. connector_history.body is TEXT (64 KB), roughly 190 records. Daily volumes fit, but a catch-up batch after missed days would overflow it; the bookings still store, and only a connector.audit_history_failed log line remains.
  4. The audit middleware’s comment overstates it. It says a handler that forgets to set its audit status produces an unknown row; the code writes nothing. This is the mechanism behind limitation 1.
  5. Personal data in history. Every history row keeps the whole delivery — names, emails and phone numbers — with no retention limit.
  6. No admin view. The admin connector report (/admin/reports/external/:type/history) accepts accordion, but the admin UI offers only Convergence and Fuse-Metrix. SQL is the only way to see the history.
  7. The data is stored, not used. Nothing member-facing reads connector_accordion_booking yet. The repository has a getBookingsByMember query for a future staff view, with no route.

See also

Last updated on