Skip to content

Find a component

Loading the directory…

↑ ↓ to select · Enter to open · Esc to closeBrowse all →
Contributor login

Suppression

Do-not-contact suppression list + opt-in proof (GDPR/CAN-SPAM), hash-keyed and channel-aware, as a Bijection component

Bijection source
git clone https://github.com/bijectionhq/suppression.git
cd suppression
git checkout b3b94194dfe96ed5b912895ead046738cee8069b

Bijection 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.

Available to evaluate

Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.

Getting started

  1. Get the source at the revision above and read its package documentation.
  2. Check the required Bijection SDK version, dependencies, and external service credentials.
  3. Follow the package setup instructions and the guide to using components.
  4. 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.

agent-prompt.md
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 omit channel for a global all-channel tombstone.
  • One send gate — isEligible answers ¬suppressed [∧ confirmed] in one call; suppression always blocks, opt-in is per call.
  • Opt-in proof ledger — recordOptIn / getOptInProof store 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 optional proofValidator narrows the stored evidence at the boundary.
  • Server-sourced time — createdAt/confirmedAt are stamped from the server clock; a caller can never supply a timestamp.
  • Mount-safe — correct under multiple named app.use mounts (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

MethodKindResult
suppress(ctx, contactHash, reason, opts?)mutationnull (reason: "unsubscribe" | "bounce" | "complaint" | "manual" | "global"; opts: { channel? })
unsuppress(ctx, contactHash, channel?)mutationboolean (true if an entry was removed)
recordOptIn(ctx, contactHash, opts)mutationnull (opts: { listKey?; source; proof? })
isSuppressed(ctx, contactHash, channel?)querySuppressionView | null
getOptInProof(ctx, contactHash, listKey?)queryOptInProofView | null
isEligible(ctx, contactHash, opts?)queryboolean (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/confirmedAt come from Date.now() in each handler, never the caller; the opt-in proof is 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.

Keywords