Skip to Content
Living documentation — last reviewed 2026-05-28
RunbooksCardcom outage / unknown charge

Cardcom outage / unknown charge

What a Cardcom outage looks like from Taikan, what the system does on its own, what a human has to do, and the 2026-10-26 rehearsal that produced this page.

How it presents

SignalWhat you see
SentryMember billing blocked: transaction=… reason=unknown and Member billing requires reconciliation (kind member-billing.reconciliation), one pair per held attempt; Member billing held for an organization once per organization and reason for the members queued behind it. During the outage itself: Cardcom circuit opened (kind cardcom.circuit) and Cardcom API call failed (kind cardcom.api, tags endpoint, failure, httpStatus), one issue per endpoint and failure kind, at most one event per 10 minutes per instance.
Better Stack → Slack #alertsMissed heartbeat Taikan member billing catch-up. The collector pings it only when a pass moved something or had nothing due; a pass that selected rows and held them all does not ping. It stays red until every hold is resolved.
Render logs (taikan-api)Cardcom call failed with the HTTP status and the first 300 characters of the body (an IIS 500 - Internal server error. page during an outage). Every minute: Billing obligation held (reason, scope, org/subscription/membership) and Billing catch-up heartbeat withheld listing blockedSubscriptionIds. Every 5 minutes: Billing recovery could not settle the charge with the lookup’s answer.
Admin appHeld Charges lists every held attempt across organizations.
Gym dashboardAction Center row charges need your decision (finance.billing_attention), owners and admins only, linking each member’s finance tab.

What the system does on its own

  • Platform-wide circuit (CardcomCircuitService, Redis, fail-open). A transport failure on any Cardcom call — network error, timeout, 5xx, unreadable body, or an HTTP 429 whatever its body; never another coded Cardcom answer — pauses every protected collector for 2 minutes (Cardcom circuit opened in the logs and one Sentry warning). The catch-up logs Billing catch-up heartbeat withheld: Cardcom circuit open and withholds its heartbeat, so the outage still pages. When the pause lapses, the first collector runs a read-only lookup for an id that cannot exist; a coded answer closes the circuit, a transport failure re-opens it. Recovery is proven by a lookup, never by a debit. Without Redis the circuit reads closed and the per-terminal hold below is the only guard.

  • The charge that hit the outage is recorded as outcome unknown: money may or may not have moved, so it is held, never declined, never retried blindly. See billing resilience.

  • One unknown attempt blocks the whole terminal: nothing else at that organization is charged until it is resolved. Charges go out one at a time per terminal, so an outage exposes at most one member per organization.

  • Every 5 minutes recoverBillingAttempts asks Cardcom for the attempt by its external id (GetTransactionByExternalUniqTran). A settled debit is applied automatically and the hold clears. Any other answer leaves the hold in place: a 9998 ExternalUniqTranId not found is not read as “not charged”.

  • Every minute the catch-up revisits the held row, logs the hold and moves on. No new debit is made.

Send rate

Collection is sequential by construction: the catch-up runs under one advisory lock, visits at most 20 organizations × 5 rows per minute pass, and one claim is in flight per terminal at a time; the daily sweep is also advisory-locked and sequential. One organization is therefore charged at most 5 members per minute (Kinetics’ ~100 presales take about 20 minutes), and the platform sends roughly one charge at a time. There is no retry of a charge on a transport failure or 429: a resend without Cardcom’s duplicate-protection guarantee could debit twice, so the attempt is held instead. Cardcom’s actual limits are question 8 in the support template below.

What each Cardcom answer means to Taikan

Every call goes through one client (CardcomProvider.post), so the rules below hold for all endpoints. The money column is what a charge becomes; other endpoints map the same way (a refund becomes unknown with a manual-refund task, a webhook lookup becomes a 503 so Cardcom redelivers, a page or document becomes a plain failure the caller reports).

Cardcom answersClassified asCharge becomesSentryCircuit
2xx, ResponseCode 0 with a transaction idsuccesssettled——
2xx, ResponseCode 4issuer declinedeclined → debt / dunning——
2xx, any other code (5004 document, 700, …)provider refusalrejected: that member held, terminal stays openreconciliation alert—
2xx, 608 (duplicate), 0 without an id, no codeambiguousunknown: terminal heldreconciliation alert—
4xx / 5xx with Cardcom’s JSON envelopeCardcom answered, request not accepteddurable path: unknown (conservative — see Cardcom question 9); legacy path: failed charge— (logged as a call)—
4xx / 5xx without the envelope (IIS error page, gateway error)Cardcom downunknown on the durable path; the legacy path throws and its caller keeps the row in flightCardcom API call failed (http_error)trips
429, any bodythrottledas the row aboverate_limited, with Retry-Aftertrips
timeout (15 s) / network errorCardcom unreachableas the row abovetimeout / networktrips
2xx that is not JSONCardcom brokenas the row aboveinvalid_bodytrips

The legacy (non-durable) charge path is used by staff enrollment, plan change, early renewal, the cancellation tail and platform billing (Taikan charging studios). It has no external id to reconcile by, so a transport failure is thrown to the caller and the row stays pending with charge_started_at set — the in-flight shape of in-flight charges, settled by hand against the Cardcom portal. It is never reported as a declined card: that is the path to a second debit. The request-driven writers (member renewal, early renewal, staff enrollment, plan change) answer 409 charge_outcome_unknown with the pending transaction id; the daily sweep counts the row as an error and moves on.

A desk or debt charge while the circuit is open is refused before any claim exists (409 billing_provider_unavailable): staff see “provider unavailable”, not money to reconcile.

Reading the answers

Cardcom answers the lookup withMeaningDo
HTTP 500 / timeout / network errorCardcom is down. The original charge most likely never reached them, but nothing proves it yet.Wait. Nothing is charged while the hold stands. Open a Cardcom ticket (support@secure.cardcom.co.il, Sun–Thu 8:30–17:30, Fri 8:00–12:00 Israel time).
HTTP 429Cardcom throttled us. The circuit pauses collection for 2 minutes (failure: rate_limited); the throttled charge itself is held like any unanswered one.Same as a 500 for the held charge. If it repeats on a normal run, the send rate above exceeds Cardcom’s limit: raise it with them before lowering ROWS_PER_ORG.
HTTP 400, ResponseCode 9998, “not found - there is not successful transaction”Cardcom has no successful transaction for that external id.Get written confirmation from Cardcom (their ticket is the reference), then release the hold and authorize the retry (below).
HTTP 200, ResponseCode 0, a TranzactionIdThe charge went through.Nothing: the next recovery pass settles it. Check provider on the admin page does it now.
Anything else (ResponseCode 608, amount or terminal mismatch)Evidence that does not match the attempt.Escalate to engineering before releasing.

Resolving a hold

Two surfaces run the same service, locks, provider veto and audit trail:

  • Admin app → Held Charges (/held-charges, platform admins). Lists holds across organizations with the member, amount, Cardcom id (ExternalUniqTranId), terminal and the last provider answer. Per row: Check provider, Release…, Authorize retry. Select several rows to release them on one Cardcom confirmation (an incident ticket covers every charge it swallowed) and authorize their retries together. API: /admin/billing/held-charges (GET, GET :id, POST :id/reconcile, POST :id/confirm-no-debit, POST :id/authorize-retry, bulk POST confirm-no-debit and POST authorize-retry). Audited in audit_logs (the admin interceptor) and as an org audit_events row attributed to the operator’s Taikan user with authority: platform_admin.
  • Gym dashboard → member finance tab → Review billing (organization owner). Same form, one attempt at a time. See billing resilience › Operator reconciliation.

The release form needs Cardcom’s confirmation reference (6–120 characters: letters, digits, . _ / -; a ticket number such as 649495 qualifies), the confirmation time (after the charge started, not in the future), and the three attestations. The server asks Cardcom once more before releasing: a settled debit vetoes the release and is recorded as paid. Authorize retry schedules one technical retry 5 minutes later (then 15, then 60) that reuses the original external id, so a charge that did settle after all comes back as the original instead of a second debit.

Message to Cardcom support

Include: terminal number, ExternalUniqTranId, amount and currency, the time in Israel time, the endpoint (/api/v11/Transactions/Transaction) and what it answered. Ask: (1) was there an incident and from when to when; (2) confirm in writing no debit and that it cannot settle later; (3) can a Transaction request that returned 500 still have debited the card; (4) is 9998 final right after an incident; (5) resending the same ExternalUniqTranId with ExternalUniqUniqTranIdResponse: true — is it guaranteed to return the original and never debit twice; (6) maintenance windows; (7) status page; (8) rate limits for 100+ token charges in 20 minutes; (9) which HTTP statuses the v11 API uses — in particular, can a 4xx that carries a ResponseCode ever mean the transaction was processed, and does a 429 guarantee it was not.

The external id is mb_ + the transaction id’s 128 bits, base64url (BillingAttemptService.collect); it is shown on the admin page and in the Review billing dialog.

Rehearsal: 2026-10-26

Full postmortem, including the vendor response and open items: Incident 2026-10-26: Cardcom outage.

The gym was Taikan’s own test organization with one 2.00 ILS test renewal, on purpose, six days before Kinetics’ first live collection. Times UTC (Israel = UTC+2).

TimeEvent
02:01:00Catch-up charges the renewal. Cardcom answers HTTP 500 in ~200 ms (their incident, later confirmed in ticket #649495 as affecting all customers). Recorded unknown; terminal held. Sentry TAIKAN-BACKEND-7V/7W.
02:15Better Stack heartbeat alert in #alerts.
02:15–05:30Every lookup gets HTTP 500. Until 04:11 the adapter swallowed the error, so nothing said why.
04:06–05:04Observability shipped during the incident: #640 (log every Cardcom call and every hold decision, keep the cause), #641 (cap Sentry reports per endpoint), #642 (no unlabeled Sentry events). Each merged without waiting for CI.
04:15First lookup after #640 shows the IIS 500 page: cause confirmed from our side.
05:35Cardcom answers again: 9998 not found. No money had moved.
04:21 / 06:43Ticket #649495 opened; at 06:43 (08:43 Israel) Cardcom confirms a platform-wide outage and that it is fixed.
07:12:14Owner releases the hold (Review billing). The server’s final lookup still says not found.
07:12:28Owner authorizes the retry. Until 07:17 the log reads collection_paused_by_owner — a mislabel for “retry scheduled”.
07:18:01Retry charges 2.00 ILS (Cardcom 267721373, same external id). Receipt #4 emailed. Catch-up reports nothing due at 07:19.

Detection to resolution: 5 h 17 min, of which ~3.5 h was Cardcom down and the rest was waiting for a human with a vendor confirmation.

What held: no double charge was possible, the terminal stopped after one attempt, the retry reused the original id, both alert channels fired, the owner flow worked first time.

What failed, and what changed:

GapChange
Only the gym owner could release, with a Cardcom ticket in hand. At scale that is one vendor ticket and one owner per organization.Platform-admin Held Charges page and API, single and bulk (ADR-0026).
The gym was told nothing: no Action Center row, task, email or banner. Launch gate 4 of the runbook (owner notification during a terminal-wide outage) had never been exercised.Action Center finance.billing_attention, urgent, linking each member’s Review billing.
No log said why a charge was unknown, why a row stayed held, or what the recovery lookup got back.#640–#642, live during the incident.
Every organization sends one charge into an outage before its own terminal blocks.Platform-wide Cardcom circuit: a transport failure on any Cardcom call pauses every protected collector for 2 minutes; the first collector afterwards runs a read-only probe before any charge.
provider_circuit_open alerts once per subscription waiting behind a hold, up to ~99 Sentry issues for Kinetics.Organization-scoped holds alert once per organization and reason (Member billing held for an organization).
A scheduled retry inside its wait reads collection_paused_by_owner and pages.retry_scheduled: logged at info, never alerted.
Cardcom’s coded “not found” counted as an API failure in Sentry.A non-2xx carrying Cardcom’s JSON envelope is logged as a call, never reported as an outage and never trips the circuit.
Nightly charges run at 04:00 Israel time, outside Cardcom’s support hours, and apparently inside their maintenance window.Open: waiting on Cardcom’s answer about maintenance windows before moving the Kinetics charge time.
No runbook.This page.