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 ontom+news@acme.comblockstom@acme.com) - dots in a Gmail or Googlemail local part:
t.o.m@gmail.comistom@gmail.com, andtom@googlemail.comistom@gmail.comtoo - 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.exampleisida@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:
| Scope | Description |
|---|---|
tenant | Blocked for your tenant. |
campaign | Recorded 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
molted suppressions add --email user@example.com --reason manual_dnc --scope tenantmolted suppressions add --email user@example.com --reason no_engagement --scope tenant \
--expires-at 2027-01-01T00:00:00ZCLI flags
| Flag | Type | Required | Description |
|---|---|---|---|
--email | string | Yes | Email address to suppress. Must be a valid email format. |
--reason | string | Yes | Reason code (see table below). |
--scope | string | Yes | Suppression scope: tenant or campaign. |
--campaign-id | string | No | Campaign ID. Required when --scope is campaign. |
--source | string | No | Source of the suppression (for your own tracking). |
--expires-at | string | No | ISO 8601 expiry timestamp. If omitted, suppression is permanent. |
Via the API
POST https://api.molted.email/v1/suppressionscurl -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"
}'| Field | Type | Required | Description |
|---|---|---|---|
tenantId | string | Yes | Your tenant identifier. |
recipientEmail | string | Yes | Email address to suppress. |
scope | string | Yes | Suppression scope: tenant or campaign. |
reasonCode | string | Yes | Reason code (see table below). |
campaignId | string | No | Campaign ID. Required when scope is campaign. |
source | string | No | Source of the suppression (for your own tracking). |
expiresAt | string | No | ISO 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_accountanddomain_suppressed, which outranksoft_bounceandno_engagement. The reason andsourcechange only when the new reason ranks at least as high. - The
sourcevalueportal_keep_awayis reserved for a person's Keep agents away in the portal; sending it returns400 reserved_source.
Lifting a suppression early is a removal (below), which needs a person.
Reason codes
| Code | Description |
|---|---|
complaint | Recipient marked an email as spam. |
hard_bounce | Email address hard-bounced. |
manual_dnc | Manually added to do-not-contact list. |
legal_request | Suppressed due to a legal request (e.g., GDPR erasure). |
role_account | Role account (e.g., info@, admin@) that should not receive automated email. |
domain_suppressed | Entire domain is suppressed. |
no_engagement | Contact has not engaged with prior emails. Consider using --expires-at for temporary suppression. |
Remove a suppression
molted suppressions remove SUPPRESSION_IDcurl -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 "https://api.molted.email/v1/suppressions/check?tenantId=tenant_abc123&email=user@example.com" \
-H "Authorization: Bearer YOUR_API_KEY"molted suppressions check --email user@example.com
molted suppressions check --domain competitor.com{ "email": "user@example.com", "suppressed": true, "reason": "manual_dnc", "matchedOn": "domain" }| Parameter | Type | Required | Description |
|---|---|---|---|
tenantId | string | Yes | Your tenant identifier. |
email | string | One of email or domain | Address to check. |
domain | string | One of email or domain | Domain 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
molted suppressions list --email user@example.com
molted suppressions list --limit 50 --offset 100
molted suppressions list-domains --limit 50 --offset 100curl "https://api.molted.email/v1/suppressions?tenantId=tenant_abc123&recipientEmail=user@example.com" \
-H "Authorization: Bearer YOUR_API_KEY"| Parameter | Type | Required | Description |
|---|---|---|---|
tenantId | string | Yes | Your tenant identifier. |
recipientEmail | string | No | Filter by email address. |
limit | integer | No | Page size, 1-500. With neither limit nor offset the list returns every row for recipientEmail, or the latest 100. |
offset | integer | No | Rows 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.
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 restEvery 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/bulkcurl -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
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):
molted settings update --require-consent-for-marketing trueOnce 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.
Record consent
molted consent record --email alice@example.com \
--type explicit_opt_in --source signup-form --jurisdiction EUmolted consent record --email alice@example.com \
--type explicit_opt_in --revoked-at 2026-06-15T00:00:00ZCLI flags
| Flag | Type | Required | Description |
|---|---|---|---|
--email | string | Yes | Recipient email address. |
--type | string | Yes | Legal basis (see table below). |
--source | string | No | Where consent was collected (e.g., signup-form). |
--jurisdiction | string | No | Jurisdiction code (e.g., EU, US-CA, UK, BR). Free-form, used for compliance reporting. |
--granted-at | string | No | When consent was granted (ISO 8601). Defaults to now. |
--revoked-at | string | No | When consent was revoked (ISO 8601). Set this to revoke existing consent. |
Via the API
POST https://api.molted.email/v1/consentcurl -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"
}'{
"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.
Legal bases
| Basis | Description |
|---|---|
explicit_opt_in | Recipient explicitly agreed to receive email. Strongest basis, required for EU. |
legitimate_interest | Business relationship justifies contact. |
contractual | Email is necessary to fulfill a contract. |
legal_obligation | Email is legally required. |
Check consent status
Use consent check to verify whether you have a valid legal basis to send marketing email to a recipient.
molted consent check --email alice@example.comcurl "https://api.molted.email/v1/consent?tenantId=tenant_abc123&recipientEmail=alice@example.com" \
-H "Authorization: Bearer YOUR_API_KEY"Response: consent exists
{
"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: no consent
{
"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 EUResponse: consent revoked
{
"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 behavior
- 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(orPATCH /v1/agent/config/settings). - Once enforcement is on, the policy engine checks consent on every marketing send -- you do not need to call
consent checkmanually before sending. consent checkis 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.
Related
- GDPR & Privacy -- data export, deletion, and decision trace
- Sending Email -- how the policy engine evaluates send requests