ADR-0017: External payment links (billing without provider API access)
Status: Draft — Phase 1 implemented on branch feat/external-payment-links
Date: 2026-08-05
Context owner: Saar
Evidence: live webhook payload captured from Grow (Erez/Kinetics account) on 2026-08-05; Israeli payment-provider market survey 2026-08-04
Context
Taikan’s payments layer assumes the Stripe model: an org holds provider API credentials, and Taikan tokenizes a card, charges it on a cron, and reads webhooks. That assumption does not survive contact with the Israeli market.
API access is a sales-gated upsell at essentially every local provider — iCredit bills tokens at ₪30/mo plus recurring at ₪45/mo, Tranzila’s recurring module is reported at +₪99/mo, Cardcom prices every module through sales and needs a second no-CVV terminal, and Grow quoted ₪600/mo for an API key that is published nowhere. Meanwhile the platforms that compete with us (Arbox, Wix, Invoice4U) pay nothing for API access because they hold ISV/partner agreements where the provider earns from the merchant’s clearing rate instead. Those partner agreements are worth pursuing, but they are commercial negotiations with unpredictable timelines, and no gym can be onboarded while one is pending.
What we verified on 2026-08-05 changes the shape of the problem. In a plain Grow merchant account with no API key at all, the dashboard offers self-serve webhook creation — no support ticket, no fee. A ₪1 test payment through a dashboard-created payment link produced this payload (fields abridged):
{
"webhookKey": "9db1249a-…-f1d86e7f251d",
"identifyParam": "taikan-test-123",
"asmachta": "506011916",
"paymentSum": "1",
"paymentType": "רגיל",
"fullName": "סער קוריאל",
"payerPhone": "0527022391",
"payerEmail": "",
"paymentSource": "Payment Links",
"invoiceURL": ""
}Four facts follow, and they are the foundation of this ADR:
webhookKeyis echoed in the body and equals the dashboard value, so we can verify origin with a shared secret and no API call — breaking the circular dependency where verification required the paid API.identifyParamis configured per webhook, not per payment, so it is an org-routing token, not a member reference.- The payer is identifiable by
fullName/payerPhone/payerEmail, andasmachtais a stable per-transaction reference. paymentSourcedistinguishes link payments from POS and app charges, which matters because a webhook is account-wide.
Grow additionally exposes a failed-standing-order webhook type (עדכון עבור הוראת קבע שנכשלה) and standing-order cycle reporting. That is the signal we assumed we would be blind to, and it is what makes provider-managed recurring viable rather than a manual monthly chore.
This ADR therefore records a tier of billing that needs zero provider API access: the gym creates payment links in its own provider dashboard, pastes them into Taikan plans, and Taikan observes the resulting payments over webhooks.
Why the existing Meshulam adapter does not carry this
meshulam.provider.ts is an API-mode adapter (hosted-page creation, token charges, refunds) that cannot run without the ₪600 key, and its webhook path is broken in two independent ways: it compares body['webhookKey'] against the stored apiKey (a different credential), and its parse fallback is unreachable because new URLSearchParams(jsonString) never throws — it silently yields one junk key, so JSON bodies always miss. It is superseded, not extended.
Decision
1. External links are a webhook-only adapter, not a charging provider
The new provider implements only parseWebhookEvent and validateWebhookSignature. It never creates payment pages, never tokenizes, never charges. Subscriptions in this mode carry paymentMethodId = NULL and nextChargeDate = NULL, which makes them structurally invisible to RecurringChargeService — verified against its WHERE clause, where a NULL nextChargeDate with no scheduledPlanId can never be selected.
Alternatives considered:
- Implement
createPaymentPagereturning the plan’s static link. Rejected —createHostedPaymentis transaction-scoped: it builds a per-charge webhook URL, appends?sub=<id>to the success URL, and writes a pending row keyed to a provider-issuedprocessId. A static link has none of those, so the adapter would have to lie about three return values. The purchase path forks earlier instead (see §3). - A parallel “billing mode” concept outside the provider abstraction. Rejected —
payment_transactions.provideris aNOT NULLenum, webhook routing/credential encryption/registry lookup all key on provider, and Morning already establishes the precedent of a partially-capable adapter (getRefundCapability() → 'manual'). Fighting the abstraction buys nothing.
2. Matching is expectation-first, not identity-first
When a member clicks buy on a link-backed plan, Taikan creates a pending subscription and a pending payment transaction recording the expected org, plan, membership, and amount. The inbound webhook is then matched against outstanding expectations, with payer identity used as corroboration rather than as the primary key.
Resolution order:
- Plan-scoped notify URL (see §5) — org and plan are known structurally.
- Open expectation — a
pendingtransaction in this org, within a recency window, whose amount equalspaymentSum, corroborated by normalized phone or lowercased email. - Member-only — no expectation exists (member paid a link sent over WhatsApp without ever opening Taikan): resolve the member by email, then by normalized phone, and infer the plan from the amount.
- Unmatched — recorded and parked for a human.
Alternatives considered:
- Blind identity matching on every event (phone/email → member → guess plan). Rejected — it is strictly weaker. Two members can share a payer phone (a spouse’s card, a parent paying for a child), amounts collide across plans, and an account-wide webhook also carries POS and non-Taikan charges. Expectation-first turns most events into an exact three-way agreement (amount, member, recency) and degrades to identity matching only when there is nothing to match against.
- Require the member to self-report “I paid”. Rejected as the primary mechanism — it adds a step members skip, and the webhook already tells us the truth. Retained as an optional later affordance.
Phone normalization is mandatory on both sides. users.phone is a mixed bag: the profile-update path normalizes via normalizeIsraeliPhone, but CSV/Arbox import, join-link registration, and invitation prefill all write raw values, and there is no index on the column. Grow sends 0527022391. Matching therefore normalizes the candidate and compares against normalized stored values, following the leads/WhatsApp-ingestion precedent where the stored side is canonical +972….
3. A dedicated external_payment_events table — intent ledger separate from money ledger
Every inbound event is persisted verbatim in a new table with its own match state, before any payment_transactions row is touched.
external_payment_events
id, organization_id, provider
external_reference -- asmachta; UNIQUE per org (idempotency)
raw_payload jsonb -- verbatim, for audit and future field discovery
amount_in_cents, currency
payer_name, payer_phone, payer_phone_normalized, payer_email
payment_source, payment_kind -- one-off vs standing-order cycle
paid_at
status -- pending_review | applied | ignored | rejected
match_confidence -- exact | probable | none
matched_membership_id, matched_subscription_id, matched_plan_id
match_reason jsonb -- why we matched, for the confirm UI and for debugging
applied_transaction_id -- FK → payment_transactions.id
review_task_id -- FK → tasks.id
confirmed_by_user_id, confirmed_atAlternatives considered:
- Write straight into
payment_transactions. Rejected — that table means “money we have accepted and attributed.” An unmatched, unverified, possibly-not-ours webhook is not that. It also hasNOT NULLorg/amount/currency and would need a synthetic membership for unmatched events, corrupting revenue analytics with rows nobody has confirmed. - Keep raw payloads only in logs/Sentry. Rejected — the confirm UI needs to show the owner exactly what arrived, and we are still discovering this contract (standing-order cycle payloads are unseen as of this writing).
WebhookProcessingService today never inserts a transaction — it only completes a pre-existing pending row, and returns early when it finds none. Applying an event therefore either completes the expectation’s pending row (path 1/2) or creates a fresh completed row (path 3), then advances the subscription.
4. Human confirmation by default; auto-apply only on exact match
exact matches (plan-scoped URL or a unique open expectation with amount and identity agreement) apply automatically. probable and none open a task and wait for the owner, mirroring the manual_refund pattern: task created from the webhook, linked by a column on the other table, closed inside a single db.transaction with an idempotent alreadyCompleted guard and side effects fired after commit.
Alternatives considered:
- Auto-apply everything. Rejected — verification is a static bearer secret with no HMAC and no replay protection. Anyone who ever sees one payload learns the secret. Combined with amount-only matching, blind auto-apply would let a forged or misattributed event grant membership. Requiring agreement across independent signals, and a human anywhere short of that, keeps the blast radius at zero.
- Human confirmation for everything. Rejected — the common case (member clicks buy, pays the exact plan price, phone matches) is unambiguous, and making the owner rubber-stamp it teaches them to click confirm without reading, which defeats the control.
5. Two ingestion channels: per-page notify URL primary, account webhook secondary
Grow’s payment-page editor exposes a per-page קישור לעדכון מערכות מידע (שרת) field alongside the thank-you redirect. Where available, each plan’s link gets its own notify URL carrying org, plan, and an opaque per-org token — a capability URL, following the query-style route already used for Morning (?org=<orgId>&token=<secret>). This makes plan identification structural rather than heuristic and excludes POS/unrelated noise entirely.
The account-wide webhook stays configured as well, because it is the only channel that delivers failed standing orders and payments made outside a plan link.
Status: the per-page payload shape is unverified — it may or may not carry webhookKey, which is why the URL token exists. Confirm before building on it (see Open questions).
6. Recurring is provider-managed; Taikan observes and sweeps
The member signs a standing order (הוראת קבע) once on the provider’s page. The provider charges monthly. Taikan hears cycle successes and failures and reacts — it never initiates a charge. handlePaymentFailed’s existing dunning ladder applies to failed-cycle events.
Because nextChargeDate is NULL, no existing cron watches these subscriptions, so a new lapse sweep flags active link-mode subscriptions whose currentPeriodEnd passed by more than a grace period (proposed: 3 days), moves them to past_due, and sends a reminder containing the payment link. The grace period absorbs webhook delivery lag so a slow event never wrongly marks a paying member delinquent.
Alternatives considered:
- Have Taikan’s cron drive renewals and ask the owner to confirm each cycle. Rejected — it converts a solved problem (the provider already charges reliably) into monthly manual labor that scales with membership count.
7. Verification: shared secret plus idempotency, with the limits stated
Origin is proven by constant-time comparison of webhookKey (account channel) or the URL token (per-page channel). Replay is blocked by the UNIQUE (organization_id, external_reference) index — a redelivered asmachta is a no-op 200. Amount is validated against the plan price; a mismatch can never auto-apply.
This is weaker than PayPlus’s HMAC and we accept it knowingly: there is no payload integrity and no timestamp, so a leaked payload is forgeable. The compensating controls are the uniqueness constraint, multi-signal agreement before auto-apply, and human review everywhere else.
Consequences
Gained: any gym on any provider that can mint a payment link and post a webhook can run Taikan membership billing — no API fee, no partner agreement, no adapter per provider. Erez can go live on Grow’s plain merchant deal immediately. Payment-provider negotiations lose their deadline pressure.
Given up: no card on file, so no in-app plan-change charging, no proration collection, no debt collection by charge, and no automatic retry of a failed payment — every one of those degrades to “send the member a link.” Revenue analytics reflect confirmed events, so an unconfirmed backlog understates revenue. Refunds are entirely out-of-band in the provider’s portal.
Product risk: this tier may be good enough that gyms never upgrade to integrated billing. Mitigate by positioning it explicitly as external billing against Taikan billing, keeping card-on-file, one-click renewal, proration, and automatic dunning as the integrated tier’s advantages.
Open questions (resolve before/while building)
- Per-page notify URL — does it fire, and does its payload match the account webhook’s shape? Does it include
webhookKey? (Test: set it on one page, pay ₪1, compare against the captured account-webhook payload.) - Standing-order cycle payload — what does a הוראת קבע charge look like, and how do we distinguish cycle 1 from cycle N? Which fields populate
paymentsNum/allPaymentNum/periodicalPaymentSum? - Failed-standing-order payload — shape unknown; it drives the dunning path.
- Per-payment custom data — can a dashboard link URL carry a query parameter that echoes back (Grow’s API has
cField1..N)? If yes, matching becomes exact in every case and §2’s fallbacks become dead code. - Invoice fields — when the gym has Grow invoicing on, do
invoiceURL/invoiceLicenseNumberpopulate? That would give tax-document links for free.
Build plan
Phase 0 — experiments (hours). Resolve open questions 1–4 with the existing capture rig (/webhooks/capture/:label, dev-only).
Phase 1 — links + manual confirm (2–3 days).
plans.paymentLinkUrl(text, nullable) + migration. NoteproviderPriceIdis a dead write-only column documented for exactly this purpose; it is deliberately left alone and should be dropped separately.- Shared zod: create/update/response in
plan.schema.ts(createPlanSchemais aZodEffects— add the field inside the innerz.object). - API DTOs (
whitelist: truestrips undecorated properties),plans.serviceinsert + conditional-spread update, and bothtoPlanResponsecopies —plans.controller.ts:38-62andsubscriptions.controller.ts:53-75. plans.service.purchase(): fork beforecreateHostedPayment— create pending sub + pending txn, return{ subscription, paymentPageUrl: plan.paymentLinkUrl }. The web shop’s existingif (paymentPageUrl) redirect : else toastbranch needs no change;isPaidWithoutProvider(shop/page.tsx:365) does.- New adapter (parse + verify only), registered in
payments.module.ts, enum value,PROVIDER_INFOentry with the webhook-key field, andVISIBLE_PROVIDERS. external_payment_eventstable + ingestion service + matcher.- Owner review UI: a “payments awaiting confirmation” table plus a task for
probable/none, followingtask-detail-sheet’smanual_refundaffordance. - Settings card showing the two URLs to paste into the provider dashboard, with copy buttons and setup steps.
Phase 2 — automation (1–2 days). Auto-apply on exact; standing-order cycle renewals; failed-cycle → existing dunning ladder.
Phase 3 — lifecycle (1 day). Lapse sweep with grace period; reminders carrying the link; member-facing pay surfaces.
Adjacent fixes this work should carry:
payment-webhook.controller.ts:108usesJSON.stringify(req.body); it round-trips JSON but garbles form-encoded bodies — usereq.rawBody.- Add a partial unique index on
payment_transactions.provider_transaction_id WHERE NOT NULL, mirroring0033_careless_morbius.sql, so replays cannot slide billing periods forward.
Flags (PostHog, per-org, groups: { organization: orgId }, fail-closed): external-payment-links (master) and external-payment-auto-apply (default off, so the tier ships human-confirmed and relaxes once the matcher is proven). Update 2026-08-22: the master gate external-payment-links was merged permanently ON — the key and every off-branch are deleted, so the tier is unconditional and only external-payment-auto-apply is still evaluated.
Tests. Unit: matcher across every ambiguity case (duplicate phone, amount collision, stale expectation, POS noise), signature verification, payload parsing. Integration: webhook → event row → confirm → transaction + active subscription, including replay. E2E: owner pastes a link, member buys, simulated webhook, owner confirms.
Implementation notes — where Phase 1 diverged from this design
Four decisions changed once the code met the codebase. They supersede the text above.
1. Not a PaymentProviderAdapter after all (revises §1). §1 argued for a webhook-only adapter registered in the provider registry. In practice that interface demands ~15 methods about charging, tokenizing and refunding, all of which would have been stubs asserting capabilities this integration does not have. Instead there is a plain GrowLinkPayloadParser plus a dedicated /webhooks/external/:provider/:orgId controller, and the existing /webhooks/payments/* pipeline is left untouched. The payment_provider enum still records meshulam, so no enum migration was needed and no ledger row lies about who processed the money. The argument in §1 for reusing the registry was weaker than it looked, because the existing webhook route was never going to be reused anyway: it requires an adapter that can charge and returns early when it finds no pending row.
2. Event kind comes from the URL, never from payload sniffing (new). The failed-standing-order payload shape is still unknown, and guessing wrong would mean crediting a member for a charge that failed. Since the owner configures one webhook per type in Grow’s dashboard anyway, each type gets its own URL (?kind=recurring_failed) and the ingestion path trusts that rather than inspecting fields it has never seen.
3. The partial unique index on payment_transactions.provider_transaction_id was deliberately NOT added. Production may already contain duplicate provider transaction ids from historical replays, and a unique index would fail the migration on deploy. It needs a duplicate audit first. The new tier does not depend on it: external_payment_events carries its own UNIQUE (organization_id, external_reference), which is what actually stops a redelivery from crediting twice on this path.
4. The per-page URL token is supported in code but not yet configurable. verify() accepts a webhookUrlToken credential, but the settings UI only collects the body webhookKey, because the per-page notify URL (open question 1) is still unverified. When that experiment lands, the remaining work is generating a token at configure time and rendering the URL — no ingestion changes.
Also worth recording: the settings dialog’s meshulam entry no longer collects pageCode/userId/apiKey. Those were API-mode credentials for an integration that cannot exist without the ₪600 key, and leaving them would have made the form demand secrets the gym does not have.
5. payment_provider gained a dedicated external value after all (revises the enum claim in #1). Reusing meshulam worked for storage, but every “can Taikan charge this org” guard (assertNotExternallyBilled, resolveMemberAction, the recurring-charge cron, manual charge, debt clearance) needs a reliable signal, and inferring “externally billed” from card absence on the subscription is fragile — a later card registration (for any reason) silently made the guard think billing had become Taikan-managed. payment_provider now has an external value that is the single source of truth: orgConfig.provider === 'external' && !!plan.paymentLinkUrl. meshulam stays in the enum, dormant, same as tranzila — dropping a Postgres enum value is riskier than just not writing it. (Verified against prod before this change: zero payment_provider_configs rows use meshulam, so no backfill was needed.)