Marketing consents

The Marketing consents app records who may receive advertising by email, SMS or WhatsApp, on which legal basis, and the proof of it, with the sign-up, confirmation, withdrawal and preference-centre routes behind it.

This page is the API reference for developers reading or writing marketing consents.

The Marketing consents app decides who may receive advertising by email, SMS or WhatsApp, and on which legal basis, and it keeps the proof of it. A marketing send is allowed only to a confirmed subscription whose legal basis the subscription type allows. The eligibility route answers that per recipient, and the mailing and journey apps ask it before every batch. The app sends no advertising itself: its only mail is the transactional confirmation of a double opt-in.

Four rules hold on every route:

  • Only a click confirms a double opt-in. The confirmation token is sent in the confirmation message and is never returned by any answer of this app. A type with requires_doi: false confirms at once on consent_single instead, with no click.
  • Consent events are never edited or deleted. Only the IP-address truncation after retention and an erasure rewrite a row, and both keep its kind, legal basis and time.
  • A withdrawal takes effect at once and is pushed to the sending service as a suppression for that subscription type. If the sending service did not receive the suppression, the lapse job retries it.
  • A published consent text never changes. People agreed to exactly that wording.

Base path: /v1/marketing-consents.

Objects

ObjectWhat it holds
Subscription typeA purpose a merchant writes about, such as a newsletter. Its code is fixed, labels and descriptions hold a text per language, channels names the channels it is offered by (email, sms, whatsapp), requires_doi (default true) says whether a sign-up waits for the confirmation click, and allows_existing_customer with similar_goods_note says whether a placed order may open the existing-customer basis. archived_at marks an archived type.
Consent textThe wording a person agrees to, versioned per subscription type and language. A draft (published_at empty) can be edited and deleted; a published version is frozen.
SubscriptionOne address on one subscription type and channel, with its state (pending, confirmed, withdrawn or lapsed), its legal_basis, the contact_id of the customers contact that holds the address once one exists, and the consent_text_id agreed to.
Consent eventOne fact in the history of a subscription, written once and never edited: its kind, source, legal_basis, consent_text_id, the ip, user_agent and page_url of the person when the caller sent them, and at, the server time.
ImportOne list import: its subscription type, channel, legal_basis, proof_note, the counts of rows, accepted and rejected, the first 100 refused rows with their reasons in errors, and its state (running, completed or failed).
Preference linkOne stable personal token per address and channel, for the mandatory footer of every marketing message.

Consent event kinds are requested, confirmation_sent, confirmed, withdrawn, lapsed, imported, basis_changed and erased. Legal bases are consent_doi (double opt-in), consent_single (a single opt-in, from an import or a type without double opt-in; a weaker proof), existing_customer (the narrow exception of §7 (3) UWG) and contract, which never allows advertising. The closed value sets are listed by GET /v1/marketing-consents/vocabularies.

Scopes

Every operation needs one capability key, marketing-consents.<resource>.<action>, shown next to it in the tables below. A key holds capability keys or a namespace such as marketing-consents.*, as the API keys page explains. A call that needs a scope the key does not hold is refused with 403.

The trash empty and purge routes are for the back office only: a call from a storefront contact is refused with 403 and purge_not_allowed. A storefront contact also cannot delete a row for good: its second DELETE is refused with 403 and not_permitted.

The market header

The app reads X-Revenexx-Market on a sign-up, on an import, on a preference link and on a preference save. The market is stored on the subscription or the preference link it creates. A sign-up without the header and without a market in its body has no market.

Trash and sync fields

Subscription types and consent texts go to the trash and carry deleted_at, deleted_by, deleted_by_id, trash_batch, purge_at and purge_blocked_reason; their lists take trashed. Subscription types and subscriptions (also the subscriptions inside a contact's consent record) carry the sync fields external_id, external_refs, source_data and source_synced_at. They are read-only here: no create or update body of these routes takes them. Consent texts have no sync fields. Shared rules: Trash; Sync fields.

Subscription types

OperationScopeWhat it does
GET /v1/marketing-consents/subscription-typesmarketing-consents.subscription-types.listLists the subscription types, archived ones included; filter on archived_at. The trash is left out unless trashed asks for it: exclude (default), only or include. Any other value is 400.
POST /v1/marketing-consents/subscription-typesmarketing-consents.subscription-types.createCreates a subscription type. code and labels are required. It is offered by email and needs double opt-in unless requires_doi is false.
GET /v1/marketing-consents/subscription-types/{id}marketing-consents.subscription-types.getReads one subscription type.
PUT /v1/marketing-consents/subscription-types/{id}marketing-consents.subscription-types.updatePartial update. code may only be repeated unchanged. A channel can only be removed while no pending or confirmed subscription holds it.
DELETE /v1/marketing-consents/subscription-types/{id}marketing-consents.subscription-types.deleteMoves the type into the trash. A second DELETE deletes it for good.
POST /v1/marketing-consents/subscription-types/{id}/restoremarketing-consents.subscription-types.restoreTakes the type back out of the trash.
POST /v1/marketing-consents/subscription-types/trash/emptymarketing-consents.subscription-types.empty-trashDeletes every type in the trash for good. A type that something still blocks stays and is named with its reason.
POST /v1/marketing-consents/subscription-types/trash/purgemarketing-consents.subscription-types.purgeDeletes the types that have been in the trash longer than the tenant's trash retention. When that retention cannot be read, the call deletes nothing and reports that it skipped. It is the nightly purge, callable by hand.
POST /v1/marketing-consents/subscription-types/{id}/archivemarketing-consents.subscription-types.archiveStops sign-ups and eligibility answers for the type. Its subscriptions and proof stay. Archiving twice changes nothing.
POST /v1/marketing-consents/subscription-types/{id}/unarchivemarketing-consents.subscription-types.unarchiveLets sign-ups and eligibility answers run again. Subscriptions keep their state.
  • POST (create) body: required code (starts with a lowercase letter, then lowercase letters, digits and _) and labels. Optional: channels (default ["email"]), requires_doi (default true), allows_existing_customer (default false), similar_goods_note, descriptions, position.
  • POST errors: 201 on success. 409 with code_taken, or in_trash when the code belongs to a type in the trash (restore that type instead). 422 with invalid_code, labels_required, invalid_channels or similar_goods_note_required (the existing-customer basis needs the similar goods named).
  • PUT errors: 422 with code_immutable when the code changes, or the same validation codes as create. 409 with channel_in_use when a removed channel still holds subscriptions.
  • DELETE errors: 409 with trash_blocked, and a blocker type_in_use when the type has subscriptions or published consent texts. Archive such a type instead.
  • Archived types: a sign-up or an eligibility call names an archived type with 422 and subscription_type_archived.
  • restore error: 409 with not_in_trash.
OperationScopeWhat it does
GET /v1/marketing-consents/consent-textsmarketing-consents.consent-texts.listLists every version of every wording, drafts included. The trash is left out unless trashed asks for it. Filter by subscription_type_id and locale.
POST /v1/marketing-consents/consent-textsmarketing-consents.consent-texts.createWrites a new draft for one type and language, numbered with the next version. Nobody can agree to a draft.
GET /v1/marketing-consents/consent-texts/{id}marketing-consents.consent-texts.getReads one version of a wording.
PUT /v1/marketing-consents/consent-texts/{id}marketing-consents.consent-texts.updateEdits the wording of a draft.
DELETE /v1/marketing-consents/consent-texts/{id}marketing-consents.consent-texts.deleteMoves a draft into the trash. A second DELETE deletes it for good.
POST /v1/marketing-consents/consent-texts/{id}/restoremarketing-consents.consent-texts.restoreTakes the text back out of the trash, in the state it was in. It answers 409 with parent_in_trash while its subscription type is in the trash.
POST /v1/marketing-consents/consent-texts/trash/emptymarketing-consents.consent-texts.empty-trashDeletes every consent text in the trash for good. A blocked one stays and is named with its reason.
POST /v1/marketing-consents/consent-texts/trash/purgemarketing-consents.consent-texts.purgeDeletes the texts that have been in the trash longer than the tenant's trash retention, and like the type purge deletes nothing when that retention cannot be read. It is the nightly purge, callable by hand.
POST /v1/marketing-consents/consent-texts/{id}/publishmarketing-consents.consent-texts.publishFreezes a draft. From then on sign-ups can agree to it. The newest published version per language is what a sign-up without a named text records.
  • POST (create) body: required subscription_type_id, locale (a language tag such as de or de-CH) and wording.
  • POST errors: 201 on success. 422 with unknown_subscription_type, invalid_locale or wording_required. 409 with version_taken when another draft took the same version at the same moment.
  • PUT errors: 409 with text_published for a published version. 422 with wording_required, or field_immutable when the body changes the type, the language or the version.
  • DELETE errors: 409 with trash_blocked and a blocker text_published for a published version. A published version is proof and is never deleted.
  • publish error: 409 with already_published.
  • restore errors: 409 with not_in_trash, or parent_in_trash while the subscription type of the text is in the trash.

Vocabularies

OperationScopeWhat it does
GET /v1/marketing-consents/vocabulariesmarketing-consents.vocabularies.listLists the value sets this app owns.
GET /v1/marketing-consents/vocabularies/{name}marketing-consents.vocabularies.getReads one value set with every value's label per language and a tone for a badge. The names are channels, subscription-states, legal-bases, consent-event-kinds, consent-sources, eligibility-reasons and import-states. 404 for any other name.

Subscriptions

OperationScopeWhat it does
GET /v1/marketing-consents/subscriptionsmarketing-consents.subscriptions.listLists the subscriptions. Filter by subscription_type_id, state, channel, contact_id and address.
GET /v1/marketing-consents/subscriptions/{id}marketing-consents.subscriptions.getReads one subscription.
POST /v1/marketing-consents/subscriptions/requestmarketing-consents.subscriptions.requestSigns an address up for a subscription type. It opens or reopens a pending subscription, creates the customers contact when the address has none, writes the requested event and sends the confirmation. A type without double opt-in is confirmed at once on consent_single.
POST /v1/marketing-consents/confirm/{token}marketing-consents.subscriptions.confirmThe double opt-in click, called by the storefront's confirmation page. It confirms the pending subscription on consent_doi and writes the proof, with the person's IP and browser when the caller sends them.
POST /v1/marketing-consents/subscriptions/{id}/withdrawmarketing-consents.subscriptions.withdrawEnds a subscription from the back office at once, writes a withdrawn event with source admin, and pushes the address to the suppression list for the type.
POST /v1/marketing-consents/eligibilitymarketing-consents.eligibility.checkSays per recipient whether a marketing send of a subscription type may go out.
POST /v1/marketing-consents/preferences/linksmarketing-consents.preferences.linksIssues the personal preference link for each address and channel. The same address always gets the same token.
GET /v1/marketing-consents/preferences/{token}marketing-consents.preferences.getReads what the preference centre shows behind a personal link: every active type the channel offers, where the address stands on each, and the wording to show.
POST /v1/marketing-consents/preferences/{token}marketing-consents.preferences.updateSaves what the recipient chose in the preference centre.
  • request body: required subscription_type (the type's code). The address is email for the email channel, or phone for sms and whatsapp; address is an alias for whichever the channel takes. Optional: channel (default email, or sms when only phone is sent), contact_id, first_name, last_name, locale (default de), consent_text_id, source (form, storefront, admin or preference_centre; default storefront), source_ref, campaign_ref, market, page_url, ip and user_agent.
  • request answers: 201 when the subscription is opened or reopened. 200 when nothing changed: the address is already confirmed, or a confirmation went out less than ten minutes ago.
  • request errors: 422 with subscription_type_required (no code sent), unknown_subscription_type, subscription_type_archived, invalid_channel, channel_not_offered, address_required (no address sent), invalid_address, invalid_source (a source outside the four listed), no_consent_text (the type has no published text yet, so every sign-up is refused), unknown_consent_text (the consent_text_id belongs to another type) or consent_text_unpublished (the consent_text_id is still a draft).
  • confirm body: none required. Optional ip, user_agent and page_url are written to the proof.
  • confirm answers: 200 with the subscription's state, channel, its type as subscription_type with code and labels, and confirmed_at. A second click on a confirmed subscription answers 200 with already_confirmed: true and writes nothing.
  • confirm errors: 404 with unknown_token when no pending sign-up answers to the token: it was never sent, withdrawn, erased or lapsed. 410 with confirmation_expired when the link has expired but the hourly lapse job has not yet lapsed the sign-up; that click lapses it. Once lapsed, the token answers 404 with unknown_token. Treat both as "sign up again".
  • withdraw body: optional reason, stored as the event's source_ref. Withdrawing twice changes nothing.
  • withdraw errors: 404 when no subscription has the id.
  • eligibility body: required subscription_type (the code) and recipients (up to 500), each a plain address or { address, contact_id? }. Optional channel (default email).
  • eligibility answers: a recipient is allowed only on a confirmed subscription. The refusal reasons are no_subscription, pending, lapsed, withdrawn, basis_not_allowed (the basis is not one the type allows), contract_basis (a contract never allows advertising) and erased.
  • eligibility errors: 422 with subscription_type_required, unknown_subscription_type, subscription_type_archived, invalid_channel, channel_not_offered, recipients_required, or too_many_recipients (more than 500).
  • preferences/links body: required recipients (up to 500), each a plain address or { address, contact_id? }. Optional channel (default email). 422 with recipients_required, too_many_recipients or invalid_channel.
  • preferences/{token} body: choices, an array of { subscription_type, subscribed } items (the type's code and true or false), where subscribed: false withdraws at once and subscribed: true signs up (with a confirmation first when the type requires double opt-in). unsubscribe_all: true withdraws every type of the channel. Optional locale, ip and user_agent. Neither choices nor unsubscribe_all is 422 with choices_required.
  • preferences/{token} errors: 404 with unknown_token when no link answers to the token, or its address was erased.

Evidence

OperationScopeWhat it does
GET /v1/marketing-consents/consent-eventsmarketing-consents.consent-events.listLists the consent events, the proof, newest last when ordered by at. Filter by subscription_id, contact_id, subscription_type_id, channel, kind, source, legal_basis, consent_text_id and further columns.
GET /v1/marketing-consents/consent-events/{id}marketing-consents.consent-events.getReads one consent event. 400 when the id is not a UUID, 404 when no event of this tenant has it.

Contacts

OperationScopeWhat it does
GET /v1/marketing-consents/contacts/{contact_id}marketing-consents.contacts.getReads one contact's consent record: every subscription with its full history.
GET /v1/marketing-consents/contacts/{contact_id}/exportmarketing-consents.contacts.exportExports the record of the subscriptions linked to the contact as one document for an access request: the addresses, the subscriptions, the events and the exact wording of every consent text they point at.
POST /v1/marketing-consents/contacts/{contact_id}/erasemarketing-consents.contacts.eraseErases the contact's consents for an erasure request, on every subscription linked to the contact. It withdraws each one that is not withdrawn yet, pending and lapsed ones included, deletes the preference links of the address, then removes the address, the contact, the IP, the browser and the page from those subscriptions and their events. A subscription stored without a contact (a sign-up made while no contact could be created) is not touched until it is linked to its contact. The type, the legal basis, the times and the address hash stay, so an eligibility answer still refuses the address.

Each contact route answers 422 with invalid_contact when contact_id is not a UUID.

Imports

OperationScopeWhat it does
GET /v1/marketing-consents/importsmarketing-consents.imports.listLists the imports with their basis, proof note and counts.
POST /v1/marketing-consents/importsmarketing-consents.imports.createImports an existing list on its recorded basis. Up to 1000 rows per call. Each accepted row becomes a confirmed subscription with an imported event that points at the import.
GET /v1/marketing-consents/imports/{id}marketing-consents.imports.getReads one import with its refused rows. 404 when no import has the id.
  • POST body: required subscription_type (the code), legal_basis and proof_note, and rows (up to 1000, each { address, first_name?, last_name?, consented_at?, source_ref? }). Optional channel (default email) and file_ref.
  • POST basis: legal_basis is consent_single or existing_customer, never double opt-in, since the import did not see a click. The existing_customer basis needs a type that allows it.
  • POST answers: 201 with the counts and the first 100 refused rows. A row is refused with a reason such as erased, withdrawn or already_subscribed.
  • POST errors: 422 with import_basis_not_allowed (a basis other than the two above), basis_not_allowed (existing_customer on a type whose allows_existing_customer is off), proof_note_required, rows_required (an empty list), too_many_rows (more than 1000), unknown_subscription_type, subscription_type_archived, invalid_channel or channel_not_offered.
  • proof_note is required: it says where the consent of these addresses is documented (system, period, form), because an import without it is a claim without proof.

Jobs

These three routes run the same work as the app's scheduled jobs, and they can be called by hand. Each call is bounded.

OperationScopeWhat it does
POST /v1/marketing-consents/lapse/runmarketing-consents.lapse.runTurns every pending subscription whose link expired into lapsed. No reminder is sent. It also retries the suppressions the sending service did not receive. The hourly job runs it.
POST /v1/marketing-consents/ip-retention/runmarketing-consents.ip-retention.runShortens the IP address of every consent event older than the ip_retention_days setting (default 90): the last IPv4 octet and the last 80 bits of an IPv6 address. The event stays. The nightly job runs it.
POST /v1/marketing-consents/sweep/runmarketing-consents.sweep.runCatches up on what other apps decided: an unsubscribe or complaint of the last 48 hours withdraws the subscription, an order of the last three hours opens the existing-customer basis on the types that allow it (its checkout must have shown the objection notice, unless the existing_customer_notice_required setting, default true, is switched off; a withdrawn or erased subscription is never reopened), and a subscription without a contact is linked to the contact of its address. The hourly job runs it.

The doi_expiry_days setting (default 7) sets how long a confirmation link stays valid.

Errors on every route

Every route can also answer 401 (no valid credentials), 403 (the key lacks the scope), 404 for an unknown id, and 429 when the rate limit is reached. A delete answers 409 with trash_blocked when the row cannot go into the trash, and 409 with delete_blocked when a second delete cannot remove it for good yet. The blocker is named. A write to a row in the trash answers 409 with in_trash; restore it first.

Was this page helpful?