Repository files navigation

🔒 CapConnect

A privacy-first, zero-knowledge data connector for the cookieless era.

Clean → Hash (SHA-256) → Forward. First-party customer data is normalized and irreversibly hashed in memory before it is relayed to Meta Conversions API and Google Ads Enhanced Conversions. No raw PII ever touches a disk, a log, or a database.

License: BSL-1.1TypeScriptPII on disk


Why CapConnect exists

Third-party cookies are gone. To keep measuring conversions, advertisers must send first-party data server-to-server. But doing that safely is the hard part: the moment raw emails and phone numbers leave your perimeter, your legal and security teams own a new liability.

CapConnect is the safe middle layer. It accepts your raw events, normalizes and SHA-256 hashes every personal field the instant it arrives, and forwards only those one-way digests to the ad platforms — exactly the way Meta and Google specify. Because the plaintext exists only transiently in process memory and is never persisted, there is nothing to leak from CapConnect's storage. There is no storage.

For CTOs & Legal: CapConnect is a stateless relay. It holds no datastore, writes no PII to disk, and emits no PII in logs. The only personal data it transmits is already a non-reversible SHA-256 hash, sent over TLS to Meta/Google — the same hashing the platforms' own SDKs perform client-side.


✨ Features

  • Zero-knowledge by construction — plaintext PII lives only on the call stack during a single request; it is GC-eligible the moment the response is returned.
  • Spec-accurate normalization — email lower/trim, phone → E.164 (auto-prepends the country code, e.g. 81 for Japan), names/city/state/zip/DOB/gender all formatted per the official Meta & Google guidelines.
  • SHA-256 hashing with idempotent pass-through for upstream-pre-hashed inputs.
  • Meta Conversions API with deduplication event_id support to pair with the browser Pixel.
  • Google Ads Enhanced Conversions via uploadClickConversions with userIdentifiers.
  • Concurrent, fault-isolated dispatch — one platform failing never blocks the other.
  • Automatic retries with exponential backoff + jitter on transient errors (network / 429 / 5xx).
  • Automatic Google OAuth refresh (optional) — refresh-token exchange, in-memory caching, concurrent-refresh coalescing; nothing persisted.
  • Single & batch webhook endpoints (/v1/collect, /v1/collect/batch).
  • Hardened Express server — shared-secret auth (constant-time compare), body-size caps, x-powered-by disabled, graceful shutdown.
  • Strict TypeScript end to end (exactOptionalPropertyTypes, noUncheckedIndexedAccess, …).
  • 80 unit/integration tests (Vitest + Supertest), ESLint type-checked rules, multi-stage Docker image, and GitHub Actions CI.

🛡 The Zero-Knowledge Guarantee

flowchart LR
A["Upstream source<br/>(Webhook / CSV parse)"] -->|HTTPS + shared secret| B["CapConnect<br/>/v1/collect"]
subgraph MEM["⏱ In-memory only — never persisted, never logged"]
direction TB
C["Validate contract"] --> D["Normalize<br/>email · phone · name · address"]
D --> E["SHA-256 hash<br/>(irreversible, one-way)"]
E --> F["Build provider payloads<br/>(hashed digests only)"]
end
B --> C
F -->|TLS| G["Meta Conversions API<br/>(event_id dedup)"]
F -->|TLS| H["Google Ads API<br/>Enhanced Conversions"]
G --> I["Structured, PII-free<br/>dispatch report"]
H --> I
I -->|JSON response| A
classDef mem fill:#0b3d2e,stroke:#10b981,color:#e6fffa;
class MEM,C,D,E,F mem;
Loading

What crosses each boundary:

BoundaryData in transitForm
Upstream → CapConnectRaw identifiers + event metadataPlaintext over TLS (you control this hop)
Inside CapConnectIdentifiersNormalized then SHA-256 hashed, in memory only
CapConnect → Meta/GoogleIdentifiersSHA-256 hex digests + un-hashed match-support fields (fbc/fbp/IP/UA/gclid) per platform spec
CapConnect → UpstreamDispatch outcomeNo PII — only event_id, status, provider trace ids

🏗 Architecture

flowchart TD
subgraph SRC["src/"]
SV["server.ts<br/>Express app · auth · validation · routing"]
NM["utils/normalizer.ts<br/>normalize + SHA-256 (pure functions)"]
CP["services/capi.ts<br/>Meta + Google payload build & POST"]
TY["types/index.ts<br/>strict shared types"]
end
SV -->|"normalizeAndHash()"| NM
SV -->|"dispatchToAllProviders()"| CP
NM --- TY
CP --- TY
SV --- TY
Loading
FileResponsibility
src/types/index.tsSingle source of truth for every shape: raw input, hashed IR, provider payloads, responses, config.
src/utils/normalizer.tsField-by-field normalization (Meta/Google spec) + SHA-256. Pure, no I/O, no logging.
src/utils/retry.tsExponential-backoff retry helper; retries transient errors only.
src/services/capi.tsBuilds & POSTs Meta CAPI and Google Ads payloads concurrently; PII-free error reporting.
src/services/googleAuth.tsGoogle OAuth token providers (static + auto-refreshing).
src/server.tsHardened Express server, config loading/validation, webhook endpoints, graceful shutdown.
tests/Vitest unit + Supertest integration suites (80 tests).

🚀 Quick start

# 1. Install
npm install
# 2. Configure
cp .env.example .env
# …then edit .env with your Pixel id, access tokens, etc.# Generate a webhook secret: openssl rand -hex 32# 3. Develop (hot reload)
npm run dev
# 4. Production build & run
npm run build
npm start

Health check

curl -s http://localhost:3000/healthz | jq
{ "status": "ok", "service": "capconnect", "providers": { "meta": true, "google": true } }

📡 API

POST /v1/collect

Authenticated with the x-capconnect-token header (must equal WEBHOOK_SECRET).

Request

curl -s -X POST http://localhost:3000/v1/collect \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '{ "customer": { "email": " John.Doe@Example.COM ", "phone": "090-1234-5678", "firstName": "John", "lastName": "Doe", "country": "JP", "city": "Tokyo", "zip": "100-0001", "externalId": "user_42" }, "event": { "eventName": "Purchase", "eventId": "order-2025-0001", "value": 4980, "currency": "JPY", "orderId": "order-2025-0001", "actionSource": "website", "eventSourceUrl": "https://shop.example.com/thank-you", "fbc": "fb.1.1700000000000.IwAR...", "gclid": "Cj0KCQ..." } }'

The phone above is normalized to 819012345678 (leading 0 dropped, 81 prepended) and then SHA-256 hashed before it ever leaves the process.

Response (200 if at least one provider accepted; 422 if none did)

{
"eventId": "order-2025-0001",
"accepted": true,
"results": [
{ "provider": "meta", "status": "sent", "httpStatus": 200, "detail": "events_received=1 fbtrace_id=Aa...", "reference": "order-2025-0001" },
{ "provider": "google", "status": "sent", "httpStatus": 200, "detail": "results=1", "reference": "order-2025-0001" }
]
}

POST /v1/collect/batch

Same auth. Body is a JSON array of { customer, event } objects (max 1000). Each item is processed independently; invalid items are reported in validationErrors without failing the rest.

curl -s -X POST http://localhost:3000/v1/collect/batch \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '[ { "customer": { "email": "a@example.com" }, "event": { "eventName": "Lead" } } ]'

GET /healthz

Unauthenticated liveness/readiness probe. Returns no PII.


⚙️ Configuration

All configuration is via environment variables (see .env.example). The process fails fast at boot if an enabled provider is missing required secrets.

Server

VariableRequiredDefaultDescription
PORTno3000HTTP listen port.
WEBHOOK_SECRETyesShared secret required in the x-capconnect-token header.
DEFAULT_COUNTRY_CODEno81Digits-only calling code auto-prepended to non-international phones.
DEFAULT_CURRENCYnoJPYISO-4217 currency applied when an event omits one.
HTTP_TIMEOUT_MSno10000Outbound request timeout for both providers.
RETRY_MAX_ATTEMPTSno3Max attempts per provider call (incl. the first).
RETRY_BASE_DELAY_MSno300Base backoff delay; doubles each retry, full-jittered.
RETRY_MAX_DELAY_MSno5000Hard cap on any single backoff delay.

Meta Conversions API

VariableRequiredDefaultDescription
META_ENABLEDnotrueToggle Meta dispatch.
META_PIXEL_IDif enabledYour Pixel / dataset id.
META_ACCESS_TOKENif enabledSystem-user access token.
META_API_VERSIONnov20.0Graph API version.
META_TEST_EVENT_CODEnoEnables Meta's Test Events tool when set.

Google Ads API (Enhanced Conversions)

VariableRequiredDefaultDescription
GOOGLE_ENABLEDnotrueToggle Google dispatch.
GOOGLE_CUSTOMER_IDif enabledAccount id owning the conversion action (digits, no dashes).
GOOGLE_CONVERSION_ACTION_IDif enabledNumeric conversion action id.
GOOGLE_DEVELOPER_TOKENif enabledGoogle Ads API developer token.
GOOGLE_OAUTH_ACCESS_TOKENauth APre-issued OAuth2 Bearer token (you refresh).
GOOGLE_OAUTH_CLIENT_IDauth BOAuth client id for automatic refresh.
GOOGLE_OAUTH_CLIENT_SECRETauth BOAuth client secret for automatic refresh.
GOOGLE_OAUTH_REFRESH_TOKENauth BOAuth refresh token (held in memory only).
GOOGLE_OAUTH_TOKEN_URInohttps://oauth2.googleapis.com/tokenToken endpoint.
GOOGLE_LOGIN_CUSTOMER_IDnoMCC / manager id for login-customer-id.
GOOGLE_API_VERSIONnov17Google Ads API version.
GOOGLE_VALIDATE_ONLYnofalseValidate without recording a conversion.

OAuth — two strategies (provide exactly one):(A) Inject a short-lived GOOGLE_OAUTH_ACCESS_TOKEN from your own secret manager and handle refresh externally; or (B) provide GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET + GOOGLE_OAUTH_REFRESH_TOKEN and CapConnect refreshes access tokens automatically — caching them in memory, coalescing concurrent refreshes, and renewing ~60s before expiry. The refresh token is never persisted or logged. Boot fails fast if neither strategy is fully configured while Google is enabled.

Resilience

  • Automatic retries with exponential backoff + full jitter on transient failures only (network errors, HTTP 429, HTTP 5xx). Deterministic 4xx errors are never retried. Tune via RETRY_*.
  • Fault isolation — a Meta failure never blocks Google (and vice-versa); a Google token-refresh failure is reported as a Google-only auth: error while Meta still dispatches.

🔐 Normalization rules (Meta/Google compliant)

FieldRuleExample → normalized
Email (em)trim, lowercase (no dot-stripping) A.B@Ex.COMa.b@ex.com
Phone (ph)digits only → E.164 (drop trunk 0, prepend country code)090-1234-5678819012345678
First/Last name (fn/ln)lowercase, strip punctuation, collapse spacesO'Brienobrien
City (ct)lowercase, remove all whitespace & punctuationNew Yorknewyork
State (st)lowercase, letters onlyCAca
Zip (zp)lowercase, strip spaces, drop +4, US→first 5100-0001100
Country (country)ISO alpha-2, lowercaseJapan→ first 2 letters
DOB (db)parse → YYYYMMDD1990/01/0219900102
Gender (ge)m / fMalem

Every normalized value is then SHA-256 hashed to lowercase hex before dispatch. Empty/unusable values are dropped (never hashed to the all-empty digest).


🧪 Local verification

npm run typecheck # strict TS, no emit
npm run lint # ESLint (type-checked rules)
npm run build # compile to dist/
npm test# run the Vitest suite (80 tests)
npm run test:coverage # tests + coverage report
npm run ci # typecheck + lint + build + test (what CI runs)

You can exercise the pipeline safely against Meta's Test Events tool by setting META_TEST_EVENT_CODE, and against Google by setting GOOGLE_VALIDATE_ONLY=true.

🐳 Docker

# Build & run with compose (reads your .env)
cp .env.example .env # then fill in secrets
docker compose up --build
# …or build the image directly
docker build -t capconnect:latest .
docker run --rm -p 3000:3000 --env-file .env capconnect:latest

The image is a multi-stage build that ships only production dependencies and the compiled dist/, runs as the non-root node user, exposes a HEALTHCHECK against /healthz, and (via compose) runs read-only with dropped capabilities and no-new-privileges.

🔁 CI

.github/workflows/ci.yml runs on every push/PR: typecheck → lint → build → test across Node 18/20/22, a coverage job, and a Docker build job.


🤝 Operational guidance

  • Terminate TLS in front of CapConnect (reverse proxy / load balancer) and keep the upstream hop encrypted.
  • Rotate WEBHOOK_SECRET and provider tokens via your secret manager; never bake them into images.
  • Do not enable request-body logging at the proxy for /v1/collect* — that would defeat the zero-knowledge property outside the app.
  • Scale horizontally — the service is stateless, so run as many replicas as you need behind a load balancer.

📄 License

CapConnect is distributed under the Business Source License 1.1 (BSL-1.1).

  • You may use, copy, modify, and self-host CapConnect freely, including internally at your company.
  • The Additional Use Grant permits production use except offering CapConnect (or a substantially similar service) to third parties as a hosted/managed commercial "conversion-relay" product.
  • On the Change Date (four years after each version's release), that version automatically converts to the Apache License 2.0.
Licensed under the Business Source License 1.1 (the "License");
you may not use this file except in compliance with the License.
Change License: Apache License, Version 2.0
Change Date: four (4) years from the date of each release.
Additional Use Grant: You may use the Licensed Work in production, except to
provide it to third parties as a hosted or managed commercial conversion-relay
service that competes with the Licensor's offering.
THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.

Prefer a copyleft model instead? CapConnect is also available under the GNU AGPL-3.0 on request — contact the maintainers. Choose BSL-1.1 for permissive self-hosting with a commercial-SaaS carve-out, or AGPL-3.0 if you want network-use copyleft obligations.


Built for the cookieless era. Your customers' data stays your customers'.

About

Privacy-first, zero-knowledge data connector: cleans & SHA-256 hashes first-party customer data in memory, then relays to Meta Conversions API & Google Ads Enhanced Conversions. No PII persisted.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

🔒 CapConnect

A privacy-first, zero-knowledge data connector for the cookieless era.

Clean → Hash (SHA-256) → Forward. First-party customer data is normalized and irreversibly hashed in memory before it is relayed to Meta Conversions API and Google Ads Enhanced Conversions. No raw PII ever touches a disk, a log, or a database.

License: BSL-1.1TypeScriptPII on disk


Why CapConnect exists

Third-party cookies are gone. To keep measuring conversions, advertisers must send first-party data server-to-server. But doing that safely is the hard part: the moment raw emails and phone numbers leave your perimeter, your legal and security teams own a new liability.

CapConnect is the safe middle layer. It accepts your raw events, normalizes and SHA-256 hashes every personal field the instant it arrives, and forwards only those one-way digests to the ad platforms — exactly the way Meta and Google specify. Because the plaintext exists only transiently in process memory and is never persisted, there is nothing to leak from CapConnect's storage. There is no storage.

For CTOs & Legal: CapConnect is a stateless relay. It holds no datastore, writes no PII to disk, and emits no PII in logs. The only personal data it transmits is already a non-reversible SHA-256 hash, sent over TLS to Meta/Google — the same hashing the platforms' own SDKs perform client-side.


✨ Features

  • Zero-knowledge by construction — plaintext PII lives only on the call stack during a single request; it is GC-eligible the moment the response is returned.
  • Spec-accurate normalization — email lower/trim, phone → E.164 (auto-prepends the country code, e.g. 81 for Japan), names/city/state/zip/DOB/gender all formatted per the official Meta & Google guidelines.
  • SHA-256 hashing with idempotent pass-through for upstream-pre-hashed inputs.
  • Meta Conversions API with deduplication event_id support to pair with the browser Pixel.
  • Google Ads Enhanced Conversions via uploadClickConversions with userIdentifiers.
  • Concurrent, fault-isolated dispatch — one platform failing never blocks the other.
  • Automatic retries with exponential backoff + jitter on transient errors (network / 429 / 5xx).
  • Automatic Google OAuth refresh (optional) — refresh-token exchange, in-memory caching, concurrent-refresh coalescing; nothing persisted.
  • Single & batch webhook endpoints (/v1/collect, /v1/collect/batch).
  • Hardened Express server — shared-secret auth (constant-time compare), body-size caps, x-powered-by disabled, graceful shutdown.
  • Strict TypeScript end to end (exactOptionalPropertyTypes, noUncheckedIndexedAccess, …).
  • 80 unit/integration tests (Vitest + Supertest), ESLint type-checked rules, multi-stage Docker image, and GitHub Actions CI.

🛡 The Zero-Knowledge Guarantee

flowchart LR
A["Upstream source<br/>(Webhook / CSV parse)"] -->|HTTPS + shared secret| B["CapConnect<br/>/v1/collect"]
subgraph MEM["⏱ In-memory only — never persisted, never logged"]
direction TB
C["Validate contract"] --> D["Normalize<br/>email · phone · name · address"]
D --> E["SHA-256 hash<br/>(irreversible, one-way)"]
E --> F["Build provider payloads<br/>(hashed digests only)"]
end
B --> C
F -->|TLS| G["Meta Conversions API<br/>(event_id dedup)"]
F -->|TLS| H["Google Ads API<br/>Enhanced Conversions"]
G --> I["Structured, PII-free<br/>dispatch report"]
H --> I
I -->|JSON response| A
classDef mem fill:#0b3d2e,stroke:#10b981,color:#e6fffa;
class MEM,C,D,E,F mem;
Loading

What crosses each boundary:

BoundaryData in transitForm
Upstream → CapConnectRaw identifiers + event metadataPlaintext over TLS (you control this hop)
Inside CapConnectIdentifiersNormalized then SHA-256 hashed, in memory only
CapConnect → Meta/GoogleIdentifiersSHA-256 hex digests + un-hashed match-support fields (fbc/fbp/IP/UA/gclid) per platform spec
CapConnect → UpstreamDispatch outcomeNo PII — only event_id, status, provider trace ids

🏗 Architecture

flowchart TD
subgraph SRC["src/"]
SV["server.ts<br/>Express app · auth · validation · routing"]
NM["utils/normalizer.ts<br/>normalize + SHA-256 (pure functions)"]
CP["services/capi.ts<br/>Meta + Google payload build & POST"]
TY["types/index.ts<br/>strict shared types"]
end
SV -->|"normalizeAndHash()"| NM
SV -->|"dispatchToAllProviders()"| CP
NM --- TY
CP --- TY
SV --- TY
Loading
FileResponsibility
src/types/index.tsSingle source of truth for every shape: raw input, hashed IR, provider payloads, responses, config.
src/utils/normalizer.tsField-by-field normalization (Meta/Google spec) + SHA-256. Pure, no I/O, no logging.
src/utils/retry.tsExponential-backoff retry helper; retries transient errors only.
src/services/capi.tsBuilds & POSTs Meta CAPI and Google Ads payloads concurrently; PII-free error reporting.
src/services/googleAuth.tsGoogle OAuth token providers (static + auto-refreshing).
src/server.tsHardened Express server, config loading/validation, webhook endpoints, graceful shutdown.
tests/Vitest unit + Supertest integration suites (80 tests).

🚀 Quick start

# 1. Install
npm install
# 2. Configure
cp .env.example .env
# …then edit .env with your Pixel id, access tokens, etc.# Generate a webhook secret: openssl rand -hex 32# 3. Develop (hot reload)
npm run dev
# 4. Production build & run
npm run build
npm start

Health check

curl -s http://localhost:3000/healthz | jq
{ "status": "ok", "service": "capconnect", "providers": { "meta": true, "google": true } }

📡 API

POST /v1/collect

Authenticated with the x-capconnect-token header (must equal WEBHOOK_SECRET).

Request

curl -s -X POST http://localhost:3000/v1/collect \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '{ "customer": { "email": " John.Doe@Example.COM ", "phone": "090-1234-5678", "firstName": "John", "lastName": "Doe", "country": "JP", "city": "Tokyo", "zip": "100-0001", "externalId": "user_42" }, "event": { "eventName": "Purchase", "eventId": "order-2025-0001", "value": 4980, "currency": "JPY", "orderId": "order-2025-0001", "actionSource": "website", "eventSourceUrl": "https://shop.example.com/thank-you", "fbc": "fb.1.1700000000000.IwAR...", "gclid": "Cj0KCQ..." } }'

The phone above is normalized to 819012345678 (leading 0 dropped, 81 prepended) and then SHA-256 hashed before it ever leaves the process.

Response (200 if at least one provider accepted; 422 if none did)

{
"eventId": "order-2025-0001",
"accepted": true,
"results": [
{ "provider": "meta", "status": "sent", "httpStatus": 200, "detail": "events_received=1 fbtrace_id=Aa...", "reference": "order-2025-0001" },
{ "provider": "google", "status": "sent", "httpStatus": 200, "detail": "results=1", "reference": "order-2025-0001" }
]
}

POST /v1/collect/batch

Same auth. Body is a JSON array of { customer, event } objects (max 1000). Each item is processed independently; invalid items are reported in validationErrors without failing the rest.

curl -s -X POST http://localhost:3000/v1/collect/batch \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '[ { "customer": { "email": "a@example.com" }, "event": { "eventName": "Lead" } } ]'

GET /healthz

Unauthenticated liveness/readiness probe. Returns no PII.


⚙️ Configuration

All configuration is via environment variables (see .env.example). The process fails fast at boot if an enabled provider is missing required secrets.

Server

VariableRequiredDefaultDescription
PORTno3000HTTP listen port.
WEBHOOK_SECRETyesShared secret required in the x-capconnect-token header.
DEFAULT_COUNTRY_CODEno81Digits-only calling code auto-prepended to non-international phones.
DEFAULT_CURRENCYnoJPYISO-4217 currency applied when an event omits one.
HTTP_TIMEOUT_MSno10000Outbound request timeout for both providers.
RETRY_MAX_ATTEMPTSno3Max attempts per provider call (incl. the first).
RETRY_BASE_DELAY_MSno300Base backoff delay; doubles each retry, full-jittered.
RETRY_MAX_DELAY_MSno5000Hard cap on any single backoff delay.

Meta Conversions API

VariableRequiredDefaultDescription
META_ENABLEDnotrueToggle Meta dispatch.
META_PIXEL_IDif enabledYour Pixel / dataset id.
META_ACCESS_TOKENif enabledSystem-user access token.
META_API_VERSIONnov20.0Graph API version.
META_TEST_EVENT_CODEnoEnables Meta's Test Events tool when set.

Google Ads API (Enhanced Conversions)

VariableRequiredDefaultDescription
GOOGLE_ENABLEDnotrueToggle Google dispatch.
GOOGLE_CUSTOMER_IDif enabledAccount id owning the conversion action (digits, no dashes).
GOOGLE_CONVERSION_ACTION_IDif enabledNumeric conversion action id.
GOOGLE_DEVELOPER_TOKENif enabledGoogle Ads API developer token.
GOOGLE_OAUTH_ACCESS_TOKENauth APre-issued OAuth2 Bearer token (you refresh).
GOOGLE_OAUTH_CLIENT_IDauth BOAuth client id for automatic refresh.
GOOGLE_OAUTH_CLIENT_SECRETauth BOAuth client secret for automatic refresh.
GOOGLE_OAUTH_REFRESH_TOKENauth BOAuth refresh token (held in memory only).
GOOGLE_OAUTH_TOKEN_URInohttps://oauth2.googleapis.com/tokenToken endpoint.
GOOGLE_LOGIN_CUSTOMER_IDnoMCC / manager id for login-customer-id.
GOOGLE_API_VERSIONnov17Google Ads API version.
GOOGLE_VALIDATE_ONLYnofalseValidate without recording a conversion.

OAuth — two strategies (provide exactly one):(A) Inject a short-lived GOOGLE_OAUTH_ACCESS_TOKEN from your own secret manager and handle refresh externally; or (B) provide GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET + GOOGLE_OAUTH_REFRESH_TOKEN and CapConnect refreshes access tokens automatically — caching them in memory, coalescing concurrent refreshes, and renewing ~60s before expiry. The refresh token is never persisted or logged. Boot fails fast if neither strategy is fully configured while Google is enabled.

Resilience

  • Automatic retries with exponential backoff + full jitter on transient failures only (network errors, HTTP 429, HTTP 5xx). Deterministic 4xx errors are never retried. Tune via RETRY_*.
  • Fault isolation — a Meta failure never blocks Google (and vice-versa); a Google token-refresh failure is reported as a Google-only auth: error while Meta still dispatches.

🔐 Normalization rules (Meta/Google compliant)

FieldRuleExample → normalized
Email (em)trim, lowercase (no dot-stripping) A.B@Ex.COMa.b@ex.com
Phone (ph)digits only → E.164 (drop trunk 0, prepend country code)090-1234-5678819012345678
First/Last name (fn/ln)lowercase, strip punctuation, collapse spacesO'Brienobrien
City (ct)lowercase, remove all whitespace & punctuationNew Yorknewyork
State (st)lowercase, letters onlyCAca
Zip (zp)lowercase, strip spaces, drop +4, US→first 5100-0001100
Country (country)ISO alpha-2, lowercaseJapan→ first 2 letters
DOB (db)parse → YYYYMMDD1990/01/0219900102
Gender (ge)m / fMalem

Every normalized value is then SHA-256 hashed to lowercase hex before dispatch. Empty/unusable values are dropped (never hashed to the all-empty digest).


🧪 Local verification

npm run typecheck # strict TS, no emit
npm run lint # ESLint (type-checked rules)
npm run build # compile to dist/
npm test# run the Vitest suite (80 tests)
npm run test:coverage # tests + coverage report
npm run ci # typecheck + lint + build + test (what CI runs)

You can exercise the pipeline safely against Meta's Test Events tool by setting META_TEST_EVENT_CODE, and against Google by setting GOOGLE_VALIDATE_ONLY=true.

🐳 Docker

# Build & run with compose (reads your .env)
cp .env.example .env # then fill in secrets
docker compose up --build
# …or build the image directly
docker build -t capconnect:latest .
docker run --rm -p 3000:3000 --env-file .env capconnect:latest

The image is a multi-stage build that ships only production dependencies and the compiled dist/, runs as the non-root node user, exposes a HEALTHCHECK against /healthz, and (via compose) runs read-only with dropped capabilities and no-new-privileges.

🔁 CI

.github/workflows/ci.yml runs on every push/PR: typecheck → lint → build → test across Node 18/20/22, a coverage job, and a Docker build job.


🤝 Operational guidance

  • Terminate TLS in front of CapConnect (reverse proxy / load balancer) and keep the upstream hop encrypted.
  • Rotate WEBHOOK_SECRET and provider tokens via your secret manager; never bake them into images.
  • Do not enable request-body logging at the proxy for /v1/collect* — that would defeat the zero-knowledge property outside the app.
  • Scale horizontally — the service is stateless, so run as many replicas as you need behind a load balancer.

📄 License

CapConnect is distributed under the Business Source License 1.1 (BSL-1.1).

  • You may use, copy, modify, and self-host CapConnect freely, including internally at your company.
  • The Additional Use Grant permits production use except offering CapConnect (or a substantially similar service) to third parties as a hosted/managed commercial "conversion-relay" product.
  • On the Change Date (four years after each version's release), that version automatically converts to the Apache License 2.0.
Licensed under the Business Source License 1.1 (the "License");
you may not use this file except in compliance with the License.
Change License: Apache License, Version 2.0
Change Date: four (4) years from the date of each release.
Additional Use Grant: You may use the Licensed Work in production, except to
provide it to third parties as a hosted or managed commercial conversion-relay
service that competes with the Licensor's offering.
THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.

Prefer a copyleft model instead? CapConnect is also available under the GNU AGPL-3.0 on request — contact the maintainers. Choose BSL-1.1 for permissive self-hosting with a commercial-SaaS carve-out, or AGPL-3.0 if you want network-use copyleft obligations.


Built for the cookieless era. Your customers' data stays your customers'.

About

Privacy-first, zero-knowledge data connector: cleans & SHA-256 hashes first-party customer data in memory, then relays to Meta Conversions API & Google Ads Enhanced Conversions. No PII persisted.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

🔒 CapConnect

A privacy-first, zero-knowledge data connector for the cookieless era.

Clean → Hash (SHA-256) → Forward. First-party customer data is normalized and irreversibly hashed in memory before it is relayed to Meta Conversions API and Google Ads Enhanced Conversions. No raw PII ever touches a disk, a log, or a database.

License: BSL-1.1TypeScriptPII on disk


Why CapConnect exists

Third-party cookies are gone. To keep measuring conversions, advertisers must send first-party data server-to-server. But doing that safely is the hard part: the moment raw emails and phone numbers leave your perimeter, your legal and security teams own a new liability.

CapConnect is the safe middle layer. It accepts your raw events, normalizes and SHA-256 hashes every personal field the instant it arrives, and forwards only those one-way digests to the ad platforms — exactly the way Meta and Google specify. Because the plaintext exists only transiently in process memory and is never persisted, there is nothing to leak from CapConnect's storage. There is no storage.

For CTOs & Legal: CapConnect is a stateless relay. It holds no datastore, writes no PII to disk, and emits no PII in logs. The only personal data it transmits is already a non-reversible SHA-256 hash, sent over TLS to Meta/Google — the same hashing the platforms' own SDKs perform client-side.


✨ Features

  • Zero-knowledge by construction — plaintext PII lives only on the call stack during a single request; it is GC-eligible the moment the response is returned.
  • Spec-accurate normalization — email lower/trim, phone → E.164 (auto-prepends the country code, e.g. 81 for Japan), names/city/state/zip/DOB/gender all formatted per the official Meta & Google guidelines.
  • SHA-256 hashing with idempotent pass-through for upstream-pre-hashed inputs.
  • Meta Conversions API with deduplication event_id support to pair with the browser Pixel.
  • Google Ads Enhanced Conversions via uploadClickConversions with userIdentifiers.
  • Concurrent, fault-isolated dispatch — one platform failing never blocks the other.
  • Automatic retries with exponential backoff + jitter on transient errors (network / 429 / 5xx).
  • Automatic Google OAuth refresh (optional) — refresh-token exchange, in-memory caching, concurrent-refresh coalescing; nothing persisted.
  • Single & batch webhook endpoints (/v1/collect, /v1/collect/batch).
  • Hardened Express server — shared-secret auth (constant-time compare), body-size caps, x-powered-by disabled, graceful shutdown.
  • Strict TypeScript end to end (exactOptionalPropertyTypes, noUncheckedIndexedAccess, …).
  • 80 unit/integration tests (Vitest + Supertest), ESLint type-checked rules, multi-stage Docker image, and GitHub Actions CI.

🛡 The Zero-Knowledge Guarantee

flowchart LR
A["Upstream source<br/>(Webhook / CSV parse)"] -->|HTTPS + shared secret| B["CapConnect<br/>/v1/collect"]
subgraph MEM["⏱ In-memory only — never persisted, never logged"]
direction TB
C["Validate contract"] --> D["Normalize<br/>email · phone · name · address"]
D --> E["SHA-256 hash<br/>(irreversible, one-way)"]
E --> F["Build provider payloads<br/>(hashed digests only)"]
end
B --> C
F -->|TLS| G["Meta Conversions API<br/>(event_id dedup)"]
F -->|TLS| H["Google Ads API<br/>Enhanced Conversions"]
G --> I["Structured, PII-free<br/>dispatch report"]
H --> I
I -->|JSON response| A
classDef mem fill:#0b3d2e,stroke:#10b981,color:#e6fffa;
class MEM,C,D,E,F mem;
Loading

What crosses each boundary:

BoundaryData in transitForm
Upstream → CapConnectRaw identifiers + event metadataPlaintext over TLS (you control this hop)
Inside CapConnectIdentifiersNormalized then SHA-256 hashed, in memory only
CapConnect → Meta/GoogleIdentifiersSHA-256 hex digests + un-hashed match-support fields (fbc/fbp/IP/UA/gclid) per platform spec
CapConnect → UpstreamDispatch outcomeNo PII — only event_id, status, provider trace ids

🏗 Architecture

flowchart TD
subgraph SRC["src/"]
SV["server.ts<br/>Express app · auth · validation · routing"]
NM["utils/normalizer.ts<br/>normalize + SHA-256 (pure functions)"]
CP["services/capi.ts<br/>Meta + Google payload build & POST"]
TY["types/index.ts<br/>strict shared types"]
end
SV -->|"normalizeAndHash()"| NM
SV -->|"dispatchToAllProviders()"| CP
NM --- TY
CP --- TY
SV --- TY
Loading
FileResponsibility
src/types/index.tsSingle source of truth for every shape: raw input, hashed IR, provider payloads, responses, config.
src/utils/normalizer.tsField-by-field normalization (Meta/Google spec) + SHA-256. Pure, no I/O, no logging.
src/utils/retry.tsExponential-backoff retry helper; retries transient errors only.
src/services/capi.tsBuilds & POSTs Meta CAPI and Google Ads payloads concurrently; PII-free error reporting.
src/services/googleAuth.tsGoogle OAuth token providers (static + auto-refreshing).
src/server.tsHardened Express server, config loading/validation, webhook endpoints, graceful shutdown.
tests/Vitest unit + Supertest integration suites (80 tests).

🚀 Quick start

# 1. Install
npm install
# 2. Configure
cp .env.example .env
# …then edit .env with your Pixel id, access tokens, etc.# Generate a webhook secret: openssl rand -hex 32# 3. Develop (hot reload)
npm run dev
# 4. Production build & run
npm run build
npm start

Health check

curl -s http://localhost:3000/healthz | jq
{ "status": "ok", "service": "capconnect", "providers": { "meta": true, "google": true } }

📡 API

POST /v1/collect

Authenticated with the x-capconnect-token header (must equal WEBHOOK_SECRET).

Request

curl -s -X POST http://localhost:3000/v1/collect \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '{ "customer": { "email": " John.Doe@Example.COM ", "phone": "090-1234-5678", "firstName": "John", "lastName": "Doe", "country": "JP", "city": "Tokyo", "zip": "100-0001", "externalId": "user_42" }, "event": { "eventName": "Purchase", "eventId": "order-2025-0001", "value": 4980, "currency": "JPY", "orderId": "order-2025-0001", "actionSource": "website", "eventSourceUrl": "https://shop.example.com/thank-you", "fbc": "fb.1.1700000000000.IwAR...", "gclid": "Cj0KCQ..." } }'

The phone above is normalized to 819012345678 (leading 0 dropped, 81 prepended) and then SHA-256 hashed before it ever leaves the process.

Response (200 if at least one provider accepted; 422 if none did)

{
"eventId": "order-2025-0001",
"accepted": true,
"results": [
{ "provider": "meta", "status": "sent", "httpStatus": 200, "detail": "events_received=1 fbtrace_id=Aa...", "reference": "order-2025-0001" },
{ "provider": "google", "status": "sent", "httpStatus": 200, "detail": "results=1", "reference": "order-2025-0001" }
]
}

POST /v1/collect/batch

Same auth. Body is a JSON array of { customer, event } objects (max 1000). Each item is processed independently; invalid items are reported in validationErrors without failing the rest.

curl -s -X POST http://localhost:3000/v1/collect/batch \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '[ { "customer": { "email": "a@example.com" }, "event": { "eventName": "Lead" } } ]'

GET /healthz

Unauthenticated liveness/readiness probe. Returns no PII.


⚙️ Configuration

All configuration is via environment variables (see .env.example). The process fails fast at boot if an enabled provider is missing required secrets.

Server

VariableRequiredDefaultDescription
PORTno3000HTTP listen port.
WEBHOOK_SECRETyesShared secret required in the x-capconnect-token header.
DEFAULT_COUNTRY_CODEno81Digits-only calling code auto-prepended to non-international phones.
DEFAULT_CURRENCYnoJPYISO-4217 currency applied when an event omits one.
HTTP_TIMEOUT_MSno10000Outbound request timeout for both providers.
RETRY_MAX_ATTEMPTSno3Max attempts per provider call (incl. the first).
RETRY_BASE_DELAY_MSno300Base backoff delay; doubles each retry, full-jittered.
RETRY_MAX_DELAY_MSno5000Hard cap on any single backoff delay.

Meta Conversions API

VariableRequiredDefaultDescription
META_ENABLEDnotrueToggle Meta dispatch.
META_PIXEL_IDif enabledYour Pixel / dataset id.
META_ACCESS_TOKENif enabledSystem-user access token.
META_API_VERSIONnov20.0Graph API version.
META_TEST_EVENT_CODEnoEnables Meta's Test Events tool when set.

Google Ads API (Enhanced Conversions)

VariableRequiredDefaultDescription
GOOGLE_ENABLEDnotrueToggle Google dispatch.
GOOGLE_CUSTOMER_IDif enabledAccount id owning the conversion action (digits, no dashes).
GOOGLE_CONVERSION_ACTION_IDif enabledNumeric conversion action id.
GOOGLE_DEVELOPER_TOKENif enabledGoogle Ads API developer token.
GOOGLE_OAUTH_ACCESS_TOKENauth APre-issued OAuth2 Bearer token (you refresh).
GOOGLE_OAUTH_CLIENT_IDauth BOAuth client id for automatic refresh.
GOOGLE_OAUTH_CLIENT_SECRETauth BOAuth client secret for automatic refresh.
GOOGLE_OAUTH_REFRESH_TOKENauth BOAuth refresh token (held in memory only).
GOOGLE_OAUTH_TOKEN_URInohttps://oauth2.googleapis.com/tokenToken endpoint.
GOOGLE_LOGIN_CUSTOMER_IDnoMCC / manager id for login-customer-id.
GOOGLE_API_VERSIONnov17Google Ads API version.
GOOGLE_VALIDATE_ONLYnofalseValidate without recording a conversion.

OAuth — two strategies (provide exactly one):(A) Inject a short-lived GOOGLE_OAUTH_ACCESS_TOKEN from your own secret manager and handle refresh externally; or (B) provide GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET + GOOGLE_OAUTH_REFRESH_TOKEN and CapConnect refreshes access tokens automatically — caching them in memory, coalescing concurrent refreshes, and renewing ~60s before expiry. The refresh token is never persisted or logged. Boot fails fast if neither strategy is fully configured while Google is enabled.

Resilience

  • Automatic retries with exponential backoff + full jitter on transient failures only (network errors, HTTP 429, HTTP 5xx). Deterministic 4xx errors are never retried. Tune via RETRY_*.
  • Fault isolation — a Meta failure never blocks Google (and vice-versa); a Google token-refresh failure is reported as a Google-only auth: error while Meta still dispatches.

🔐 Normalization rules (Meta/Google compliant)

FieldRuleExample → normalized
Email (em)trim, lowercase (no dot-stripping) A.B@Ex.COMa.b@ex.com
Phone (ph)digits only → E.164 (drop trunk 0, prepend country code)090-1234-5678819012345678
First/Last name (fn/ln)lowercase, strip punctuation, collapse spacesO'Brienobrien
City (ct)lowercase, remove all whitespace & punctuationNew Yorknewyork
State (st)lowercase, letters onlyCAca
Zip (zp)lowercase, strip spaces, drop +4, US→first 5100-0001100
Country (country)ISO alpha-2, lowercaseJapan→ first 2 letters
DOB (db)parse → YYYYMMDD1990/01/0219900102
Gender (ge)m / fMalem

Every normalized value is then SHA-256 hashed to lowercase hex before dispatch. Empty/unusable values are dropped (never hashed to the all-empty digest).


🧪 Local verification

npm run typecheck # strict TS, no emit
npm run lint # ESLint (type-checked rules)
npm run build # compile to dist/
npm test# run the Vitest suite (80 tests)
npm run test:coverage # tests + coverage report
npm run ci # typecheck + lint + build + test (what CI runs)

You can exercise the pipeline safely against Meta's Test Events tool by setting META_TEST_EVENT_CODE, and against Google by setting GOOGLE_VALIDATE_ONLY=true.

🐳 Docker

# Build & run with compose (reads your .env)
cp .env.example .env # then fill in secrets
docker compose up --build
# …or build the image directly
docker build -t capconnect:latest .
docker run --rm -p 3000:3000 --env-file .env capconnect:latest

The image is a multi-stage build that ships only production dependencies and the compiled dist/, runs as the non-root node user, exposes a HEALTHCHECK against /healthz, and (via compose) runs read-only with dropped capabilities and no-new-privileges.

🔁 CI

.github/workflows/ci.yml runs on every push/PR: typecheck → lint → build → test across Node 18/20/22, a coverage job, and a Docker build job.


🤝 Operational guidance

  • Terminate TLS in front of CapConnect (reverse proxy / load balancer) and keep the upstream hop encrypted.
  • Rotate WEBHOOK_SECRET and provider tokens via your secret manager; never bake them into images.
  • Do not enable request-body logging at the proxy for /v1/collect* — that would defeat the zero-knowledge property outside the app.
  • Scale horizontally — the service is stateless, so run as many replicas as you need behind a load balancer.

📄 License

CapConnect is distributed under the Business Source License 1.1 (BSL-1.1).

  • You may use, copy, modify, and self-host CapConnect freely, including internally at your company.
  • The Additional Use Grant permits production use except offering CapConnect (or a substantially similar service) to third parties as a hosted/managed commercial "conversion-relay" product.
  • On the Change Date (four years after each version's release), that version automatically converts to the Apache License 2.0.
Licensed under the Business Source License 1.1 (the "License");
you may not use this file except in compliance with the License.
Change License: Apache License, Version 2.0
Change Date: four (4) years from the date of each release.
Additional Use Grant: You may use the Licensed Work in production, except to
provide it to third parties as a hosted or managed commercial conversion-relay
service that competes with the Licensor's offering.
THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.

Prefer a copyleft model instead? CapConnect is also available under the GNU AGPL-3.0 on request — contact the maintainers. Choose BSL-1.1 for permissive self-hosting with a commercial-SaaS carve-out, or AGPL-3.0 if you want network-use copyleft obligations.


Built for the cookieless era. Your customers' data stays your customers'.

About

Privacy-first, zero-knowledge data connector: cleans & SHA-256 hashes first-party customer data in memory, then relays to Meta Conversions API & Google Ads Enhanced Conversions. No PII persisted.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

🔒 CapConnect

A privacy-first, zero-knowledge data connector for the cookieless era.

Clean → Hash (SHA-256) → Forward. First-party customer data is normalized and irreversibly hashed in memory before it is relayed to Meta Conversions API and Google Ads Enhanced Conversions. No raw PII ever touches a disk, a log, or a database.

License: BSL-1.1TypeScriptPII on disk


Why CapConnect exists

Third-party cookies are gone. To keep measuring conversions, advertisers must send first-party data server-to-server. But doing that safely is the hard part: the moment raw emails and phone numbers leave your perimeter, your legal and security teams own a new liability.

CapConnect is the safe middle layer. It accepts your raw events, normalizes and SHA-256 hashes every personal field the instant it arrives, and forwards only those one-way digests to the ad platforms — exactly the way Meta and Google specify. Because the plaintext exists only transiently in process memory and is never persisted, there is nothing to leak from CapConnect's storage. There is no storage.

For CTOs & Legal: CapConnect is a stateless relay. It holds no datastore, writes no PII to disk, and emits no PII in logs. The only personal data it transmits is already a non-reversible SHA-256 hash, sent over TLS to Meta/Google — the same hashing the platforms' own SDKs perform client-side.


✨ Features

  • Zero-knowledge by construction — plaintext PII lives only on the call stack during a single request; it is GC-eligible the moment the response is returned.
  • Spec-accurate normalization — email lower/trim, phone → E.164 (auto-prepends the country code, e.g. 81 for Japan), names/city/state/zip/DOB/gender all formatted per the official Meta & Google guidelines.
  • SHA-256 hashing with idempotent pass-through for upstream-pre-hashed inputs.
  • Meta Conversions API with deduplication event_id support to pair with the browser Pixel.
  • Google Ads Enhanced Conversions via uploadClickConversions with userIdentifiers.
  • Concurrent, fault-isolated dispatch — one platform failing never blocks the other.
  • Automatic retries with exponential backoff + jitter on transient errors (network / 429 / 5xx).
  • Automatic Google OAuth refresh (optional) — refresh-token exchange, in-memory caching, concurrent-refresh coalescing; nothing persisted.
  • Single & batch webhook endpoints (/v1/collect, /v1/collect/batch).
  • Hardened Express server — shared-secret auth (constant-time compare), body-size caps, x-powered-by disabled, graceful shutdown.
  • Strict TypeScript end to end (exactOptionalPropertyTypes, noUncheckedIndexedAccess, …).
  • 80 unit/integration tests (Vitest + Supertest), ESLint type-checked rules, multi-stage Docker image, and GitHub Actions CI.

🛡 The Zero-Knowledge Guarantee

flowchart LR
A["Upstream source<br/>(Webhook / CSV parse)"] -->|HTTPS + shared secret| B["CapConnect<br/>/v1/collect"]
subgraph MEM["⏱ In-memory only — never persisted, never logged"]
direction TB
C["Validate contract"] --> D["Normalize<br/>email · phone · name · address"]
D --> E["SHA-256 hash<br/>(irreversible, one-way)"]
E --> F["Build provider payloads<br/>(hashed digests only)"]
end
B --> C
F -->|TLS| G["Meta Conversions API<br/>(event_id dedup)"]
F -->|TLS| H["Google Ads API<br/>Enhanced Conversions"]
G --> I["Structured, PII-free<br/>dispatch report"]
H --> I
I -->|JSON response| A
classDef mem fill:#0b3d2e,stroke:#10b981,color:#e6fffa;
class MEM,C,D,E,F mem;
Loading

What crosses each boundary:

BoundaryData in transitForm
Upstream → CapConnectRaw identifiers + event metadataPlaintext over TLS (you control this hop)
Inside CapConnectIdentifiersNormalized then SHA-256 hashed, in memory only
CapConnect → Meta/GoogleIdentifiersSHA-256 hex digests + un-hashed match-support fields (fbc/fbp/IP/UA/gclid) per platform spec
CapConnect → UpstreamDispatch outcomeNo PII — only event_id, status, provider trace ids

🏗 Architecture

flowchart TD
subgraph SRC["src/"]
SV["server.ts<br/>Express app · auth · validation · routing"]
NM["utils/normalizer.ts<br/>normalize + SHA-256 (pure functions)"]
CP["services/capi.ts<br/>Meta + Google payload build & POST"]
TY["types/index.ts<br/>strict shared types"]
end
SV -->|"normalizeAndHash()"| NM
SV -->|"dispatchToAllProviders()"| CP
NM --- TY
CP --- TY
SV --- TY
Loading
FileResponsibility
src/types/index.tsSingle source of truth for every shape: raw input, hashed IR, provider payloads, responses, config.
src/utils/normalizer.tsField-by-field normalization (Meta/Google spec) + SHA-256. Pure, no I/O, no logging.
src/utils/retry.tsExponential-backoff retry helper; retries transient errors only.
src/services/capi.tsBuilds & POSTs Meta CAPI and Google Ads payloads concurrently; PII-free error reporting.
src/services/googleAuth.tsGoogle OAuth token providers (static + auto-refreshing).
src/server.tsHardened Express server, config loading/validation, webhook endpoints, graceful shutdown.
tests/Vitest unit + Supertest integration suites (80 tests).

🚀 Quick start

# 1. Install
npm install
# 2. Configure
cp .env.example .env
# …then edit .env with your Pixel id, access tokens, etc.# Generate a webhook secret: openssl rand -hex 32# 3. Develop (hot reload)
npm run dev
# 4. Production build & run
npm run build
npm start

Health check

curl -s http://localhost:3000/healthz | jq
{ "status": "ok", "service": "capconnect", "providers": { "meta": true, "google": true } }

📡 API

POST /v1/collect

Authenticated with the x-capconnect-token header (must equal WEBHOOK_SECRET).

Request

curl -s -X POST http://localhost:3000/v1/collect \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '{ "customer": { "email": " John.Doe@Example.COM ", "phone": "090-1234-5678", "firstName": "John", "lastName": "Doe", "country": "JP", "city": "Tokyo", "zip": "100-0001", "externalId": "user_42" }, "event": { "eventName": "Purchase", "eventId": "order-2025-0001", "value": 4980, "currency": "JPY", "orderId": "order-2025-0001", "actionSource": "website", "eventSourceUrl": "https://shop.example.com/thank-you", "fbc": "fb.1.1700000000000.IwAR...", "gclid": "Cj0KCQ..." } }'

The phone above is normalized to 819012345678 (leading 0 dropped, 81 prepended) and then SHA-256 hashed before it ever leaves the process.

Response (200 if at least one provider accepted; 422 if none did)

{
"eventId": "order-2025-0001",
"accepted": true,
"results": [
{ "provider": "meta", "status": "sent", "httpStatus": 200, "detail": "events_received=1 fbtrace_id=Aa...", "reference": "order-2025-0001" },
{ "provider": "google", "status": "sent", "httpStatus": 200, "detail": "results=1", "reference": "order-2025-0001" }
]
}

POST /v1/collect/batch

Same auth. Body is a JSON array of { customer, event } objects (max 1000). Each item is processed independently; invalid items are reported in validationErrors without failing the rest.

curl -s -X POST http://localhost:3000/v1/collect/batch \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '[ { "customer": { "email": "a@example.com" }, "event": { "eventName": "Lead" } } ]'

GET /healthz

Unauthenticated liveness/readiness probe. Returns no PII.


⚙️ Configuration

All configuration is via environment variables (see .env.example). The process fails fast at boot if an enabled provider is missing required secrets.

Server

VariableRequiredDefaultDescription
PORTno3000HTTP listen port.
WEBHOOK_SECRETyesShared secret required in the x-capconnect-token header.
DEFAULT_COUNTRY_CODEno81Digits-only calling code auto-prepended to non-international phones.
DEFAULT_CURRENCYnoJPYISO-4217 currency applied when an event omits one.
HTTP_TIMEOUT_MSno10000Outbound request timeout for both providers.
RETRY_MAX_ATTEMPTSno3Max attempts per provider call (incl. the first).
RETRY_BASE_DELAY_MSno300Base backoff delay; doubles each retry, full-jittered.
RETRY_MAX_DELAY_MSno5000Hard cap on any single backoff delay.

Meta Conversions API

VariableRequiredDefaultDescription
META_ENABLEDnotrueToggle Meta dispatch.
META_PIXEL_IDif enabledYour Pixel / dataset id.
META_ACCESS_TOKENif enabledSystem-user access token.
META_API_VERSIONnov20.0Graph API version.
META_TEST_EVENT_CODEnoEnables Meta's Test Events tool when set.

Google Ads API (Enhanced Conversions)

VariableRequiredDefaultDescription
GOOGLE_ENABLEDnotrueToggle Google dispatch.
GOOGLE_CUSTOMER_IDif enabledAccount id owning the conversion action (digits, no dashes).
GOOGLE_CONVERSION_ACTION_IDif enabledNumeric conversion action id.
GOOGLE_DEVELOPER_TOKENif enabledGoogle Ads API developer token.
GOOGLE_OAUTH_ACCESS_TOKENauth APre-issued OAuth2 Bearer token (you refresh).
GOOGLE_OAUTH_CLIENT_IDauth BOAuth client id for automatic refresh.
GOOGLE_OAUTH_CLIENT_SECRETauth BOAuth client secret for automatic refresh.
GOOGLE_OAUTH_REFRESH_TOKENauth BOAuth refresh token (held in memory only).
GOOGLE_OAUTH_TOKEN_URInohttps://oauth2.googleapis.com/tokenToken endpoint.
GOOGLE_LOGIN_CUSTOMER_IDnoMCC / manager id for login-customer-id.
GOOGLE_API_VERSIONnov17Google Ads API version.
GOOGLE_VALIDATE_ONLYnofalseValidate without recording a conversion.

OAuth — two strategies (provide exactly one):(A) Inject a short-lived GOOGLE_OAUTH_ACCESS_TOKEN from your own secret manager and handle refresh externally; or (B) provide GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET + GOOGLE_OAUTH_REFRESH_TOKEN and CapConnect refreshes access tokens automatically — caching them in memory, coalescing concurrent refreshes, and renewing ~60s before expiry. The refresh token is never persisted or logged. Boot fails fast if neither strategy is fully configured while Google is enabled.

Resilience

  • Automatic retries with exponential backoff + full jitter on transient failures only (network errors, HTTP 429, HTTP 5xx). Deterministic 4xx errors are never retried. Tune via RETRY_*.
  • Fault isolation — a Meta failure never blocks Google (and vice-versa); a Google token-refresh failure is reported as a Google-only auth: error while Meta still dispatches.

🔐 Normalization rules (Meta/Google compliant)

FieldRuleExample → normalized
Email (em)trim, lowercase (no dot-stripping) A.B@Ex.COMa.b@ex.com
Phone (ph)digits only → E.164 (drop trunk 0, prepend country code)090-1234-5678819012345678
First/Last name (fn/ln)lowercase, strip punctuation, collapse spacesO'Brienobrien
City (ct)lowercase, remove all whitespace & punctuationNew Yorknewyork
State (st)lowercase, letters onlyCAca
Zip (zp)lowercase, strip spaces, drop +4, US→first 5100-0001100
Country (country)ISO alpha-2, lowercaseJapan→ first 2 letters
DOB (db)parse → YYYYMMDD1990/01/0219900102
Gender (ge)m / fMalem

Every normalized value is then SHA-256 hashed to lowercase hex before dispatch. Empty/unusable values are dropped (never hashed to the all-empty digest).


🧪 Local verification

npm run typecheck # strict TS, no emit
npm run lint # ESLint (type-checked rules)
npm run build # compile to dist/
npm test# run the Vitest suite (80 tests)
npm run test:coverage # tests + coverage report
npm run ci # typecheck + lint + build + test (what CI runs)

You can exercise the pipeline safely against Meta's Test Events tool by setting META_TEST_EVENT_CODE, and against Google by setting GOOGLE_VALIDATE_ONLY=true.

🐳 Docker

# Build & run with compose (reads your .env)
cp .env.example .env # then fill in secrets
docker compose up --build
# …or build the image directly
docker build -t capconnect:latest .
docker run --rm -p 3000:3000 --env-file .env capconnect:latest

The image is a multi-stage build that ships only production dependencies and the compiled dist/, runs as the non-root node user, exposes a HEALTHCHECK against /healthz, and (via compose) runs read-only with dropped capabilities and no-new-privileges.

🔁 CI

.github/workflows/ci.yml runs on every push/PR: typecheck → lint → build → test across Node 18/20/22, a coverage job, and a Docker build job.


🤝 Operational guidance

  • Terminate TLS in front of CapConnect (reverse proxy / load balancer) and keep the upstream hop encrypted.
  • Rotate WEBHOOK_SECRET and provider tokens via your secret manager; never bake them into images.
  • Do not enable request-body logging at the proxy for /v1/collect* — that would defeat the zero-knowledge property outside the app.
  • Scale horizontally — the service is stateless, so run as many replicas as you need behind a load balancer.

📄 License

CapConnect is distributed under the Business Source License 1.1 (BSL-1.1).

  • You may use, copy, modify, and self-host CapConnect freely, including internally at your company.
  • The Additional Use Grant permits production use except offering CapConnect (or a substantially similar service) to third parties as a hosted/managed commercial "conversion-relay" product.
  • On the Change Date (four years after each version's release), that version automatically converts to the Apache License 2.0.
Licensed under the Business Source License 1.1 (the "License");
you may not use this file except in compliance with the License.
Change License: Apache License, Version 2.0
Change Date: four (4) years from the date of each release.
Additional Use Grant: You may use the Licensed Work in production, except to
provide it to third parties as a hosted or managed commercial conversion-relay
service that competes with the Licensor's offering.
THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.

Prefer a copyleft model instead? CapConnect is also available under the GNU AGPL-3.0 on request — contact the maintainers. Choose BSL-1.1 for permissive self-hosting with a commercial-SaaS carve-out, or AGPL-3.0 if you want network-use copyleft obligations.


Built for the cookieless era. Your customers' data stays your customers'.

About

Privacy-first, zero-knowledge data connector: cleans & SHA-256 hashes first-party customer data in memory, then relays to Meta Conversions API & Google Ads Enhanced Conversions. No PII persisted.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

🔒 CapConnect

A privacy-first, zero-knowledge data connector for the cookieless era.

Clean → Hash (SHA-256) → Forward. First-party customer data is normalized and irreversibly hashed in memory before it is relayed to Meta Conversions API and Google Ads Enhanced Conversions. No raw PII ever touches a disk, a log, or a database.

License: BSL-1.1TypeScriptPII on disk


Why CapConnect exists

Third-party cookies are gone. To keep measuring conversions, advertisers must send first-party data server-to-server. But doing that safely is the hard part: the moment raw emails and phone numbers leave your perimeter, your legal and security teams own a new liability.

CapConnect is the safe middle layer. It accepts your raw events, normalizes and SHA-256 hashes every personal field the instant it arrives, and forwards only those one-way digests to the ad platforms — exactly the way Meta and Google specify. Because the plaintext exists only transiently in process memory and is never persisted, there is nothing to leak from CapConnect's storage. There is no storage.

For CTOs & Legal: CapConnect is a stateless relay. It holds no datastore, writes no PII to disk, and emits no PII in logs. The only personal data it transmits is already a non-reversible SHA-256 hash, sent over TLS to Meta/Google — the same hashing the platforms' own SDKs perform client-side.


✨ Features

  • Zero-knowledge by construction — plaintext PII lives only on the call stack during a single request; it is GC-eligible the moment the response is returned.
  • Spec-accurate normalization — email lower/trim, phone → E.164 (auto-prepends the country code, e.g. 81 for Japan), names/city/state/zip/DOB/gender all formatted per the official Meta & Google guidelines.
  • SHA-256 hashing with idempotent pass-through for upstream-pre-hashed inputs.
  • Meta Conversions API with deduplication event_id support to pair with the browser Pixel.
  • Google Ads Enhanced Conversions via uploadClickConversions with userIdentifiers.
  • Concurrent, fault-isolated dispatch — one platform failing never blocks the other.
  • Automatic retries with exponential backoff + jitter on transient errors (network / 429 / 5xx).
  • Automatic Google OAuth refresh (optional) — refresh-token exchange, in-memory caching, concurrent-refresh coalescing; nothing persisted.
  • Single & batch webhook endpoints (/v1/collect, /v1/collect/batch).
  • Hardened Express server — shared-secret auth (constant-time compare), body-size caps, x-powered-by disabled, graceful shutdown.
  • Strict TypeScript end to end (exactOptionalPropertyTypes, noUncheckedIndexedAccess, …).
  • 80 unit/integration tests (Vitest + Supertest), ESLint type-checked rules, multi-stage Docker image, and GitHub Actions CI.

🛡 The Zero-Knowledge Guarantee

flowchart LR
A["Upstream source<br/>(Webhook / CSV parse)"] -->|HTTPS + shared secret| B["CapConnect<br/>/v1/collect"]
subgraph MEM["⏱ In-memory only — never persisted, never logged"]
direction TB
C["Validate contract"] --> D["Normalize<br/>email · phone · name · address"]
D --> E["SHA-256 hash<br/>(irreversible, one-way)"]
E --> F["Build provider payloads<br/>(hashed digests only)"]
end
B --> C
F -->|TLS| G["Meta Conversions API<br/>(event_id dedup)"]
F -->|TLS| H["Google Ads API<br/>Enhanced Conversions"]
G --> I["Structured, PII-free<br/>dispatch report"]
H --> I
I -->|JSON response| A
classDef mem fill:#0b3d2e,stroke:#10b981,color:#e6fffa;
class MEM,C,D,E,F mem;
Loading

What crosses each boundary:

BoundaryData in transitForm
Upstream → CapConnectRaw identifiers + event metadataPlaintext over TLS (you control this hop)
Inside CapConnectIdentifiersNormalized then SHA-256 hashed, in memory only
CapConnect → Meta/GoogleIdentifiersSHA-256 hex digests + un-hashed match-support fields (fbc/fbp/IP/UA/gclid) per platform spec
CapConnect → UpstreamDispatch outcomeNo PII — only event_id, status, provider trace ids

🏗 Architecture

flowchart TD
subgraph SRC["src/"]
SV["server.ts<br/>Express app · auth · validation · routing"]
NM["utils/normalizer.ts<br/>normalize + SHA-256 (pure functions)"]
CP["services/capi.ts<br/>Meta + Google payload build & POST"]
TY["types/index.ts<br/>strict shared types"]
end
SV -->|"normalizeAndHash()"| NM
SV -->|"dispatchToAllProviders()"| CP
NM --- TY
CP --- TY
SV --- TY
Loading
FileResponsibility
src/types/index.tsSingle source of truth for every shape: raw input, hashed IR, provider payloads, responses, config.
src/utils/normalizer.tsField-by-field normalization (Meta/Google spec) + SHA-256. Pure, no I/O, no logging.
src/utils/retry.tsExponential-backoff retry helper; retries transient errors only.
src/services/capi.tsBuilds & POSTs Meta CAPI and Google Ads payloads concurrently; PII-free error reporting.
src/services/googleAuth.tsGoogle OAuth token providers (static + auto-refreshing).
src/server.tsHardened Express server, config loading/validation, webhook endpoints, graceful shutdown.
tests/Vitest unit + Supertest integration suites (80 tests).

🚀 Quick start

# 1. Install
npm install
# 2. Configure
cp .env.example .env
# …then edit .env with your Pixel id, access tokens, etc.# Generate a webhook secret: openssl rand -hex 32# 3. Develop (hot reload)
npm run dev
# 4. Production build & run
npm run build
npm start

Health check

curl -s http://localhost:3000/healthz | jq
{ "status": "ok", "service": "capconnect", "providers": { "meta": true, "google": true } }

📡 API

POST /v1/collect

Authenticated with the x-capconnect-token header (must equal WEBHOOK_SECRET).

Request

curl -s -X POST http://localhost:3000/v1/collect \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '{ "customer": { "email": " John.Doe@Example.COM ", "phone": "090-1234-5678", "firstName": "John", "lastName": "Doe", "country": "JP", "city": "Tokyo", "zip": "100-0001", "externalId": "user_42" }, "event": { "eventName": "Purchase", "eventId": "order-2025-0001", "value": 4980, "currency": "JPY", "orderId": "order-2025-0001", "actionSource": "website", "eventSourceUrl": "https://shop.example.com/thank-you", "fbc": "fb.1.1700000000000.IwAR...", "gclid": "Cj0KCQ..." } }'

The phone above is normalized to 819012345678 (leading 0 dropped, 81 prepended) and then SHA-256 hashed before it ever leaves the process.

Response (200 if at least one provider accepted; 422 if none did)

{
"eventId": "order-2025-0001",
"accepted": true,
"results": [
{ "provider": "meta", "status": "sent", "httpStatus": 200, "detail": "events_received=1 fbtrace_id=Aa...", "reference": "order-2025-0001" },
{ "provider": "google", "status": "sent", "httpStatus": 200, "detail": "results=1", "reference": "order-2025-0001" }
]
}

POST /v1/collect/batch

Same auth. Body is a JSON array of { customer, event } objects (max 1000). Each item is processed independently; invalid items are reported in validationErrors without failing the rest.

curl -s -X POST http://localhost:3000/v1/collect/batch \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '[ { "customer": { "email": "a@example.com" }, "event": { "eventName": "Lead" } } ]'

GET /healthz

Unauthenticated liveness/readiness probe. Returns no PII.


⚙️ Configuration

All configuration is via environment variables (see .env.example). The process fails fast at boot if an enabled provider is missing required secrets.

Server

VariableRequiredDefaultDescription
PORTno3000HTTP listen port.
WEBHOOK_SECRETyesShared secret required in the x-capconnect-token header.
DEFAULT_COUNTRY_CODEno81Digits-only calling code auto-prepended to non-international phones.
DEFAULT_CURRENCYnoJPYISO-4217 currency applied when an event omits one.
HTTP_TIMEOUT_MSno10000Outbound request timeout for both providers.
RETRY_MAX_ATTEMPTSno3Max attempts per provider call (incl. the first).
RETRY_BASE_DELAY_MSno300Base backoff delay; doubles each retry, full-jittered.
RETRY_MAX_DELAY_MSno5000Hard cap on any single backoff delay.

Meta Conversions API

VariableRequiredDefaultDescription
META_ENABLEDnotrueToggle Meta dispatch.
META_PIXEL_IDif enabledYour Pixel / dataset id.
META_ACCESS_TOKENif enabledSystem-user access token.
META_API_VERSIONnov20.0Graph API version.
META_TEST_EVENT_CODEnoEnables Meta's Test Events tool when set.

Google Ads API (Enhanced Conversions)

VariableRequiredDefaultDescription
GOOGLE_ENABLEDnotrueToggle Google dispatch.
GOOGLE_CUSTOMER_IDif enabledAccount id owning the conversion action (digits, no dashes).
GOOGLE_CONVERSION_ACTION_IDif enabledNumeric conversion action id.
GOOGLE_DEVELOPER_TOKENif enabledGoogle Ads API developer token.
GOOGLE_OAUTH_ACCESS_TOKENauth APre-issued OAuth2 Bearer token (you refresh).
GOOGLE_OAUTH_CLIENT_IDauth BOAuth client id for automatic refresh.
GOOGLE_OAUTH_CLIENT_SECRETauth BOAuth client secret for automatic refresh.
GOOGLE_OAUTH_REFRESH_TOKENauth BOAuth refresh token (held in memory only).
GOOGLE_OAUTH_TOKEN_URInohttps://oauth2.googleapis.com/tokenToken endpoint.
GOOGLE_LOGIN_CUSTOMER_IDnoMCC / manager id for login-customer-id.
GOOGLE_API_VERSIONnov17Google Ads API version.
GOOGLE_VALIDATE_ONLYnofalseValidate without recording a conversion.

OAuth — two strategies (provide exactly one):(A) Inject a short-lived GOOGLE_OAUTH_ACCESS_TOKEN from your own secret manager and handle refresh externally; or (B) provide GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET + GOOGLE_OAUTH_REFRESH_TOKEN and CapConnect refreshes access tokens automatically — caching them in memory, coalescing concurrent refreshes, and renewing ~60s before expiry. The refresh token is never persisted or logged. Boot fails fast if neither strategy is fully configured while Google is enabled.

Resilience

  • Automatic retries with exponential backoff + full jitter on transient failures only (network errors, HTTP 429, HTTP 5xx). Deterministic 4xx errors are never retried. Tune via RETRY_*.
  • Fault isolation — a Meta failure never blocks Google (and vice-versa); a Google token-refresh failure is reported as a Google-only auth: error while Meta still dispatches.

🔐 Normalization rules (Meta/Google compliant)

FieldRuleExample → normalized
Email (em)trim, lowercase (no dot-stripping) A.B@Ex.COMa.b@ex.com
Phone (ph)digits only → E.164 (drop trunk 0, prepend country code)090-1234-5678819012345678
First/Last name (fn/ln)lowercase, strip punctuation, collapse spacesO'Brienobrien
City (ct)lowercase, remove all whitespace & punctuationNew Yorknewyork
State (st)lowercase, letters onlyCAca
Zip (zp)lowercase, strip spaces, drop +4, US→first 5100-0001100
Country (country)ISO alpha-2, lowercaseJapan→ first 2 letters
DOB (db)parse → YYYYMMDD1990/01/0219900102
Gender (ge)m / fMalem

Every normalized value is then SHA-256 hashed to lowercase hex before dispatch. Empty/unusable values are dropped (never hashed to the all-empty digest).


🧪 Local verification

npm run typecheck # strict TS, no emit
npm run lint # ESLint (type-checked rules)
npm run build # compile to dist/
npm test# run the Vitest suite (80 tests)
npm run test:coverage # tests + coverage report
npm run ci # typecheck + lint + build + test (what CI runs)

You can exercise the pipeline safely against Meta's Test Events tool by setting META_TEST_EVENT_CODE, and against Google by setting GOOGLE_VALIDATE_ONLY=true.

🐳 Docker

# Build & run with compose (reads your .env)
cp .env.example .env # then fill in secrets
docker compose up --build
# …or build the image directly
docker build -t capconnect:latest .
docker run --rm -p 3000:3000 --env-file .env capconnect:latest

The image is a multi-stage build that ships only production dependencies and the compiled dist/, runs as the non-root node user, exposes a HEALTHCHECK against /healthz, and (via compose) runs read-only with dropped capabilities and no-new-privileges.

🔁 CI

.github/workflows/ci.yml runs on every push/PR: typecheck → lint → build → test across Node 18/20/22, a coverage job, and a Docker build job.


🤝 Operational guidance

  • Terminate TLS in front of CapConnect (reverse proxy / load balancer) and keep the upstream hop encrypted.
  • Rotate WEBHOOK_SECRET and provider tokens via your secret manager; never bake them into images.
  • Do not enable request-body logging at the proxy for /v1/collect* — that would defeat the zero-knowledge property outside the app.
  • Scale horizontally — the service is stateless, so run as many replicas as you need behind a load balancer.

📄 License

CapConnect is distributed under the Business Source License 1.1 (BSL-1.1).

  • You may use, copy, modify, and self-host CapConnect freely, including internally at your company.
  • The Additional Use Grant permits production use except offering CapConnect (or a substantially similar service) to third parties as a hosted/managed commercial "conversion-relay" product.
  • On the Change Date (four years after each version's release), that version automatically converts to the Apache License 2.0.
Licensed under the Business Source License 1.1 (the "License");
you may not use this file except in compliance with the License.
Change License: Apache License, Version 2.0
Change Date: four (4) years from the date of each release.
Additional Use Grant: You may use the Licensed Work in production, except to
provide it to third parties as a hosted or managed commercial conversion-relay
service that competes with the Licensor's offering.
THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.

Prefer a copyleft model instead? CapConnect is also available under the GNU AGPL-3.0 on request — contact the maintainers. Choose BSL-1.1 for permissive self-hosting with a commercial-SaaS carve-out, or AGPL-3.0 if you want network-use copyleft obligations.


Built for the cookieless era. Your customers' data stays your customers'.

About

Privacy-first, zero-knowledge data connector: cleans & SHA-256 hashes first-party customer data in memory, then relays to Meta Conversions API & Google Ads Enhanced Conversions. No PII persisted.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

🔒 CapConnect

A privacy-first, zero-knowledge data connector for the cookieless era.

Clean → Hash (SHA-256) → Forward. First-party customer data is normalized and irreversibly hashed in memory before it is relayed to Meta Conversions API and Google Ads Enhanced Conversions. No raw PII ever touches a disk, a log, or a database.

License: BSL-1.1TypeScriptPII on disk


Why CapConnect exists

Third-party cookies are gone. To keep measuring conversions, advertisers must send first-party data server-to-server. But doing that safely is the hard part: the moment raw emails and phone numbers leave your perimeter, your legal and security teams own a new liability.

CapConnect is the safe middle layer. It accepts your raw events, normalizes and SHA-256 hashes every personal field the instant it arrives, and forwards only those one-way digests to the ad platforms — exactly the way Meta and Google specify. Because the plaintext exists only transiently in process memory and is never persisted, there is nothing to leak from CapConnect's storage. There is no storage.

For CTOs & Legal: CapConnect is a stateless relay. It holds no datastore, writes no PII to disk, and emits no PII in logs. The only personal data it transmits is already a non-reversible SHA-256 hash, sent over TLS to Meta/Google — the same hashing the platforms' own SDKs perform client-side.


✨ Features

  • Zero-knowledge by construction — plaintext PII lives only on the call stack during a single request; it is GC-eligible the moment the response is returned.
  • Spec-accurate normalization — email lower/trim, phone → E.164 (auto-prepends the country code, e.g. 81 for Japan), names/city/state/zip/DOB/gender all formatted per the official Meta & Google guidelines.
  • SHA-256 hashing with idempotent pass-through for upstream-pre-hashed inputs.
  • Meta Conversions API with deduplication event_id support to pair with the browser Pixel.
  • Google Ads Enhanced Conversions via uploadClickConversions with userIdentifiers.
  • Concurrent, fault-isolated dispatch — one platform failing never blocks the other.
  • Automatic retries with exponential backoff + jitter on transient errors (network / 429 / 5xx).
  • Automatic Google OAuth refresh (optional) — refresh-token exchange, in-memory caching, concurrent-refresh coalescing; nothing persisted.
  • Single & batch webhook endpoints (/v1/collect, /v1/collect/batch).
  • Hardened Express server — shared-secret auth (constant-time compare), body-size caps, x-powered-by disabled, graceful shutdown.
  • Strict TypeScript end to end (exactOptionalPropertyTypes, noUncheckedIndexedAccess, …).
  • 80 unit/integration tests (Vitest + Supertest), ESLint type-checked rules, multi-stage Docker image, and GitHub Actions CI.

🛡 The Zero-Knowledge Guarantee

flowchart LR
A["Upstream source<br/>(Webhook / CSV parse)"] -->|HTTPS + shared secret| B["CapConnect<br/>/v1/collect"]
subgraph MEM["⏱ In-memory only — never persisted, never logged"]
direction TB
C["Validate contract"] --> D["Normalize<br/>email · phone · name · address"]
D --> E["SHA-256 hash<br/>(irreversible, one-way)"]
E --> F["Build provider payloads<br/>(hashed digests only)"]
end
B --> C
F -->|TLS| G["Meta Conversions API<br/>(event_id dedup)"]
F -->|TLS| H["Google Ads API<br/>Enhanced Conversions"]
G --> I["Structured, PII-free<br/>dispatch report"]
H --> I
I -->|JSON response| A
classDef mem fill:#0b3d2e,stroke:#10b981,color:#e6fffa;
class MEM,C,D,E,F mem;
Loading

What crosses each boundary:

BoundaryData in transitForm
Upstream → CapConnectRaw identifiers + event metadataPlaintext over TLS (you control this hop)
Inside CapConnectIdentifiersNormalized then SHA-256 hashed, in memory only
CapConnect → Meta/GoogleIdentifiersSHA-256 hex digests + un-hashed match-support fields (fbc/fbp/IP/UA/gclid) per platform spec
CapConnect → UpstreamDispatch outcomeNo PII — only event_id, status, provider trace ids

🏗 Architecture

flowchart TD
subgraph SRC["src/"]
SV["server.ts<br/>Express app · auth · validation · routing"]
NM["utils/normalizer.ts<br/>normalize + SHA-256 (pure functions)"]
CP["services/capi.ts<br/>Meta + Google payload build & POST"]
TY["types/index.ts<br/>strict shared types"]
end
SV -->|"normalizeAndHash()"| NM
SV -->|"dispatchToAllProviders()"| CP
NM --- TY
CP --- TY
SV --- TY
Loading
FileResponsibility
src/types/index.tsSingle source of truth for every shape: raw input, hashed IR, provider payloads, responses, config.
src/utils/normalizer.tsField-by-field normalization (Meta/Google spec) + SHA-256. Pure, no I/O, no logging.
src/utils/retry.tsExponential-backoff retry helper; retries transient errors only.
src/services/capi.tsBuilds & POSTs Meta CAPI and Google Ads payloads concurrently; PII-free error reporting.
src/services/googleAuth.tsGoogle OAuth token providers (static + auto-refreshing).
src/server.tsHardened Express server, config loading/validation, webhook endpoints, graceful shutdown.
tests/Vitest unit + Supertest integration suites (80 tests).

🚀 Quick start

# 1. Install
npm install
# 2. Configure
cp .env.example .env
# …then edit .env with your Pixel id, access tokens, etc.# Generate a webhook secret: openssl rand -hex 32# 3. Develop (hot reload)
npm run dev
# 4. Production build & run
npm run build
npm start

Health check

curl -s http://localhost:3000/healthz | jq
{ "status": "ok", "service": "capconnect", "providers": { "meta": true, "google": true } }

📡 API

POST /v1/collect

Authenticated with the x-capconnect-token header (must equal WEBHOOK_SECRET).

Request

curl -s -X POST http://localhost:3000/v1/collect \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '{ "customer": { "email": " John.Doe@Example.COM ", "phone": "090-1234-5678", "firstName": "John", "lastName": "Doe", "country": "JP", "city": "Tokyo", "zip": "100-0001", "externalId": "user_42" }, "event": { "eventName": "Purchase", "eventId": "order-2025-0001", "value": 4980, "currency": "JPY", "orderId": "order-2025-0001", "actionSource": "website", "eventSourceUrl": "https://shop.example.com/thank-you", "fbc": "fb.1.1700000000000.IwAR...", "gclid": "Cj0KCQ..." } }'

The phone above is normalized to 819012345678 (leading 0 dropped, 81 prepended) and then SHA-256 hashed before it ever leaves the process.

Response (200 if at least one provider accepted; 422 if none did)

{
"eventId": "order-2025-0001",
"accepted": true,
"results": [
{ "provider": "meta", "status": "sent", "httpStatus": 200, "detail": "events_received=1 fbtrace_id=Aa...", "reference": "order-2025-0001" },
{ "provider": "google", "status": "sent", "httpStatus": 200, "detail": "results=1", "reference": "order-2025-0001" }
]
}

POST /v1/collect/batch

Same auth. Body is a JSON array of { customer, event } objects (max 1000). Each item is processed independently; invalid items are reported in validationErrors without failing the rest.

curl -s -X POST http://localhost:3000/v1/collect/batch \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '[ { "customer": { "email": "a@example.com" }, "event": { "eventName": "Lead" } } ]'

GET /healthz

Unauthenticated liveness/readiness probe. Returns no PII.


⚙️ Configuration

All configuration is via environment variables (see .env.example). The process fails fast at boot if an enabled provider is missing required secrets.

Server

VariableRequiredDefaultDescription
PORTno3000HTTP listen port.
WEBHOOK_SECRETyesShared secret required in the x-capconnect-token header.
DEFAULT_COUNTRY_CODEno81Digits-only calling code auto-prepended to non-international phones.
DEFAULT_CURRENCYnoJPYISO-4217 currency applied when an event omits one.
HTTP_TIMEOUT_MSno10000Outbound request timeout for both providers.
RETRY_MAX_ATTEMPTSno3Max attempts per provider call (incl. the first).
RETRY_BASE_DELAY_MSno300Base backoff delay; doubles each retry, full-jittered.
RETRY_MAX_DELAY_MSno5000Hard cap on any single backoff delay.

Meta Conversions API

VariableRequiredDefaultDescription
META_ENABLEDnotrueToggle Meta dispatch.
META_PIXEL_IDif enabledYour Pixel / dataset id.
META_ACCESS_TOKENif enabledSystem-user access token.
META_API_VERSIONnov20.0Graph API version.
META_TEST_EVENT_CODEnoEnables Meta's Test Events tool when set.

Google Ads API (Enhanced Conversions)

VariableRequiredDefaultDescription
GOOGLE_ENABLEDnotrueToggle Google dispatch.
GOOGLE_CUSTOMER_IDif enabledAccount id owning the conversion action (digits, no dashes).
GOOGLE_CONVERSION_ACTION_IDif enabledNumeric conversion action id.
GOOGLE_DEVELOPER_TOKENif enabledGoogle Ads API developer token.
GOOGLE_OAUTH_ACCESS_TOKENauth APre-issued OAuth2 Bearer token (you refresh).
GOOGLE_OAUTH_CLIENT_IDauth BOAuth client id for automatic refresh.
GOOGLE_OAUTH_CLIENT_SECRETauth BOAuth client secret for automatic refresh.
GOOGLE_OAUTH_REFRESH_TOKENauth BOAuth refresh token (held in memory only).
GOOGLE_OAUTH_TOKEN_URInohttps://oauth2.googleapis.com/tokenToken endpoint.
GOOGLE_LOGIN_CUSTOMER_IDnoMCC / manager id for login-customer-id.
GOOGLE_API_VERSIONnov17Google Ads API version.
GOOGLE_VALIDATE_ONLYnofalseValidate without recording a conversion.

OAuth — two strategies (provide exactly one):(A) Inject a short-lived GOOGLE_OAUTH_ACCESS_TOKEN from your own secret manager and handle refresh externally; or (B) provide GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET + GOOGLE_OAUTH_REFRESH_TOKEN and CapConnect refreshes access tokens automatically — caching them in memory, coalescing concurrent refreshes, and renewing ~60s before expiry. The refresh token is never persisted or logged. Boot fails fast if neither strategy is fully configured while Google is enabled.

Resilience

  • Automatic retries with exponential backoff + full jitter on transient failures only (network errors, HTTP 429, HTTP 5xx). Deterministic 4xx errors are never retried. Tune via RETRY_*.
  • Fault isolation — a Meta failure never blocks Google (and vice-versa); a Google token-refresh failure is reported as a Google-only auth: error while Meta still dispatches.

🔐 Normalization rules (Meta/Google compliant)

FieldRuleExample → normalized
Email (em)trim, lowercase (no dot-stripping) A.B@Ex.COMa.b@ex.com
Phone (ph)digits only → E.164 (drop trunk 0, prepend country code)090-1234-5678819012345678
First/Last name (fn/ln)lowercase, strip punctuation, collapse spacesO'Brienobrien
City (ct)lowercase, remove all whitespace & punctuationNew Yorknewyork
State (st)lowercase, letters onlyCAca
Zip (zp)lowercase, strip spaces, drop +4, US→first 5100-0001100
Country (country)ISO alpha-2, lowercaseJapan→ first 2 letters
DOB (db)parse → YYYYMMDD1990/01/0219900102
Gender (ge)m / fMalem

Every normalized value is then SHA-256 hashed to lowercase hex before dispatch. Empty/unusable values are dropped (never hashed to the all-empty digest).


🧪 Local verification

npm run typecheck # strict TS, no emit
npm run lint # ESLint (type-checked rules)
npm run build # compile to dist/
npm test# run the Vitest suite (80 tests)
npm run test:coverage # tests + coverage report
npm run ci # typecheck + lint + build + test (what CI runs)

You can exercise the pipeline safely against Meta's Test Events tool by setting META_TEST_EVENT_CODE, and against Google by setting GOOGLE_VALIDATE_ONLY=true.

🐳 Docker

# Build & run with compose (reads your .env)
cp .env.example .env # then fill in secrets
docker compose up --build
# …or build the image directly
docker build -t capconnect:latest .
docker run --rm -p 3000:3000 --env-file .env capconnect:latest

The image is a multi-stage build that ships only production dependencies and the compiled dist/, runs as the non-root node user, exposes a HEALTHCHECK against /healthz, and (via compose) runs read-only with dropped capabilities and no-new-privileges.

🔁 CI

.github/workflows/ci.yml runs on every push/PR: typecheck → lint → build → test across Node 18/20/22, a coverage job, and a Docker build job.


🤝 Operational guidance

  • Terminate TLS in front of CapConnect (reverse proxy / load balancer) and keep the upstream hop encrypted.
  • Rotate WEBHOOK_SECRET and provider tokens via your secret manager; never bake them into images.
  • Do not enable request-body logging at the proxy for /v1/collect* — that would defeat the zero-knowledge property outside the app.
  • Scale horizontally — the service is stateless, so run as many replicas as you need behind a load balancer.

📄 License

CapConnect is distributed under the Business Source License 1.1 (BSL-1.1).

  • You may use, copy, modify, and self-host CapConnect freely, including internally at your company.
  • The Additional Use Grant permits production use except offering CapConnect (or a substantially similar service) to third parties as a hosted/managed commercial "conversion-relay" product.
  • On the Change Date (four years after each version's release), that version automatically converts to the Apache License 2.0.
Licensed under the Business Source License 1.1 (the "License");
you may not use this file except in compliance with the License.
Change License: Apache License, Version 2.0
Change Date: four (4) years from the date of each release.
Additional Use Grant: You may use the Licensed Work in production, except to
provide it to third parties as a hosted or managed commercial conversion-relay
service that competes with the Licensor's offering.
THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.

Prefer a copyleft model instead? CapConnect is also available under the GNU AGPL-3.0 on request — contact the maintainers. Choose BSL-1.1 for permissive self-hosting with a commercial-SaaS carve-out, or AGPL-3.0 if you want network-use copyleft obligations.


Built for the cookieless era. Your customers' data stays your customers'.

About

Privacy-first, zero-knowledge data connector: cleans & SHA-256 hashes first-party customer data in memory, then relays to Meta Conversions API & Google Ads Enhanced Conversions. No PII persisted.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

🔒 CapConnect

A privacy-first, zero-knowledge data connector for the cookieless era.

Clean → Hash (SHA-256) → Forward. First-party customer data is normalized and irreversibly hashed in memory before it is relayed to Meta Conversions API and Google Ads Enhanced Conversions. No raw PII ever touches a disk, a log, or a database.

License: BSL-1.1TypeScriptPII on disk


Why CapConnect exists

Third-party cookies are gone. To keep measuring conversions, advertisers must send first-party data server-to-server. But doing that safely is the hard part: the moment raw emails and phone numbers leave your perimeter, your legal and security teams own a new liability.

CapConnect is the safe middle layer. It accepts your raw events, normalizes and SHA-256 hashes every personal field the instant it arrives, and forwards only those one-way digests to the ad platforms — exactly the way Meta and Google specify. Because the plaintext exists only transiently in process memory and is never persisted, there is nothing to leak from CapConnect's storage. There is no storage.

For CTOs & Legal: CapConnect is a stateless relay. It holds no datastore, writes no PII to disk, and emits no PII in logs. The only personal data it transmits is already a non-reversible SHA-256 hash, sent over TLS to Meta/Google — the same hashing the platforms' own SDKs perform client-side.


✨ Features

  • Zero-knowledge by construction — plaintext PII lives only on the call stack during a single request; it is GC-eligible the moment the response is returned.
  • Spec-accurate normalization — email lower/trim, phone → E.164 (auto-prepends the country code, e.g. 81 for Japan), names/city/state/zip/DOB/gender all formatted per the official Meta & Google guidelines.
  • SHA-256 hashing with idempotent pass-through for upstream-pre-hashed inputs.
  • Meta Conversions API with deduplication event_id support to pair with the browser Pixel.
  • Google Ads Enhanced Conversions via uploadClickConversions with userIdentifiers.
  • Concurrent, fault-isolated dispatch — one platform failing never blocks the other.
  • Automatic retries with exponential backoff + jitter on transient errors (network / 429 / 5xx).
  • Automatic Google OAuth refresh (optional) — refresh-token exchange, in-memory caching, concurrent-refresh coalescing; nothing persisted.
  • Single & batch webhook endpoints (/v1/collect, /v1/collect/batch).
  • Hardened Express server — shared-secret auth (constant-time compare), body-size caps, x-powered-by disabled, graceful shutdown.
  • Strict TypeScript end to end (exactOptionalPropertyTypes, noUncheckedIndexedAccess, …).
  • 80 unit/integration tests (Vitest + Supertest), ESLint type-checked rules, multi-stage Docker image, and GitHub Actions CI.

🛡 The Zero-Knowledge Guarantee

flowchart LR
A["Upstream source<br/>(Webhook / CSV parse)"] -->|HTTPS + shared secret| B["CapConnect<br/>/v1/collect"]
subgraph MEM["⏱ In-memory only — never persisted, never logged"]
direction TB
C["Validate contract"] --> D["Normalize<br/>email · phone · name · address"]
D --> E["SHA-256 hash<br/>(irreversible, one-way)"]
E --> F["Build provider payloads<br/>(hashed digests only)"]
end
B --> C
F -->|TLS| G["Meta Conversions API<br/>(event_id dedup)"]
F -->|TLS| H["Google Ads API<br/>Enhanced Conversions"]
G --> I["Structured, PII-free<br/>dispatch report"]
H --> I
I -->|JSON response| A
classDef mem fill:#0b3d2e,stroke:#10b981,color:#e6fffa;
class MEM,C,D,E,F mem;
Loading

What crosses each boundary:

BoundaryData in transitForm
Upstream → CapConnectRaw identifiers + event metadataPlaintext over TLS (you control this hop)
Inside CapConnectIdentifiersNormalized then SHA-256 hashed, in memory only
CapConnect → Meta/GoogleIdentifiersSHA-256 hex digests + un-hashed match-support fields (fbc/fbp/IP/UA/gclid) per platform spec
CapConnect → UpstreamDispatch outcomeNo PII — only event_id, status, provider trace ids

🏗 Architecture

flowchart TD
subgraph SRC["src/"]
SV["server.ts<br/>Express app · auth · validation · routing"]
NM["utils/normalizer.ts<br/>normalize + SHA-256 (pure functions)"]
CP["services/capi.ts<br/>Meta + Google payload build & POST"]
TY["types/index.ts<br/>strict shared types"]
end
SV -->|"normalizeAndHash()"| NM
SV -->|"dispatchToAllProviders()"| CP
NM --- TY
CP --- TY
SV --- TY
Loading
FileResponsibility
src/types/index.tsSingle source of truth for every shape: raw input, hashed IR, provider payloads, responses, config.
src/utils/normalizer.tsField-by-field normalization (Meta/Google spec) + SHA-256. Pure, no I/O, no logging.
src/utils/retry.tsExponential-backoff retry helper; retries transient errors only.
src/services/capi.tsBuilds & POSTs Meta CAPI and Google Ads payloads concurrently; PII-free error reporting.
src/services/googleAuth.tsGoogle OAuth token providers (static + auto-refreshing).
src/server.tsHardened Express server, config loading/validation, webhook endpoints, graceful shutdown.
tests/Vitest unit + Supertest integration suites (80 tests).

🚀 Quick start

# 1. Install
npm install
# 2. Configure
cp .env.example .env
# …then edit .env with your Pixel id, access tokens, etc.# Generate a webhook secret: openssl rand -hex 32# 3. Develop (hot reload)
npm run dev
# 4. Production build & run
npm run build
npm start

Health check

curl -s http://localhost:3000/healthz | jq
{ "status": "ok", "service": "capconnect", "providers": { "meta": true, "google": true } }

📡 API

POST /v1/collect

Authenticated with the x-capconnect-token header (must equal WEBHOOK_SECRET).

Request

curl -s -X POST http://localhost:3000/v1/collect \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '{ "customer": { "email": " John.Doe@Example.COM ", "phone": "090-1234-5678", "firstName": "John", "lastName": "Doe", "country": "JP", "city": "Tokyo", "zip": "100-0001", "externalId": "user_42" }, "event": { "eventName": "Purchase", "eventId": "order-2025-0001", "value": 4980, "currency": "JPY", "orderId": "order-2025-0001", "actionSource": "website", "eventSourceUrl": "https://shop.example.com/thank-you", "fbc": "fb.1.1700000000000.IwAR...", "gclid": "Cj0KCQ..." } }'

The phone above is normalized to 819012345678 (leading 0 dropped, 81 prepended) and then SHA-256 hashed before it ever leaves the process.

Response (200 if at least one provider accepted; 422 if none did)

{
"eventId": "order-2025-0001",
"accepted": true,
"results": [
{ "provider": "meta", "status": "sent", "httpStatus": 200, "detail": "events_received=1 fbtrace_id=Aa...", "reference": "order-2025-0001" },
{ "provider": "google", "status": "sent", "httpStatus": 200, "detail": "results=1", "reference": "order-2025-0001" }
]
}

POST /v1/collect/batch

Same auth. Body is a JSON array of { customer, event } objects (max 1000). Each item is processed independently; invalid items are reported in validationErrors without failing the rest.

curl -s -X POST http://localhost:3000/v1/collect/batch \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '[ { "customer": { "email": "a@example.com" }, "event": { "eventName": "Lead" } } ]'

GET /healthz

Unauthenticated liveness/readiness probe. Returns no PII.


⚙️ Configuration

All configuration is via environment variables (see .env.example). The process fails fast at boot if an enabled provider is missing required secrets.

Server

VariableRequiredDefaultDescription
PORTno3000HTTP listen port.
WEBHOOK_SECRETyesShared secret required in the x-capconnect-token header.
DEFAULT_COUNTRY_CODEno81Digits-only calling code auto-prepended to non-international phones.
DEFAULT_CURRENCYnoJPYISO-4217 currency applied when an event omits one.
HTTP_TIMEOUT_MSno10000Outbound request timeout for both providers.
RETRY_MAX_ATTEMPTSno3Max attempts per provider call (incl. the first).
RETRY_BASE_DELAY_MSno300Base backoff delay; doubles each retry, full-jittered.
RETRY_MAX_DELAY_MSno5000Hard cap on any single backoff delay.

Meta Conversions API

VariableRequiredDefaultDescription
META_ENABLEDnotrueToggle Meta dispatch.
META_PIXEL_IDif enabledYour Pixel / dataset id.
META_ACCESS_TOKENif enabledSystem-user access token.
META_API_VERSIONnov20.0Graph API version.
META_TEST_EVENT_CODEnoEnables Meta's Test Events tool when set.

Google Ads API (Enhanced Conversions)

VariableRequiredDefaultDescription
GOOGLE_ENABLEDnotrueToggle Google dispatch.
GOOGLE_CUSTOMER_IDif enabledAccount id owning the conversion action (digits, no dashes).
GOOGLE_CONVERSION_ACTION_IDif enabledNumeric conversion action id.
GOOGLE_DEVELOPER_TOKENif enabledGoogle Ads API developer token.
GOOGLE_OAUTH_ACCESS_TOKENauth APre-issued OAuth2 Bearer token (you refresh).
GOOGLE_OAUTH_CLIENT_IDauth BOAuth client id for automatic refresh.
GOOGLE_OAUTH_CLIENT_SECRETauth BOAuth client secret for automatic refresh.
GOOGLE_OAUTH_REFRESH_TOKENauth BOAuth refresh token (held in memory only).
GOOGLE_OAUTH_TOKEN_URInohttps://oauth2.googleapis.com/tokenToken endpoint.
GOOGLE_LOGIN_CUSTOMER_IDnoMCC / manager id for login-customer-id.
GOOGLE_API_VERSIONnov17Google Ads API version.
GOOGLE_VALIDATE_ONLYnofalseValidate without recording a conversion.

OAuth — two strategies (provide exactly one):(A) Inject a short-lived GOOGLE_OAUTH_ACCESS_TOKEN from your own secret manager and handle refresh externally; or (B) provide GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET + GOOGLE_OAUTH_REFRESH_TOKEN and CapConnect refreshes access tokens automatically — caching them in memory, coalescing concurrent refreshes, and renewing ~60s before expiry. The refresh token is never persisted or logged. Boot fails fast if neither strategy is fully configured while Google is enabled.

Resilience

  • Automatic retries with exponential backoff + full jitter on transient failures only (network errors, HTTP 429, HTTP 5xx). Deterministic 4xx errors are never retried. Tune via RETRY_*.
  • Fault isolation — a Meta failure never blocks Google (and vice-versa); a Google token-refresh failure is reported as a Google-only auth: error while Meta still dispatches.

🔐 Normalization rules (Meta/Google compliant)

FieldRuleExample → normalized
Email (em)trim, lowercase (no dot-stripping) A.B@Ex.COMa.b@ex.com
Phone (ph)digits only → E.164 (drop trunk 0, prepend country code)090-1234-5678819012345678
First/Last name (fn/ln)lowercase, strip punctuation, collapse spacesO'Brienobrien
City (ct)lowercase, remove all whitespace & punctuationNew Yorknewyork
State (st)lowercase, letters onlyCAca
Zip (zp)lowercase, strip spaces, drop +4, US→first 5100-0001100
Country (country)ISO alpha-2, lowercaseJapan→ first 2 letters
DOB (db)parse → YYYYMMDD1990/01/0219900102
Gender (ge)m / fMalem

Every normalized value is then SHA-256 hashed to lowercase hex before dispatch. Empty/unusable values are dropped (never hashed to the all-empty digest).


🧪 Local verification

npm run typecheck # strict TS, no emit
npm run lint # ESLint (type-checked rules)
npm run build # compile to dist/
npm test# run the Vitest suite (80 tests)
npm run test:coverage # tests + coverage report
npm run ci # typecheck + lint + build + test (what CI runs)

You can exercise the pipeline safely against Meta's Test Events tool by setting META_TEST_EVENT_CODE, and against Google by setting GOOGLE_VALIDATE_ONLY=true.

🐳 Docker

# Build & run with compose (reads your .env)
cp .env.example .env # then fill in secrets
docker compose up --build
# …or build the image directly
docker build -t capconnect:latest .
docker run --rm -p 3000:3000 --env-file .env capconnect:latest

The image is a multi-stage build that ships only production dependencies and the compiled dist/, runs as the non-root node user, exposes a HEALTHCHECK against /healthz, and (via compose) runs read-only with dropped capabilities and no-new-privileges.

🔁 CI

.github/workflows/ci.yml runs on every push/PR: typecheck → lint → build → test across Node 18/20/22, a coverage job, and a Docker build job.


🤝 Operational guidance

  • Terminate TLS in front of CapConnect (reverse proxy / load balancer) and keep the upstream hop encrypted.
  • Rotate WEBHOOK_SECRET and provider tokens via your secret manager; never bake them into images.
  • Do not enable request-body logging at the proxy for /v1/collect* — that would defeat the zero-knowledge property outside the app.
  • Scale horizontally — the service is stateless, so run as many replicas as you need behind a load balancer.

📄 License

CapConnect is distributed under the Business Source License 1.1 (BSL-1.1).

  • You may use, copy, modify, and self-host CapConnect freely, including internally at your company.
  • The Additional Use Grant permits production use except offering CapConnect (or a substantially similar service) to third parties as a hosted/managed commercial "conversion-relay" product.
  • On the Change Date (four years after each version's release), that version automatically converts to the Apache License 2.0.
Licensed under the Business Source License 1.1 (the "License");
you may not use this file except in compliance with the License.
Change License: Apache License, Version 2.0
Change Date: four (4) years from the date of each release.
Additional Use Grant: You may use the Licensed Work in production, except to
provide it to third parties as a hosted or managed commercial conversion-relay
service that competes with the Licensor's offering.
THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.

Prefer a copyleft model instead? CapConnect is also available under the GNU AGPL-3.0 on request — contact the maintainers. Choose BSL-1.1 for permissive self-hosting with a commercial-SaaS carve-out, or AGPL-3.0 if you want network-use copyleft obligations.


Built for the cookieless era. Your customers' data stays your customers'.

About

Privacy-first, zero-knowledge data connector: cleans & SHA-256 hashes first-party customer data in memory, then relays to Meta Conversions API & Google Ads Enhanced Conversions. No PII persisted.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

🔒 CapConnect

A privacy-first, zero-knowledge data connector for the cookieless era.

Clean → Hash (SHA-256) → Forward. First-party customer data is normalized and irreversibly hashed in memory before it is relayed to Meta Conversions API and Google Ads Enhanced Conversions. No raw PII ever touches a disk, a log, or a database.

License: BSL-1.1TypeScriptPII on disk


Why CapConnect exists

Third-party cookies are gone. To keep measuring conversions, advertisers must send first-party data server-to-server. But doing that safely is the hard part: the moment raw emails and phone numbers leave your perimeter, your legal and security teams own a new liability.

CapConnect is the safe middle layer. It accepts your raw events, normalizes and SHA-256 hashes every personal field the instant it arrives, and forwards only those one-way digests to the ad platforms — exactly the way Meta and Google specify. Because the plaintext exists only transiently in process memory and is never persisted, there is nothing to leak from CapConnect's storage. There is no storage.

For CTOs & Legal: CapConnect is a stateless relay. It holds no datastore, writes no PII to disk, and emits no PII in logs. The only personal data it transmits is already a non-reversible SHA-256 hash, sent over TLS to Meta/Google — the same hashing the platforms' own SDKs perform client-side.


✨ Features

  • Zero-knowledge by construction — plaintext PII lives only on the call stack during a single request; it is GC-eligible the moment the response is returned.
  • Spec-accurate normalization — email lower/trim, phone → E.164 (auto-prepends the country code, e.g. 81 for Japan), names/city/state/zip/DOB/gender all formatted per the official Meta & Google guidelines.
  • SHA-256 hashing with idempotent pass-through for upstream-pre-hashed inputs.
  • Meta Conversions API with deduplication event_id support to pair with the browser Pixel.
  • Google Ads Enhanced Conversions via uploadClickConversions with userIdentifiers.
  • Concurrent, fault-isolated dispatch — one platform failing never blocks the other.
  • Automatic retries with exponential backoff + jitter on transient errors (network / 429 / 5xx).
  • Automatic Google OAuth refresh (optional) — refresh-token exchange, in-memory caching, concurrent-refresh coalescing; nothing persisted.
  • Single & batch webhook endpoints (/v1/collect, /v1/collect/batch).
  • Hardened Express server — shared-secret auth (constant-time compare), body-size caps, x-powered-by disabled, graceful shutdown.
  • Strict TypeScript end to end (exactOptionalPropertyTypes, noUncheckedIndexedAccess, …).
  • 80 unit/integration tests (Vitest + Supertest), ESLint type-checked rules, multi-stage Docker image, and GitHub Actions CI.

🛡 The Zero-Knowledge Guarantee

flowchart LR
A["Upstream source<br/>(Webhook / CSV parse)"] -->|HTTPS + shared secret| B["CapConnect<br/>/v1/collect"]
subgraph MEM["⏱ In-memory only — never persisted, never logged"]
direction TB
C["Validate contract"] --> D["Normalize<br/>email · phone · name · address"]
D --> E["SHA-256 hash<br/>(irreversible, one-way)"]
E --> F["Build provider payloads<br/>(hashed digests only)"]
end
B --> C
F -->|TLS| G["Meta Conversions API<br/>(event_id dedup)"]
F -->|TLS| H["Google Ads API<br/>Enhanced Conversions"]
G --> I["Structured, PII-free<br/>dispatch report"]
H --> I
I -->|JSON response| A
classDef mem fill:#0b3d2e,stroke:#10b981,color:#e6fffa;
class MEM,C,D,E,F mem;
Loading

What crosses each boundary:

BoundaryData in transitForm
Upstream → CapConnectRaw identifiers + event metadataPlaintext over TLS (you control this hop)
Inside CapConnectIdentifiersNormalized then SHA-256 hashed, in memory only
CapConnect → Meta/GoogleIdentifiersSHA-256 hex digests + un-hashed match-support fields (fbc/fbp/IP/UA/gclid) per platform spec
CapConnect → UpstreamDispatch outcomeNo PII — only event_id, status, provider trace ids

🏗 Architecture

flowchart TD
subgraph SRC["src/"]
SV["server.ts<br/>Express app · auth · validation · routing"]
NM["utils/normalizer.ts<br/>normalize + SHA-256 (pure functions)"]
CP["services/capi.ts<br/>Meta + Google payload build & POST"]
TY["types/index.ts<br/>strict shared types"]
end
SV -->|"normalizeAndHash()"| NM
SV -->|"dispatchToAllProviders()"| CP
NM --- TY
CP --- TY
SV --- TY
Loading
FileResponsibility
src/types/index.tsSingle source of truth for every shape: raw input, hashed IR, provider payloads, responses, config.
src/utils/normalizer.tsField-by-field normalization (Meta/Google spec) + SHA-256. Pure, no I/O, no logging.
src/utils/retry.tsExponential-backoff retry helper; retries transient errors only.
src/services/capi.tsBuilds & POSTs Meta CAPI and Google Ads payloads concurrently; PII-free error reporting.
src/services/googleAuth.tsGoogle OAuth token providers (static + auto-refreshing).
src/server.tsHardened Express server, config loading/validation, webhook endpoints, graceful shutdown.
tests/Vitest unit + Supertest integration suites (80 tests).

🚀 Quick start

# 1. Install
npm install
# 2. Configure
cp .env.example .env
# …then edit .env with your Pixel id, access tokens, etc.# Generate a webhook secret: openssl rand -hex 32# 3. Develop (hot reload)
npm run dev
# 4. Production build & run
npm run build
npm start

Health check

curl -s http://localhost:3000/healthz | jq
{ "status": "ok", "service": "capconnect", "providers": { "meta": true, "google": true } }

📡 API

POST /v1/collect

Authenticated with the x-capconnect-token header (must equal WEBHOOK_SECRET).

Request

curl -s -X POST http://localhost:3000/v1/collect \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '{ "customer": { "email": " John.Doe@Example.COM ", "phone": "090-1234-5678", "firstName": "John", "lastName": "Doe", "country": "JP", "city": "Tokyo", "zip": "100-0001", "externalId": "user_42" }, "event": { "eventName": "Purchase", "eventId": "order-2025-0001", "value": 4980, "currency": "JPY", "orderId": "order-2025-0001", "actionSource": "website", "eventSourceUrl": "https://shop.example.com/thank-you", "fbc": "fb.1.1700000000000.IwAR...", "gclid": "Cj0KCQ..." } }'

The phone above is normalized to 819012345678 (leading 0 dropped, 81 prepended) and then SHA-256 hashed before it ever leaves the process.

Response (200 if at least one provider accepted; 422 if none did)

{
"eventId": "order-2025-0001",
"accepted": true,
"results": [
{ "provider": "meta", "status": "sent", "httpStatus": 200, "detail": "events_received=1 fbtrace_id=Aa...", "reference": "order-2025-0001" },
{ "provider": "google", "status": "sent", "httpStatus": 200, "detail": "results=1", "reference": "order-2025-0001" }
]
}

POST /v1/collect/batch

Same auth. Body is a JSON array of { customer, event } objects (max 1000). Each item is processed independently; invalid items are reported in validationErrors without failing the rest.

curl -s -X POST http://localhost:3000/v1/collect/batch \
-H "Content-Type: application/json" \
-H "x-capconnect-token: $WEBHOOK_SECRET" \
-d '[ { "customer": { "email": "a@example.com" }, "event": { "eventName": "Lead" } } ]'

GET /healthz

Unauthenticated liveness/readiness probe. Returns no PII.


⚙️ Configuration

All configuration is via environment variables (see .env.example). The process fails fast at boot if an enabled provider is missing required secrets.

Server

VariableRequiredDefaultDescription
PORTno3000HTTP listen port.
WEBHOOK_SECRETyesShared secret required in the x-capconnect-token header.
DEFAULT_COUNTRY_CODEno81Digits-only calling code auto-prepended to non-international phones.
DEFAULT_CURRENCYnoJPYISO-4217 currency applied when an event omits one.
HTTP_TIMEOUT_MSno10000Outbound request timeout for both providers.
RETRY_MAX_ATTEMPTSno3Max attempts per provider call (incl. the first).
RETRY_BASE_DELAY_MSno300Base backoff delay; doubles each retry, full-jittered.
RETRY_MAX_DELAY_MSno5000Hard cap on any single backoff delay.

Meta Conversions API

VariableRequiredDefaultDescription
META_ENABLEDnotrueToggle Meta dispatch.
META_PIXEL_IDif enabledYour Pixel / dataset id.
META_ACCESS_TOKENif enabledSystem-user access token.
META_API_VERSIONnov20.0Graph API version.
META_TEST_EVENT_CODEnoEnables Meta's Test Events tool when set.

Google Ads API (Enhanced Conversions)

VariableRequiredDefaultDescription
GOOGLE_ENABLEDnotrueToggle Google dispatch.
GOOGLE_CUSTOMER_IDif enabledAccount id owning the conversion action (digits, no dashes).
GOOGLE_CONVERSION_ACTION_IDif enabledNumeric conversion action id.
GOOGLE_DEVELOPER_TOKENif enabledGoogle Ads API developer token.
GOOGLE_OAUTH_ACCESS_TOKENauth APre-issued OAuth2 Bearer token (you refresh).
GOOGLE_OAUTH_CLIENT_IDauth BOAuth client id for automatic refresh.
GOOGLE_OAUTH_CLIENT_SECRETauth BOAuth client secret for automatic refresh.
GOOGLE_OAUTH_REFRESH_TOKENauth BOAuth refresh token (held in memory only).
GOOGLE_OAUTH_TOKEN_URInohttps://oauth2.googleapis.com/tokenToken endpoint.
GOOGLE_LOGIN_CUSTOMER_IDnoMCC / manager id for login-customer-id.
GOOGLE_API_VERSIONnov17Google Ads API version.
GOOGLE_VALIDATE_ONLYnofalseValidate without recording a conversion.

OAuth — two strategies (provide exactly one):(A) Inject a short-lived GOOGLE_OAUTH_ACCESS_TOKEN from your own secret manager and handle refresh externally; or (B) provide GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET + GOOGLE_OAUTH_REFRESH_TOKEN and CapConnect refreshes access tokens automatically — caching them in memory, coalescing concurrent refreshes, and renewing ~60s before expiry. The refresh token is never persisted or logged. Boot fails fast if neither strategy is fully configured while Google is enabled.

Resilience

  • Automatic retries with exponential backoff + full jitter on transient failures only (network errors, HTTP 429, HTTP 5xx). Deterministic 4xx errors are never retried. Tune via RETRY_*.
  • Fault isolation — a Meta failure never blocks Google (and vice-versa); a Google token-refresh failure is reported as a Google-only auth: error while Meta still dispatches.

🔐 Normalization rules (Meta/Google compliant)

FieldRuleExample → normalized
Email (em)trim, lowercase (no dot-stripping) A.B@Ex.COMa.b@ex.com
Phone (ph)digits only → E.164 (drop trunk 0, prepend country code)090-1234-5678819012345678
First/Last name (fn/ln)lowercase, strip punctuation, collapse spacesO'Brienobrien
City (ct)lowercase, remove all whitespace & punctuationNew Yorknewyork
State (st)lowercase, letters onlyCAca
Zip (zp)lowercase, strip spaces, drop +4, US→first 5100-0001100
Country (country)ISO alpha-2, lowercaseJapan→ first 2 letters
DOB (db)parse → YYYYMMDD1990/01/0219900102
Gender (ge)m / fMalem

Every normalized value is then SHA-256 hashed to lowercase hex before dispatch. Empty/unusable values are dropped (never hashed to the all-empty digest).


🧪 Local verification

npm run typecheck # strict TS, no emit
npm run lint # ESLint (type-checked rules)
npm run build # compile to dist/
npm test# run the Vitest suite (80 tests)
npm run test:coverage # tests + coverage report
npm run ci # typecheck + lint + build + test (what CI runs)

You can exercise the pipeline safely against Meta's Test Events tool by setting META_TEST_EVENT_CODE, and against Google by setting GOOGLE_VALIDATE_ONLY=true.

🐳 Docker

# Build & run with compose (reads your .env)
cp .env.example .env # then fill in secrets
docker compose up --build
# …or build the image directly
docker build -t capconnect:latest .
docker run --rm -p 3000:3000 --env-file .env capconnect:latest

The image is a multi-stage build that ships only production dependencies and the compiled dist/, runs as the non-root node user, exposes a HEALTHCHECK against /healthz, and (via compose) runs read-only with dropped capabilities and no-new-privileges.

🔁 CI

.github/workflows/ci.yml runs on every push/PR: typecheck → lint → build → test across Node 18/20/22, a coverage job, and a Docker build job.


🤝 Operational guidance

  • Terminate TLS in front of CapConnect (reverse proxy / load balancer) and keep the upstream hop encrypted.
  • Rotate WEBHOOK_SECRET and provider tokens via your secret manager; never bake them into images.
  • Do not enable request-body logging at the proxy for /v1/collect* — that would defeat the zero-knowledge property outside the app.
  • Scale horizontally — the service is stateless, so run as many replicas as you need behind a load balancer.

📄 License

CapConnect is distributed under the Business Source License 1.1 (BSL-1.1).

  • You may use, copy, modify, and self-host CapConnect freely, including internally at your company.
  • The Additional Use Grant permits production use except offering CapConnect (or a substantially similar service) to third parties as a hosted/managed commercial "conversion-relay" product.
  • On the Change Date (four years after each version's release), that version automatically converts to the Apache License 2.0.
Licensed under the Business Source License 1.1 (the "License");
you may not use this file except in compliance with the License.
Change License: Apache License, Version 2.0
Change Date: four (4) years from the date of each release.
Additional Use Grant: You may use the Licensed Work in production, except to
provide it to third parties as a hosted or managed commercial conversion-relay
service that competes with the Licensor's offering.
THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.

Prefer a copyleft model instead? CapConnect is also available under the GNU AGPL-3.0 on request — contact the maintainers. Choose BSL-1.1 for permissive self-hosting with a commercial-SaaS carve-out, or AGPL-3.0 if you want network-use copyleft obligations.


Built for the cookieless era. Your customers' data stays your customers'.

About

Privacy-first, zero-knowledge data connector: cleans & SHA-256 hashes first-party customer data in memory, then relays to Meta Conversions API & Google Ads Enhanced Conversions. No PII persisted.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages