Skip to content

fix(providers): retire the duplicate customerio slug and pin the published counts#731

Merged
choraria merged 2 commits into
mainfrom
lane/provider-slug-dedupe
Jul 21, 2026
Merged

fix(providers): retire the duplicate customerio slug and pin the published counts#731
choraria merged 2 commits into
mainfrom
lane/provider-slug-dedupe

Conversation

@choraria

Copy link
Copy Markdown
Contributor

The duplicate

The registry carried two slugs for one real provider. customerio and customer_io had
byte-identical schemes — same x-cio-signature / x-cio-timestamp headers, same hex encoding, same
v0:{ts}:{body} signed message, same tolerance — and both rendered as "Customer.io", so the /verify
provider picker and the docs directory each listed Customer.io twice.

customer_io survives: it matches the convention every other dotted-domain brand follows (cal_com,
checkout_com, incident_io, merge_dev). PROVIDERS is now 141.

Why this isn't just a vocabulary edit

provider_secrets.provider is plain text with no enum and no CHECK, and the engine picks an adapter
from the registered provider. A slug that stops resolving is dropped, not rejected — so an
endpoint registered under customerio would not error and would not warn. It would simply stop
verifying and start landing events unverified.

So the alias lives in canonicalProvider, the one place stored free text becomes a Provider, and the
code does not depend on the migration having run:

  • webhooks-specRETIRED_PROVIDER_ALIASES + a shared canonicalProvider, replacing the
    engine's local copy. A retired slug resolves; the write path (ProviderSchema) still rejects it, so
    nothing new can register under one.
  • packages/db reads — events and provider-secret rows canonicalise on the way out, so a legacy
    value can never reach EventSummarySchema / ProviderSecretSummarySchema (both strict) and break a
    whole list. The public API, the generated SDKs and the dashboard never see the dead slug.
  • Migration 0095 — hygiene, not a rescue. Rewrites the stored rows so the data stops disagreeing
    with the vocabulary and the provider filter returns historical rows.

The migration is bounded, and that's verified

events is the largest table and its only provider index leads with endpoint_id, so a bare
where provider = 'customerio' would scan all of it. The affected endpoints are knowable up front, and
the set is exhaustive, not a heuristic: the engine names an event's provider from the endpoint's
registered secret; revoke is a soft delete and nothing hard-deletes provider_secrets; and both tables
cascade from endpoints/orgs, so there are no orphan events. A DO block skips the events statement
entirely when there are none, making "we did not touch events" a certainty rather than a planner choice.

Exercised against a real Postgres 14 (matching CI):

check result
rewrites events + secrets, including a revoked secret's endpoint
leaves unrelated endpoints untouched
idempotent on re-run
in the empty case, a statement-level tripwire proves the events UPDATE never executes

Two counts that were already wrong

The docs split the registry "around 109 config-driven / the remaining 33 bespoke" — a plausible pair
that summed to the headline and matched the code in neither half. Twilio has both a config row and
a bespoke adapter, and registry.ts prefers the bespoke one, so its config row never runs. The real
partition is 108 / 33. Nobody noticed, because a number typed into prose has no relationship to the
registry it describes.

published-counts.test.ts now pins every published count to the live registry across docs, the
marketing site and the README, and asserts the provider directory lists exactly the live set — the
check that would have caught this duplicate at the source, which no count ever could (the number was
self-consistently wrong).

Mutation-verified

Every guard here was checked red before being trusted:

  • drop the alias from canonicalProvider → the legacy secret verifies false, silently. That's
    the exact failure mode, reproduced.
  • put a stale count back in one doc → fails, naming the file and fragment.
  • empty a manifest entry → the zero-input floor fires.
  • smuggle a live slug into RETIRED_PROVIDERS → fails.
  • add a dead branding key → still caught.

The w1-batch3 known-answer test was retargeted rather than deleted — it was Customer.io's only
replay-window and header-metadata coverage (s8-crypto-finds covers verify + framing only).

Test plan

  • pnpm lint ✅ (402 guard tests) · pnpm typecheck ✅ 32/32 · pnpm test ✅ 28/28
  • Migration behaviour verified against a real Postgres 14 as tabled above.
  • test-db (CI) covers up → down → up reversibility; migrate:down is deliberately a no-op — the two
    slugs are indistinguishable once normalised, so restoring the wrong ones would be worse than leaving
    them.

⚠️ For the founder

  1. Migration 0095 needs applying — this session is prod-DB blocked, so I could not run it against
    production. The code is safe either way (the alias means nothing breaks if it hasn't run yet).
  2. Removing an enum member from the published @webhook-co/sdk is semver-relevant. The generated
    Provider union loses "customerio". Nothing could legitimately have been using it — it verified
    identically to customer_io — but it is a type-level breaking change worth a release note.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XiZ2XWyU6gUDa9rT8iYqvL

…blished counts

The registry carried TWO slugs for one real provider. `customerio` and `customer_io`
had byte-identical schemes — same `x-cio-signature`/`x-cio-timestamp` headers, same hex
encoding, same `v0:{ts}:{body}` signed message, same tolerance — and both rendered as
"Customer.io", so the provider picker and the docs directory each listed Customer.io
twice. `customer_io` survives: it matches the convention every other dotted-domain brand
follows (cal_com, checkout_com, incident_io, merge_dev). PROVIDERS is now 141.

Retiring a slug is not a vocabulary edit. `provider_secrets.provider` is plain `text`,
and the engine picks an adapter from the REGISTERED provider — a slug that stops
resolving is DROPPED, not rejected. An endpoint registered under `customerio` would not
error and would not warn; it would simply stop verifying and start landing events
unverified. So the alias lives in `canonicalProvider`, the one place stored free text
becomes a Provider, and the code does not depend on the migration having run:

  - webhooks-spec: RETIRED_PROVIDER_ALIASES + a shared `canonicalProvider`, replacing the
    engine's local copy. A retired slug resolves; the write path (`ProviderSchema`) still
    rejects it, so nothing new registers under one.
  - db reads: events + provider-secret rows canonicalise on the way out, so a legacy value
    can never reach `EventSummarySchema`/`ProviderSecretSummarySchema` (both strict) and
    break a whole list. The public API, the SDKs and the dashboard never see the dead slug.
  - migration 0095: hygiene, not a rescue. Rewrites the stored rows so the data stops
    disagreeing with the vocabulary and the provider FILTER returns historical rows. The
    events rewrite is bounded by the endpoints that registered the slug (revoke is a soft
    delete and nothing hard-deletes, so that set is exhaustive) and is skipped entirely
    when there are none, rather than scanning the largest table in the schema.

Verified against a real Postgres 14: rewrites correctly including a revoked secret's
endpoint, idempotent on re-run, and a statement-level tripwire proves the events UPDATE
never executes in the expected empty case.

Also corrects two counts that had already drifted with nothing to catch them. The docs
split the registry "around 109 config-driven / the remaining 33 bespoke" — a pair that
summed to the headline and matched the code in neither half, because Twilio has both a
config row and a bespoke adapter and `registry.ts` prefers the bespoke one. The real
partition is 108/33. `published-counts.test.ts` now pins every published count to the
registry, and asserts the provider directory lists exactly the live set — the check that
would have caught this duplicate at the source, which no count ever could.

Mutation-verified: dropping the alias makes the legacy secret verify `false` (silently);
a stale count, an emptied manifest entry, and a live slug smuggled into RETIRED_PROVIDERS
each fail. The w1-batch3 KAT is retargeted rather than deleted — it was Customer.io's only
replay-window and header-metadata coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiZ2XWyU6gUDa9rT8iYqvL
choraria added a commit to webhook-co/webhook-go that referenced this pull request Jul 21, 2026
… provider slug (#15)

webhook.git carried TWO registry slugs for one real provider: `customerio` and `customer_io`
had byte-identical schemes (same `x-cio-signature`/`x-cio-timestamp` headers, same hex
encoding, same `v0:{ts}:{body}` signed message, same tolerance). `customer_io` survives —
it matches the convention every other dotted-domain brand follows.

Spec-side that is 8 removed enum members and nothing else. Keeping this copy current is
what keeps the route-coverage ratchet honest: it validates against this file, so a stale
copy would keep passing against an old route list and a newly-shipped route could reach
production with no Go method.

Source: webhook-co/webhook#731


Claude-Session: https://claude.ai/code/session_01XiZ2XWyU6gUDa9rT8iYqvL

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The AI review was right: the alias was proven on the WRITE path (the engine ingest
regression) and asserted nowhere on the read paths this PR also changed. That is the
failure mode the repo already knows — a guard whose tests don't run the guard.

Both reads parse through a STRICT provider enum, and both validate per row inside a list
mapper, so a single legacy row does not degrade its own entry — it throws and takes the
WHOLE list with it. These tests pin exactly that:

  - events: `listEvents`, `getEvent` and the org-wide browse all return a `customerio` row
    as `customer_io`, with the other events still present.
  - provider secrets: `listEndpointProviderSecrets` (which had no test at all) reports the
    live slug, with the endpoint's other secrets still visible alongside it.

Each fixture is inserted straight into SQL — every write path rejects the slug now, so
bypassing them is the only way to reproduce the row an existing database already holds —
and each block first asserts the column really does hold the dead slug, so a fixture that
silently normalised could not make the rest pass vacuously.

Mutation-verified against a real Postgres: reverting the three mappers to `r.provider`
fails all four, and restoring them passes. Full db suite green (100 files, 1544 tests),
including the up -> down -> up reversibility run over migration 0095.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiZ2XWyU6gUDa9rT8iYqvL
@mintlify

mintlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
webhook-co 🟢 Ready View Preview Jul 21, 2026, 5:17 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@choraria
choraria marked this pull request as ready for review July 21, 2026 17:25
@choraria
choraria merged commit 062d59b into main Jul 21, 2026
33 checks passed
@choraria
choraria deleted the lane/provider-slug-dedupe branch July 21, 2026 17:25
@mintlify

mintlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
webhook-co 🟡 Building Jul 21, 2026, 5:17 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant