Newsletter
Goal
Build a newsletter subscription: a form for any visitor and a subscription toggle on the account page. The important part is not the form, but the double opt-in lifecycle behind status — a subscribe request that succeeds has not subscribed anybody yet, and the status it returns lives per composable instance rather than in shared state.
Shopware Flow
subscribeToNewsletter post /newsletter/subscribe creates the recipient and returns success and status. It does not complete the subscription on its own: with option set to subscribe, the Store API sends a confirmation email, and the subscription only becomes active once confirmNewsletter post /newsletter/confirm is called with the em and hash values from that email.
The subscribe and unsubscribe routes are secured with the sales channel access token alone, so a guest can subscribe. readNewsletterRecipient post /account/newsletter-recipient is an account route secured with the context token, which is why getNewsletterStatus() belongs behind the login and the other two calls do not.
Hover a type chip to inspect fields generated from the current Store API schema.
Step 1
UI: Submit an email address
A footer box or an account toggle collects the email address and the option value. The component owns the input and the pending flag, nothing else.
- Code
newsletterSubscribe({ email, option: SUBSCRIBE_KEY })- State
- local form state
- Types
Read the diagram from left to right:
- A visitor submits an email address with
optionset toSUBSCRIBE_KEY. useNewsletteraddsstorefrontUrlfromuseInternationalization().getStorefrontUrl()to the body.- The Store API creates the recipient and, for the
subscribeoption, sends the confirmation email. newsletterSubscribewrites the returnedstatusintonewsletterStatus, so no extra status request is needed after subscribing.- The link in the email opens your app with
emandhash, and that page callsconfirmNewsletter post /newsletter/confirm. getNewsletterStatus()reads the status of the logged-in customer from the account route.- The UI reads
isNewsletterSubscriberandconfirmationNeededfrom the composable instead of keeping its own copy.
You never call the status route after subscribing, and you never refresh the session or the cart. newsletterSubscribe already wrote the status the response carried, and no context value, price, or cart line depends on a newsletter subscription.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Subscribe an address | newsletterSubscribe({ email, option: SUBSCRIBE_KEY }) | POST /newsletter/subscribe | subscribeToNewsletter body |
| Confirm the double opt-in | apiClient.invoke("confirmNewsletter post /newsletter/confirm") | POST /newsletter/confirm | confirmNewsletter body |
| Unsubscribe an address | newsletterUnsubscribe(email) | POST /newsletter/unsubscribe | unsubscribeToNewsletter body |
| Read the customer status | getNewsletterStatus() | POST /account/newsletter-recipient | readNewsletterRecipient response |
newsletterSubscribe resolves with the response body — subscribeToNewsletter response — and writes its status into newsletterStatus on the way out, so the subscribe call is also the status call. The other two writes give you nothing: newsletterUnsubscribe resolves with void, and the confirm operation answers a bare SuccessResponse.
Composables
Pick by scope — how much of the flow the composable is about:
| Composable | Scope | Reach for it when |
|---|---|---|
useNewsletter | the whole subscription | subscribing, unsubscribing, or rendering the current status |
useUser | the customer session | isLoggedIn gates the status call, user supplies the email |
useInternationalization | the storefront origin | you need getStorefrontUrl() outside a useNewsletter call |
useShopwareContext | the raw API client | apiClient confirms the double opt-in, the step no composable wraps |
useNewsletter is the one this recipe is about:
- Read —
newsletterStatus,isNewsletterSubscriber,confirmationNeeded. - Write —
newsletterSubscribe(params),newsletterUnsubscribe(email). - Load —
getNewsletterStatus(). - Option values —
SUBSCRIBE_KEY("subscribe") andUNSUBSCRIBE_KEY("unsubscribe").
Five things the generated reference will not tell you:
newsletterStatusis a plainrefcreated inside the function body. There is nouseContextkey and no module-level state behind it, so a footer subscribe box and an account toggle each get their own status and never see each other's updates.- The three calls disagree about who writes that ref.
newsletterSubscribeandgetNewsletterStatusboth store the status they received;newsletterUnsubscriberesolves withvoidand leaves it untouched, so the UI keeps rendering the previous state until you reload the status. isNewsletterSubscriberis true for every status exceptoptOutandundefined, andconfirmationNeededis true only fornotSet. A recipient innotSetis therefore reported as a subscriber and as awaiting confirmation at the same time — checkconfirmationNeededfirst.SUBSCRIBE_KEYandUNSUBSCRIBE_KEYare typedstring, andoptionin the request body is a plainstringtoo, so nothing type-checks the value. The operation description also documentsdirect, which activates the subscription without a confirmation mail, andconfirmSubscribe.getNewsletterStatus()performs no session check of its own. It posts to an account route, so without a customer session the request fails rather than returning an empty status.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you type the subscribe body, the confirmation parameters, or the recipient status:
import type { Schemas, operations } from "#shopware";
type NewsletterSubscribeBody =
operations["subscribeToNewsletter post /newsletter/subscribe"]["body"];
type NewsletterSubscribeResponse =
operations["subscribeToNewsletter post /newsletter/subscribe"]["response"];
type NewsletterConfirmBody =
operations["confirmNewsletter post /newsletter/confirm"]["body"];
type AccountNewsletterRecipient = Schemas["AccountNewsletterRecipient"];
type NewsletterRecipientStatus = AccountNewsletterRecipient["status"];newsletterSubscribe accepts Omit<NewsletterSubscribeBody, "storefrontUrl">, and the status is the union "notSet" | "optIn" | "optOut" | "direct" | "undefined" — the same type the composable declares for newsletterStatus.
Derive the status from AccountNewsletterRecipient rather than from Schemas["NewsletterStatus"]. The named schema is recent — the 6.6.10 and 6.7.10 schemas shipped in this repo do not declare it, 6.7.13 does — and on an older backend the generated types spell the same union inline on the recipient instead. The alias above works against either.
Minimal Vue Example
<script setup lang="ts">
const { user, isLoggedIn } = useUser();
const {
newsletterSubscribe,
newsletterUnsubscribe,
getNewsletterStatus,
newsletterStatus,
isNewsletterSubscriber,
confirmationNeeded,
SUBSCRIBE_KEY,
} = useNewsletter();
const email = ref("");
const isSubmitting = ref(false);
const isLoadingStatus = ref(false);
const newsletterError = ref("");
const errorId = useId();
const subscriberEmail = computed(() =>
isLoggedIn.value ? (user.value?.email ?? "") : email.value,
);
const loadStatus = async () => {
// The account route answers for the logged-in customer only.
if (!isLoggedIn.value) return;
isLoadingStatus.value = true;
try {
await getNewsletterStatus();
} catch {
newsletterError.value = "The newsletter status could not be loaded.";
} finally {
isLoadingStatus.value = false;
}
};
const subscribe = async () => {
newsletterError.value = "";
isSubmitting.value = true;
try {
// storefrontUrl is added by the composable, never by the form.
// The response status is written to newsletterStatus, so no reload here.
await newsletterSubscribe({
email: subscriberEmail.value,
option: SUBSCRIBE_KEY,
});
} catch {
newsletterError.value = "The subscription could not be saved.";
} finally {
isSubmitting.value = false;
}
};
const unsubscribe = async () => {
newsletterError.value = "";
isSubmitting.value = true;
try {
await newsletterUnsubscribe(subscriberEmail.value);
// newsletterUnsubscribe resolves with void and leaves newsletterStatus
// untouched, so read the status again for a logged-in customer.
await loadStatus();
} catch {
newsletterError.value = "The subscription could not be removed.";
} finally {
isSubmitting.value = false;
}
};
// Immediate watcher instead of onMounted: it also runs when the customer
// signs in without a page change, for example through the login modal.
watch(
isLoggedIn,
(loggedIn) => {
if (!loggedIn) {
newsletterStatus.value = "undefined";
newsletterError.value = "";
email.value = "";
return;
}
loadStatus();
},
{ immediate: import.meta.client },
);
</script>
<template>
<section>
<h2>Newsletter</h2>
<p v-if="newsletterError" :id="errorId" role="alert">
{{ newsletterError }}
</p>
<form v-if="!isLoggedIn" @submit.prevent="subscribe">
<label>
Email
<input
v-model="email"
type="email"
autocomplete="email"
required
:aria-invalid="newsletterError ? true : undefined"
:aria-describedby="newsletterError ? errorId : undefined"
/>
</label>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? "Sending..." : "Subscribe" }}
</button>
<p v-if="confirmationNeeded" role="status">
Check your inbox and confirm the subscription through the link we sent.
</p>
</form>
<template v-else>
<p v-if="isLoadingStatus && !isSubmitting" role="status">
Loading subscription status...
</p>
<template v-else>
<p v-if="confirmationNeeded" role="status">
Your subscription is waiting for the confirmation link sent to
{{ user?.email }}.
</p>
<button
v-if="isNewsletterSubscriber"
type="button"
:disabled="isSubmitting"
@click="unsubscribe()"
>
Unsubscribe
</button>
<button
v-else
type="button"
:disabled="isSubmitting"
@click="subscribe()"
>
Subscribe
</button>
</template>
</template>
</section>
</template>State And Session
Subscribing changes nothing about the sales channel session. The recipient is identified by the email address in the body, not by sw-context-token, and useNewsletter calls neither refreshSessionContext() nor refreshCart() because no context value, price, or cart line depends on a newsletter subscription.
getNewsletterStatus() is the exception. readNewsletterRecipient post /account/newsletter-recipient is declared with the context token in the schema and returns an AccountNewsletterRecipient, the status of the customer behind the current session. Call it after login, as vue-starter-template does on the account overview in app/pages/account/index.vue.
Because newsletterStatus lives per composable instance, every component that renders subscription state has to fill its own instance with newsletterSubscribe() or getNewsletterStatus(). Until one of them resolves, the ref holds its initial "undefined", which reads as "not a subscriber" rather than as "not loaded yet" — track loading separately if the difference matters to the UI.
Edge Cases
getStorefrontUrl()readswindow.location.originunlessdevStorefrontUrlis configured, so on the server it throws aReferenceErrorinstead of returning a fallback. TriggernewsletterSubscribefrom a client-side handler, and setdevStorefrontUrlfor local development, where the origin islocalhostand matches no sales channel domain.- In a multi-domain or multi-language sales channel,
storefrontUrldecides which domain the confirmation link points at. Subscribing from the wrong origin sends the customer to a domain that may not serve your confirmation route. - No composable wraps
confirmNewsletter post /newsletter/confirm.vue-starter-templatehandles it inapp/pages/newsletter-subscribe.vueby readingemandhashfrom the query and callingapiClient.invokedirectly. Without such a page, double opt-in subscriptions never activate. newsletterUnsubscriberesolves withvoid. It does not touchnewsletterStatus, so the UI keeps showing the previous state until you callgetNewsletterStatus()again, which is only possible for a logged-in customer.- A guest has no way to read a status. After a guest subscribe, the only status you have is the one returned by that single request.
isLoggedInis!!user.id && !!user.active && !user.guest, so it is false for a guest-checkout session — and that session still fillsuserwith the address from the order. A component that picks the address withuser.value?.email ?? email.valuetherefore sends the checkout address and silently discards the one the visitor typed. Pick the address from the same flag the template branches on, not from whetheruserhappens to be set.useNewsletterdoes not check the session beforegetNewsletterStatus(). The route resolves the recipient from the customer behind the context token, so guard the call withisLoggedInand keep it out of pages a guest can open.- The subscribe body also accepts
salutationId,firstName,lastName,street,zipCode,city,languageId, andcustomFields.SwNewsletterFormin@shopware/cms-base-layer— what the CMS form element renders — sends the first three; the starter's footer box sends onlyemail. - Neither
useNewsletternor the shipped form components (SwNewsletterForm, the starter'sNewsletterBox) send a captcha, honeypot, or any other bot-protection field. The subscribe route is reachable with the sales channel access token alone, so rate limiting and bot protection belong in front of the Store API.
Common Mistakes
- Do not pass
storefrontUrlin the subscribe parameters. The composable injects it, and the parameter type omits it. - Do not assume two components share
newsletterStatus. The ref is created peruseNewsletter()call. - Do not treat
isNewsletterSubscriberas confirmed. CheckconfirmationNeededbefore telling the customer the subscription is active. - Do not call
getNewsletterStatus()outside a customer session. - Do not request the status again right after
newsletterSubscribe(). The composable already wrote the status from the response. - Do not report whether an address was on the list after
newsletterUnsubscribe. The call returns nothing, and echoing a difference would confirm addresses to whoever submits the form. - Do not expose raw API error details from the newsletter routes in the UI.
Testing Checklist
- Subscribing calls
subscribeToNewsletter post /newsletter/subscribewithoptionset tosubscribeand astorefrontUrlthe component never provided. - The status returned by the subscribe response lands in
newsletterStatuswithout a follow-up request. - A
notSetstatus renders the pending-confirmation hint and leavesisNewsletterSubscribertrue. - Opening the confirmation link with
emandhashcallsconfirmNewsletter post /newsletter/confirmand renders a success state. - A missing or invalid
emorhashrenders an error state instead of a success message. - Unsubscribing calls
unsubscribeToNewsletter post /newsletter/unsubscribeand, for a logged-in customer, refreshes the status afterwards. getNewsletterStatus()is not called whileisLoggedInis false.- A failing subscribe shows a form-level message and no raw API error.