Marketing consents
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: falseconfirms at once onconsent_singleinstead, 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
| Object | What it holds |
|---|---|
| Subscription type | A 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 text | The 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. |
| Subscription | One 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 event | One 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. |
| Import | One 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 link | One 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
| Operation | Scope | What it does |
|---|---|---|
GET /v1/marketing-consents/subscription-types | marketing-consents.subscription-types.list | Lists 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-types | marketing-consents.subscription-types.create | Creates 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.get | Reads one subscription type. |
PUT /v1/marketing-consents/subscription-types/{id} | marketing-consents.subscription-types.update | Partial 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.delete | Moves the type into the trash. A second DELETE deletes it for good. |
POST /v1/marketing-consents/subscription-types/{id}/restore | marketing-consents.subscription-types.restore | Takes the type back out of the trash. |
POST /v1/marketing-consents/subscription-types/trash/empty | marketing-consents.subscription-types.empty-trash | Deletes 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/purge | marketing-consents.subscription-types.purge | Deletes 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}/archive | marketing-consents.subscription-types.archive | Stops sign-ups and eligibility answers for the type. Its subscriptions and proof stay. Archiving twice changes nothing. |
POST /v1/marketing-consents/subscription-types/{id}/unarchive | marketing-consents.subscription-types.unarchive | Lets sign-ups and eligibility answers run again. Subscriptions keep their state. |
POST(create) body: requiredcode(starts with a lowercase letter, then lowercase letters, digits and_) andlabels. Optional:channels(default["email"]),requires_doi(defaulttrue),allows_existing_customer(defaultfalse),similar_goods_note,descriptions,position.POSTerrors:201on success.409withcode_taken, orin_trashwhen the code belongs to a type in the trash (restore that type instead).422withinvalid_code,labels_required,invalid_channelsorsimilar_goods_note_required(the existing-customer basis needs the similar goods named).PUTerrors:422withcode_immutablewhen the code changes, or the same validation codes as create.409withchannel_in_usewhen a removed channel still holds subscriptions.DELETEerrors:409withtrash_blocked, and a blockertype_in_usewhen 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
422andsubscription_type_archived. restoreerror:409withnot_in_trash.
Consent texts
| Operation | Scope | What it does |
|---|---|---|
GET /v1/marketing-consents/consent-texts | marketing-consents.consent-texts.list | Lists 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-texts | marketing-consents.consent-texts.create | Writes 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.get | Reads one version of a wording. |
PUT /v1/marketing-consents/consent-texts/{id} | marketing-consents.consent-texts.update | Edits the wording of a draft. |
DELETE /v1/marketing-consents/consent-texts/{id} | marketing-consents.consent-texts.delete | Moves a draft into the trash. A second DELETE deletes it for good. |
POST /v1/marketing-consents/consent-texts/{id}/restore | marketing-consents.consent-texts.restore | Takes 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/empty | marketing-consents.consent-texts.empty-trash | Deletes 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/purge | marketing-consents.consent-texts.purge | Deletes 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}/publish | marketing-consents.consent-texts.publish | Freezes 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: requiredsubscription_type_id,locale(a language tag such asdeorde-CH) andwording.POSTerrors:201on success.422withunknown_subscription_type,invalid_localeorwording_required.409withversion_takenwhen another draft took the same version at the same moment.PUTerrors:409withtext_publishedfor a published version.422withwording_required, orfield_immutablewhen the body changes the type, the language or the version.DELETEerrors:409withtrash_blockedand a blockertext_publishedfor a published version. A published version is proof and is never deleted.publisherror:409withalready_published.restoreerrors:409withnot_in_trash, orparent_in_trashwhile the subscription type of the text is in the trash.
Vocabularies
| Operation | Scope | What it does |
|---|---|---|
GET /v1/marketing-consents/vocabularies | marketing-consents.vocabularies.list | Lists the value sets this app owns. |
GET /v1/marketing-consents/vocabularies/{name} | marketing-consents.vocabularies.get | Reads 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
| Operation | Scope | What it does |
|---|---|---|
GET /v1/marketing-consents/subscriptions | marketing-consents.subscriptions.list | Lists the subscriptions. Filter by subscription_type_id, state, channel, contact_id and address. |
GET /v1/marketing-consents/subscriptions/{id} | marketing-consents.subscriptions.get | Reads one subscription. |
POST /v1/marketing-consents/subscriptions/request | marketing-consents.subscriptions.request | Signs 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.confirm | The 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}/withdraw | marketing-consents.subscriptions.withdraw | Ends 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/eligibility | marketing-consents.eligibility.check | Says per recipient whether a marketing send of a subscription type may go out. |
POST /v1/marketing-consents/preferences/links | marketing-consents.preferences.links | Issues 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.get | Reads 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.update | Saves what the recipient chose in the preference centre. |
requestbody: requiredsubscription_type(the type's code). The address isemailfor the email channel, orphoneforsmsandwhatsapp;addressis an alias for whichever the channel takes. Optional:channel(defaultemail, orsmswhen onlyphoneis sent),contact_id,first_name,last_name,locale(defaultde),consent_text_id,source(form,storefront,adminorpreference_centre; defaultstorefront),source_ref,campaign_ref,market,page_url,ipanduser_agent.requestanswers:201when the subscription is opened or reopened.200when nothing changed: the address is already confirmed, or a confirmation went out less than ten minutes ago.requesterrors:422withsubscription_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(asourceoutside the four listed),no_consent_text(the type has no published text yet, so every sign-up is refused),unknown_consent_text(theconsent_text_idbelongs to another type) orconsent_text_unpublished(theconsent_text_idis still a draft).confirmbody: none required. Optionalip,user_agentandpage_urlare written to the proof.confirmanswers:200with the subscription'sstate,channel, its type assubscription_typewithcodeandlabels, andconfirmed_at. A second click on a confirmed subscription answers200withalready_confirmed: trueand writes nothing.confirmerrors:404withunknown_tokenwhen no pending sign-up answers to the token: it was never sent, withdrawn, erased or lapsed.410withconfirmation_expiredwhen the link has expired but the hourly lapse job has not yet lapsed the sign-up; that click lapses it. Once lapsed, the token answers404withunknown_token. Treat both as "sign up again".withdrawbody: optionalreason, stored as the event'ssource_ref. Withdrawing twice changes nothing.withdrawerrors:404when no subscription has the id.eligibilitybody: requiredsubscription_type(the code) andrecipients(up to 500), each a plain address or{ address, contact_id? }. Optionalchannel(defaultemail).eligibilityanswers: a recipient is allowed only on a confirmed subscription. The refusal reasons areno_subscription,pending,lapsed,withdrawn,basis_not_allowed(the basis is not one the type allows),contract_basis(a contract never allows advertising) anderased.eligibilityerrors:422withsubscription_type_required,unknown_subscription_type,subscription_type_archived,invalid_channel,channel_not_offered,recipients_required, ortoo_many_recipients(more than 500).preferences/linksbody: requiredrecipients(up to 500), each a plain address or{ address, contact_id? }. Optionalchannel(defaultemail).422withrecipients_required,too_many_recipientsorinvalid_channel.preferences/{token}body:choices, an array of{ subscription_type, subscribed }items (the type's code andtrueorfalse), wheresubscribed: falsewithdraws at once andsubscribed: truesigns up (with a confirmation first when the type requires double opt-in).unsubscribe_all: truewithdraws every type of the channel. Optionallocale,ipanduser_agent. Neitherchoicesnorunsubscribe_allis422withchoices_required.preferences/{token}errors:404withunknown_tokenwhen no link answers to the token, or its address was erased.
Evidence
Consent events
| Operation | Scope | What it does |
|---|---|---|
GET /v1/marketing-consents/consent-events | marketing-consents.consent-events.list | Lists 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.get | Reads one consent event. 400 when the id is not a UUID, 404 when no event of this tenant has it. |
Contacts
| Operation | Scope | What it does |
|---|---|---|
GET /v1/marketing-consents/contacts/{contact_id} | marketing-consents.contacts.get | Reads one contact's consent record: every subscription with its full history. |
GET /v1/marketing-consents/contacts/{contact_id}/export | marketing-consents.contacts.export | Exports 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}/erase | marketing-consents.contacts.erase | Erases 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
| Operation | Scope | What it does |
|---|---|---|
GET /v1/marketing-consents/imports | marketing-consents.imports.list | Lists the imports with their basis, proof note and counts. |
POST /v1/marketing-consents/imports | marketing-consents.imports.create | Imports 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.get | Reads one import with its refused rows. 404 when no import has the id. |
POSTbody: requiredsubscription_type(the code),legal_basisandproof_note, androws(up to 1000, each{ address, first_name?, last_name?, consented_at?, source_ref? }). Optionalchannel(defaultemail) andfile_ref.POSTbasis:legal_basisisconsent_singleorexisting_customer, never double opt-in, since the import did not see a click. Theexisting_customerbasis needs a type that allows it.POSTanswers:201with the counts and the first 100 refused rows. A row is refused with a reason such aserased,withdrawnoralready_subscribed.POSTerrors:422withimport_basis_not_allowed(a basis other than the two above),basis_not_allowed(existing_customeron a type whoseallows_existing_customeris off),proof_note_required,rows_required(an empty list),too_many_rows(more than 1000),unknown_subscription_type,subscription_type_archived,invalid_channelorchannel_not_offered.proof_noteis 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.
| Operation | Scope | What it does |
|---|---|---|
POST /v1/marketing-consents/lapse/run | marketing-consents.lapse.run | Turns 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/run | marketing-consents.ip-retention.run | Shortens 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/run | marketing-consents.sweep.run | Catches 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.