Suppression
Do-not-contact suppression list + opt-in proof (GDPR/CAN-SPAM), hash-keyed and channel-aware, as a Bijection component
git clone https://github.com/bijectionhq/suppression.git
cd suppression
git checkout b3b94194dfe96ed5b912895ead046738cee8069bBijection repository access is required. Request access.
Description
Do-not-contact suppression list + opt-in proof (GDPR/CAN-SPAM), hash-keyed and channel-aware, as a Bijection component
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: bijectionhq/suppression. Based on vllnt/convex-suppression. License: MIT. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
Do-not-contact suppression list + opt-in proof (GDPR/CAN-SPAM), hash-keyed and channel-aware, as a Convex component
Explore the demo ↗Upstream overview
From the upstream package documentation.
@vllnt/convex-suppression stores do-not-contact decisions and opt-in evidence keyed by a contact hash supplied by your backend. A send eligibility check lets different outbound workflows consult the same suppression state.
suppress records a channel-specific or global decision. recordOptIn stores evidence for a list, and isEligible combines suppression with an optional opt-in requirement. Repeated writes update the same key rather than creating duplicate rows.
Your backend normalizes and hashes contacts, authorizes changes, and decides retention and messaging policy. Stored hashes and proof still need appropriate privacy handling. The component provides data and checks; it does not send messages or establish legal compliance by itself.
Maintained by bntvllnt at VLLNT. Find the author on GitHub and X.
Use cases
From the upstream package documentation.
- Email products: block sends after an unsubscribe, bounce, or complaint.
- Mobile messaging: apply channel-specific suppression to SMS or push campaigns.
- Multi-provider systems: keep suppression decisions when changing delivery providers.
- Account erasure flows: retain an appropriate suppression record without depending on the deleted account row.
How it works
From the upstream package documentation.
suppress records a channel-specific or global decision. recordOptIn stores evidence for a list, and isEligible combines suppression with an optional opt-in requirement. Repeated writes update the same key rather than creating duplicate rows.
Your backend normalizes and hashes contacts, authorizes changes, and decides retention and messaging policy. Stored hashes and proof still need appropriate privacy handling. The component provides data and checks; it does not send messages or establish legal compliance by itself.
Upstream package FAQ
Does @vllnt/convex-suppression store email addresses or phone numbers?
No. The @vllnt/convex-suppression component never stores raw PII. The host application is responsible for hashing and normalizing the contact identifier and passing an opaque contactHash string. The component only stores that hash, so suppression tombstones survive a GDPR erasure request that deletes the underlying contact record.
How do I check whether a contact can receive a message before sending?
Call isEligible(ctx, contactHash, opts) from a Convex query before every send. The opts object accepts an optional channel to scope the check, an optional listKey to target a specific opt-in list, and an optional requireOptIn boolean that blocks sends unless a confirmed opt-in record exists. The method returns a boolean and handles both suppression and opt-in checks in a single call.
Can I use @vllnt/convex-suppression for multiple mailing lists or channels in the same app?
Yes. The component supports multiple named mounts via Convex's app.use, so you can isolate marketing and transactional suppression lists in separate sandboxes. Within a single mount, channel scoping lets you suppress a contact for email independently of sms or push. The listKey parameter on recordOptIn and isEligible scopes opt-in proofs to a specific list.
Is the suppress mutation safe to call from a provider webhook that may deliver events more than once?
Yes. The suppress and recordOptIn mutations in @vllnt/convex-suppression are idempotent. Re-calling either on the same key tuple updates the existing row rather than inserting a duplicate, so replayed webhooks from email providers reporting bounces or complaints do not produce duplicate suppression records.
What version of Convex is required for @vllnt/convex-suppression?
The @vllnt/convex-suppression component requires convex@^1.41.0 as a peer dependency. Install it with npm install @vllnt/convex-suppression, then register the component in your convex/convex.config.ts file using app.use(suppression) imported from @vllnt/convex-suppression/convex.config.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Suppression with Bijection.
Read: https://bijection.com/components/suppression/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from vllnt/convex-suppression, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
@vllnt/convex-suppression
The do-not-contact suppression list + opt-in proof, as a Convex component.
const dnc = new Suppression(components.suppression);
await dnc.suppress(ctx, contactHash, "complaint", { channel: "email" });
const canSend = await dnc.isEligible(ctx, contactHash, { channel: "email" });
The GDPR opt-out / CAN-SPAM primitive: a global do-not-contact list keyed by an
opaque contactHash (never raw PII, so it survives erasure) plus an opt-in
proof ledger. Before every send the host asks isEligible; an unsubscribe / bounce
/ complaint calls suppress; a double-opt-in confirmation calls recordOptIn.
Domain-neutral — channels and reasons are the host's.
Features
- Hash-keyed, erasure-surviving — stores only the opaque
contactHash, never raw PII, so a tombstone outlives erasure of the subject. - Channel-aware — scope a suppression to one channel (
"email"/"sms"/"push"), or omitchannelfor a global all-channel tombstone. - One send gate —
isEligibleanswers¬suppressed [∧ confirmed]in one call; suppression always blocks, opt-in is per call. - Opt-in proof ledger —
recordOptIn/getOptInProofstore legal proof-of-consent per list, kept separate from any authz store. - Idempotent — re-suppressing / re-recording on its key tuple updates in place, so a replayed webhook never duplicates a row.
- Typed, opaque proof —
Suppression<TProof>with an optionalproofValidatornarrows the stored evidence at the boundary. - Server-sourced time —
createdAt/confirmedAtare stamped from the server clock; a caller can never supply a timestamp. - Mount-safe — correct under multiple named
app.usemounts (e.g. marketing vs. transactional lists), each an isolated sandbox.
Installation
pnpm add @vllnt/convex-suppression
Peer dependency: convex@^1.41.0.
Usage
// convex/convex.config.ts
import { defineApp } from "convex/server";
import suppression from "@vllnt/convex-suppression/convex.config";
const app = defineApp();
app.use(suppression);
export default app;
// convex/email.ts — host owns auth + hashing; pass an opaque contactHash in.
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { Suppression } from "@vllnt/convex-suppression";
const dnc = new Suppression<{ ip: string }>(components.suppression, {
proofValidator: v.object({ ip: v.string() }).parse,
});
const hash = (email: string) => myHash(email.trim().toLowerCase()); // host's hashing + salt policy
// The send gate — call before every send.
export const canEmail = query({
args: { email: v.string() },
handler: (ctx, { email }) =>
dnc.isEligible(ctx, hash(email), { channel: "email", listKey: "newsletter", requireOptIn: true }),
});
// A provider webhook suppresses on complaint; a confirmed double opt-in records proof.
export const onComplaint = mutation({
args: { email: v.string() },
handler: (ctx, { email }) => dnc.suppress(ctx, hash(email), "complaint", { channel: "email" }),
});
export const confirmOptIn = mutation({
args: { email: v.string(), ip: v.string() },
handler: (ctx, { email, ip }) =>
dnc.recordOptIn(ctx, hash(email), { listKey: "newsletter", source: "double-opt-in", proof: { ip } }),
});
Client options: new Suppression(component, { proofValidator? }). Omitting channel/listKey targets the global entry.
API Reference
| Method | Kind | Result |
|---|---|---|
suppress(ctx, contactHash, reason, opts?) | mutation | null (reason: "unsubscribe" | "bounce" | "complaint" | "manual" | "global"; opts: { channel? }) |
unsuppress(ctx, contactHash, channel?) | mutation | boolean (true if an entry was removed) |
recordOptIn(ctx, contactHash, opts) | mutation | null (opts: { listKey?; source; proof? }) |
isSuppressed(ctx, contactHash, channel?) | query | SuppressionView | null |
getOptInProof(ctx, contactHash, listKey?) | query | OptInProofView | null |
isEligible(ctx, contactHash, opts?) | query | boolean (opts: { channel?; listKey?; requireOptIn? }) |
Full reference: docs/API.md.
React
Backend-only — no ./react entry. The consumer surface is a server-side
isEligible gate and webhook-driven suppress writes; a reactive do-not-contact
badge, if ever needed, is an ordinary useQuery over the host's re-exported
isSuppressed ref.
Security
- Auth-agnostic — the host authenticates the caller, decides who may suppress/query a hash, and passes an opaque
contactHash; tables are sandboxed. - Hash-keyed, never raw PII — the host hashes and normalizes the contact and owns the salt policy; a suppression survives erasure of the subject.
- Server-sourced time —
createdAt/confirmedAtcome fromDate.now()in each handler, never the caller; the opt-inproofis opaque, narrowed by the host validator.
See docs/API.md.
Testing
pnpm test # single run
pnpm test:coverage # enforced 100% on covered files
Tests run against the real component runtime via convex-test (@edge-runtime/vm), not mocks.
Contributing
See CONTRIBUTING.md.
Author
Built by bntvllnt · bntvllnt.com · X @bntvllnt
Part of the @vllnt Convex component fleet — vllnt.com
If this is useful, sponsor the work.
License
MIT — see LICENSE.