MOLTED EMAIL

Suppressions & Consent

Manage suppression lists, record consent, and enforce compliance across scopes.

Suppressions prevent email from being sent to specific recipients, and are always enforced automatically by the policy engine -- for direct sends, journey steps, and scheduled follow-ups alike, plus a final re-check by the send worker immediately before the email goes out (covering sends that were scheduled or deferred for domain warmup, which can sit queued for up to 30 days).

Consent records track the legal basis for contacting someone, but by default that's all they do -- recording consent does not by itself stop or allow anything. A tenant can opt into enforcement (see Consent below); until then, consent is a ledger you check yourself.

Recipient email matching covers every spelling of the same mailbox. A suppression on tom@acme.com also blocks:

  • other case and surrounding whitespace: Tom@ACME.com
  • a +tag, in either direction: tom+news@acme.com (and a suppression on tom+news@acme.com blocks tom@acme.com)
  • dots in a Gmail or Googlemail local part: t.o.m@gmail.com is tom@gmail.com, and tom@googlemail.com is tom@gmail.com too
  • a trailing dot on the domain, including a Unicode full stop: tom@acme.com., tom@acme.com。
  • an internationalized domain in Unicode or punycode form: ida@bücher.example is ida@xn--bcher-kva.example
  • a quoted local part that needs no quotes: "tom"@acme.com
  • a display-name form: "Tom" <tom+x@acme.com>

Dots in a local part at any other provider are significant, and a Unicode lookalike (a Cyrillic о in place of a Latin o) is a different address, so neither is matched. Domain suppressions match the domain in any case, with or without a trailing dot, in Unicode or punycode form.

Suppression scopes

Suppressions can be applied at two levels from your tenant:

ScopeDescription
tenantBlocked for your tenant.
campaignRecorded against a campaign ID.

campaign scope is not yet limited to the named campaign (#1932): a campaign row currently blocks every send to that address from your tenant, the same as tenant scope. Real per-campaign scoping is tracked in #1984.

This also reaches list subscription, since both paths run the same suppression check: POST /v1/agent/lists/:id/subscribers (molted lists subscribe) now rejects a campaign-suppressed address with 400 Bad Request: Contact is suppressed: <reasonCode>, and the public opt-in webhook path (subscribe_to_list inbound-hook action) silently does not subscribe a campaign-suppressed address (by design, the same as a tenant-suppressed one: it never reactivates a suppressed contact, with no error shown to the submitter). Remove every suppression row for an address (see Remove a suppression) before expecting it to subscribe again.

Platform-wide (global) suppressions also exist but are reserved for automated complaint and hard-bounce handling by Molted's webhook pipeline -- they cannot be created by tenant API keys. Attempting to do so returns 403 global_scope_not_allowed.

Add a suppression

CLI
molted suppressions add --email user@example.com --reason manual_dnc --scope tenant
CLI - with expiration
molted suppressions add --email user@example.com --reason no_engagement --scope tenant \
  --expires-at 2027-01-01T00:00:00Z

CLI flags

FlagTypeRequiredDescription
--emailstringYesEmail address to suppress. Must be a valid email format.
--reasonstringYesReason code (see table below).
--scopestringYesSuppression scope: tenant or campaign.
--campaign-idstringNoCampaign ID. Required when --scope is campaign.
--sourcestringNoSource of the suppression (for your own tracking).
--expires-atstringNoISO 8601 expiry timestamp. If omitted, suppression is permanent.

Via the API

POST https://api.molted.email/v1/suppressions
curl
curl -X POST https://api.molted.email/v1/suppressions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "tenant_abc123",
    "recipientEmail": "user@example.com",
    "scope": "tenant",
    "reasonCode": "manual_dnc",
    "expiresAt": "2027-01-01T00:00:00Z"
  }'
FieldTypeRequiredDescription
tenantIdstringYesYour tenant identifier.
recipientEmailstringYesEmail address to suppress.
scopestringYesSuppression scope: tenant or campaign.
reasonCodestringYesReason code (see table below).
campaignIdstringNoCampaign ID. Required when scope is campaign.
sourcestringNoSource of the suppression (for your own tracking).
expiresAtstringNoISO 8601 expiry timestamp. If omitted, suppression is permanent.

Adding an address that is already suppressed only ever tightens it:

  • A permanent suppression stays permanent, and one with an expiry keeps the later of the two. Only a suppression that has already expired takes the new expiresAt.
  • A weaker reason never replaces a stronger one. A complaint, a hard bounce, an unsubscribe or a legal request outranks manual_dnc, role_account and domain_suppressed, which outrank soft_bounce and no_engagement. The reason and source change only when the new reason ranks at least as high.
  • The source value portal_keep_away is reserved for a person's Keep agents away in the portal; sending it returns 400 reserved_source.

Lifting a suppression early is a removal (below), which needs a person.

Reason codes

CodeDescription
complaintRecipient marked an email as spam.
hard_bounceEmail address hard-bounced.
manual_dncManually added to do-not-contact list.
legal_requestSuppressed due to a legal request (e.g., GDPR erasure).
role_accountRole account (e.g., info@, admin@) that should not receive automated email.
domain_suppressedEntire domain is suppressed.
no_engagementContact has not engaged with prior emails. Consider using --expires-at for temporary suppression.

Remove a suppression

CLI
molted suppressions remove SUPPRESSION_ID
curl
curl -X DELETE "https://api.molted.email/v1/suppressions/SUPPRESSION_ID?tenantId=tenant_abc123" \
  -H "Authorization: Bearer YOUR_API_KEY"

This removes one row. An address can have more than one (a tenant-scoped row plus one or more campaign-scoped rows, all enforced the same way, #1932): removing this id does not mean the contact can receive email from you again if another row for it still exists. A row for a variant spelling of the address (for example tom+news@acme.com) also still blocks tom@acme.com, and is listed under its own spelling. List every row for the address first (List suppressions), remove each one, then confirm with Check a recipient before sending.

Check a recipient

Ask whether a send to an address would be blocked. The answer comes from the same check the policy engine runs on every send, with the same inputs: email and domain suppressions, your tenant's rows and platform-wide ones, and expiry. So an expired suppression reads as clear, a domain suppression counts for every address at that domain, and a campaign-scoped suppression blocks the same as a tenant-scoped one (#1932).

If the lookup itself fails, the check answers 503 suppression_check_unavailable rather than "clear". Don't send until it answers.

curl
curl "https://api.molted.email/v1/suppressions/check?tenantId=tenant_abc123&email=user@example.com" \
  -H "Authorization: Bearer YOUR_API_KEY"
CLI
molted suppressions check --email user@example.com
molted suppressions check --domain competitor.com
Response
{ "email": "user@example.com", "suppressed": true, "reason": "manual_dnc", "matchedOn": "domain" }
ParameterTypeRequiredDescription
tenantIdstringYesYour tenant identifier.
emailstringOne of email or domainAddress to check.
domainstringOne of email or domainDomain to check: is every address at it suppressed?

reason is the matching suppression's reason code and matchedOn is email or domain (both null when the address is clear). Passing neither or both of email and domain, or either one twice, returns 400 invalid_params.

List suppressions

CLI
molted suppressions list --email user@example.com
molted suppressions list --limit 50 --offset 100
molted suppressions list-domains --limit 50 --offset 100
curl
curl "https://api.molted.email/v1/suppressions?tenantId=tenant_abc123&recipientEmail=user@example.com" \
  -H "Authorization: Bearer YOUR_API_KEY"
ParameterTypeRequiredDescription
tenantIdstringYesYour tenant identifier.
recipientEmailstringNoFilter by email address.
limitintegerNoPage size, 1-500. With neither limit nor offset the list returns every row for recipientEmail, or the latest 100.
offsetintegerNoRows to skip, 0-1,000,000.

Rows come back newest first. A listed row doesn't always block a send (it may be expired); use Check a recipient for that. GET /v1/suppressed-domains (molted suppressions list-domains) takes the same limit and offset (without them it returns every domain suppression).

Bulk import

Import suppressions in bulk: useful when migrating bounces or unsubscribes from another provider.

CLI
molted suppressions import --file bounces.csv
# CSV header: email,scope,reasonCode,campaignId,source,expiresAt
# (campaignId, source, expiresAt are optional columns)

molted suppressions import --file bounces.json
# JSON array: [{"email":"a@x.com","scope":"tenant","reasonCode":"hard_bounce"}, ...]

molted suppressions import --file bounces.csv --dry-run      # validate only, send nothing
# exits 1 if any row is invalid (unless --skip-invalid is also given -- then a real run would succeed too)
molted suppressions import --file bounces.csv --skip-invalid # import the valid rows, report the rest

Every row is validated client-side first (email format, scope tenant/campaign -- global is rejected and campaignId is required for campaign, reasonCode from the table above, expiresAt must parse as a date). By default any invalid row aborts the whole import and sends nothing; pass --skip-invalid to import the valid rows anyway. Rows are sent to the API in chunks of up to 500 rows or ~80KB of JSON, whichever comes first.

Caveat (#1932): a campaign-scoped suppression (scope: "campaign") is recorded against the campaign ID, but currently enforced the same as scope: "tenant" -- it blocks every send to that address from your tenant, not just the named campaign. Real per-campaign scoping is tracked in #1984; until then scope: "campaign" and scope: "tenant" behave the same on send.

POST https://api.molted.email/v1/ops/suppressions/bulk
curl
curl -X POST https://api.molted.email/v1/ops/suppressions/bulk \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "tenant_abc123",
    "entries": [
      { "recipientEmail": "a@example.com", "scope": "tenant", "reasonCode": "hard_bounce" },
      { "recipientEmail": "b@example.com", "scope": "tenant", "reasonCode": "manual_dnc", "source": "provider-migration" }
    ]
  }'

Response: { "added": 2, "results": [{ "id": "..." }, { "id": "..." }] }.

This route has no server-side validation at all (not even that recipientEmail is present, or that scope/reasonCode are one of the accepted values), and each entry is inserted individually rather than in one transaction: an invalid or malformed entry partway through an unvalidated request can leave earlier entries committed. The CLI's suppressions import validates every row up front and chunks large files for exactly this reason; if you're calling the API directly, validate and chunk on your own side too.


Consent records document the legal basis for contacting someone. By default this is just a ledger: recording consent (or a withdrawal) does not change what the policy engine does.

A tenant can opt into enforcement with the requireConsentForMarketing setting (GET/PATCH /v1/agent/config/settings, or /v1/me/settings from the portal session; default false):

CLI - turn on consent enforcement
molted settings update --require-consent-for-marketing true

Once it's on, the policy engine checks consent before every MARKETING-template send -- missing or withdrawn consent blocks the send with reason no_consent. "Active" consent is the latest consent record for that recipient with no revokedAt set; a revokedAt in the future still counts as withdrawn right now, the same as one in the past. Transactional emails (receipts, password resets), _default, and _-bucket inline sends are always exempt, whatever the setting. Turning enforcement on tightens a tenant's controls (any agent key may do it); turning it off loosens them and is human-gated, the same as lowering the cooldown or clearing the physical address -- see Loosening a control.

List sends and double opt-in confirmations are exempt too: a broadcast to a list (POST /v1/agent/lists/:id/send) and the confirmation email a double opt-in list sends when someone subscribes never check consent_records -- the list subscription itself, with its own opt-in and its own unsubscribe, is the consent. This holds whatever requireConsentForMarketing is set to.

CLI
molted consent record --email alice@example.com \
  --type explicit_opt_in --source signup-form --jurisdiction EU
CLI - revoke consent
molted consent record --email alice@example.com \
  --type explicit_opt_in --revoked-at 2026-06-15T00:00:00Z

CLI flags

FlagTypeRequiredDescription
--emailstringYesRecipient email address.
--typestringYesLegal basis (see table below).
--sourcestringNoWhere consent was collected (e.g., signup-form).
--jurisdictionstringNoJurisdiction code (e.g., EU, US-CA, UK, BR). Free-form, used for compliance reporting.
--granted-atstringNoWhen consent was granted (ISO 8601). Defaults to now.
--revoked-atstringNoWhen consent was revoked (ISO 8601). Set this to revoke existing consent.

Via the API

POST https://api.molted.email/v1/consent
curl
curl -X POST https://api.molted.email/v1/consent \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tenantId": "tenant_abc123",
    "recipientEmail": "alice@example.com",
    "basis": "explicit_opt_in",
    "source": "signup-form",
    "jurisdiction": "EU"
  }'
Response
{
  "id": "429c84fc-fd48-4c90-9506-2f4791398b2a",
  "tenantId": "tenant_abc123",
  "recipientEmail": "alice@example.com",
  "basis": "explicit_opt_in",
  "source": "signup-form",
  "jurisdiction": "EU",
  "grantedAt": "2026-04-20T17:30:00.000Z",
  "revokedAt": null,
  "createdAt": "2026-04-20T17:30:00.000Z"
}

grantedAt defaults to the request time when omitted, so a freshly recorded record always has a usable timestamp for GDPR audit trails. Pass grantedAt explicitly only when backdating consent (e.g. importing history from another system). For a standalone revocation (where prior consent timestamp is unknown), pass revokedAt without grantedAt and the server preserves grantedAt: null.

BasisDescription
explicit_opt_inRecipient explicitly agreed to receive email. Strongest basis, required for EU.
legitimate_interestBusiness relationship justifies contact.
contractualEmail is necessary to fulfill a contract.
legal_obligationEmail is legally required.

Use consent check to verify whether you have a valid legal basis to send marketing email to a recipient.

CLI
molted consent check --email alice@example.com
curl
curl "https://api.molted.email/v1/consent?tenantId=tenant_abc123&recipientEmail=alice@example.com" \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
{
  "hasConsent": true,
  "basis": "explicit_opt_in",
  "source": "signup-form",
  "jurisdiction": "EU",
  "grantedAt": "2026-01-15T10:00:00Z"
}

If your tenant has requireConsentForMarketing on, marketing sends to this address are allowed. If it's off (the default), this is informational only -- the send would go out either way.

Response
{
  "hasConsent": false
}

No consent record exists. If requireConsentForMarketing is on, marketing sends to this address are blocked by the policy engine with reason no_consent; transactional sends are unaffected either way.

To unblock marketing sends, record consent:

molted consent record --email alice@example.com \
  --type explicit_opt_in --source signup-form --jurisdiction EU
Response
{
  "hasConsent": false,
  "revokedAt": "2026-06-15T00:00:00Z"
}

Consent was previously granted but has been revoked. With requireConsentForMarketing on, marketing sends are blocked. To re-enable, record a new consent with a fresh legal basis.

  • Consent is per-email address, not per-contact.
  • Enforcement is opt-in and off by default: recording or revoking consent never blocks a send by itself. Turn it on with molted settings update --require-consent-for-marketing true (or PATCH /v1/agent/config/settings).
  • Once enforcement is on, the policy engine checks consent on every marketing send -- you do not need to call consent check manually before sending.
  • consent check is useful for pre-flight validation in your agent logic, or for building UI that shows consent status, whether or not enforcement is on.
  • Transactional templates bypass consent checks entirely.