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.
| Header | Value |
|---|---|
client-id | Accordion’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]
}
]
}| Field | Required | Notes |
|---|---|---|
Orderid | Yes | |
CartID | Yes | With CustomerID, identifies the record |
CustomerID | Yes | A shared cart item arrives as one record per member, same CartID |
IBANumber | Yes | Must be a positive number and an existing member |
FlightDateTime | Yes | ISO 8601 with an offset — see Time zones |
locationCode | Yes | Accordion’s tunnel code, e.g. TAM, DCVA |
TotalMinutes | Yes | Minutes flown; decimals allowed |
RecordStatus | Yes | Created, Amended or Cancelled |
Instructors | No | The instructors’ IBA numbers, e.g. [54321, 65432]. Leave it out if unknown; send [] for no instructor |
FirstName, LastName, Email, Phone, ProductName, Sharecode | No | Stored 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.
| Status | When | Body |
|---|---|---|
200 | Every record stored | { valid: true, accepted, rejected: 0, rejections: [] } |
422 | Some records rejected | { valid: false, accepted, rejected, rejections: [{ CartID, CustomerID, reason }] } |
422 | Envelope unusable (no records array, empty, a record missing a field) | { valid: false, message: "Invalid payload: …" } |
400 | Body is not valid JSON | { valid: false, message: "Request body is not valid JSON." } |
400 | No client-id, or no signature header | { valid: false, message } |
404 | Unknown client-id | { valid: false, message } |
403 | Signature does not match | { valid: false, message } |
500 | Unexpected error | { valid: false, message } |
Per-record rejection reasons:
reason | Meaning |
|---|---|
missing_field | A required field is null. A field left out entirely fails the whole delivery at the envelope check instead |
missing_iba_number | IBANumber is not a positive number |
invalid_status | RecordStatus is not one of the three values |
invalid_minutes | TotalMinutes is not a non-negative number |
invalid_timestamp | FlightDateTime has no offset or does not parse |
invalid_instructors | Instructors is present but not an array of positive whole numbers |
unknown_member | No member has that IBA number |
storage_error | Valid, 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;| Situation | Written by | status / error_msg |
|---|---|---|
| Rejected before the handler (auth, envelope) | shared/middlewares/req-validate.js | fail with the message returned |
| Reached the handler | shared/middlewares/audit-history.js, after the response is sent | success, or fail with N of M records rejected; rejections holds the count per reason, e.g. {"unknown_member": 3} |
| Body is not valid JSON | nothing: it fails before any route | no row; see http.body_parse_failed below |
Structured error logs (JSON to stdout, for Datadog):
accordion.store_failed— one record could not be written; includescart_idandcustomer_idaccordion.record_rejected(warn) — one record was rejected; includescart_id,customer_idandreasonconnector.audit_history_failed— the history row could not be writtenconnector.req_validate.insert_history_failed— the same, on the validation pathconnector.signature_rejected(warn) — the signature did not match; includesvendor,client_id,endpoint,content_length, and whether the body had raw non-ASCII bytes (non_ascii_bytes) or\uXXXXescapes (unicode_escapes), never the bodyhttp.server_error— an unexpected error answered 500; includes the error,endpointandmethodhttp.body_parse_failed(warn) — the body could not be parsed; includesendpoint,client_id,content_lengthand the parseposition, never the body
Known limitations
- 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. - Which records were rejected is logged, not stored.
connector_history.rejectionsholds the count per reason (TUN-954), but not the records. Each rejected record is logged asaccordion.record_rejected(warn) withcart_id,customer_idandreason, so it can be found in Datadog for 15 days. Rows written before TUN-954 haverejectionsNULL. - Large deliveries lose their history row.
connector_history.bodyisTEXT(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 aconnector.audit_history_failedlog line remains. - The audit middleware’s comment overstates it. It says a handler that
forgets to set its audit status produces an
unknownrow; the code writes nothing. This is the mechanism behind limitation 1. - Personal data in history. Every history row keeps the whole delivery — names, emails and phone numbers — with no retention limit.
- No admin view. The admin connector report
(
/admin/reports/external/:type/history) acceptsaccordion, but the admin UI offers only Convergence and Fuse-Metrix. SQL is the only way to see the history. - The data is stored, not used. Nothing member-facing reads
connector_accordion_bookingyet. The repository has agetBookingsByMemberquery for a future staff view, with no route.
See also
- Business logic: Accordion integration rules — what is accepted, how cancellations and unmapped tunnels behave, and the product and staff sharecode list
- FuzeMetrix — the same HMAC scheme and shared connector middleware