Skip to content

CASL consent ​

Canada's Anti-Spam Legislation (CASL, LCAP in French) requires a sender to hold consent before it emails or texts a commercial message to a person. Vroum records that consent per client and per shop, because consent is given to a sender and a client can belong to several shops.

Sources: CRTC implied-consent guidance, ISED: getting consent to send email.

The rules encoded ​

BasisValid forRecorded by
Express consent via the public linkUntil withdrawnGrantCaslConsentByLink
Express consent given in personUntil withdrawnGrantCaslConsentInPerson (reason required)
Purchase: a work order reaching paid2 years after the most recent oneRecordCaslTransaction listener
Inquiry: an inbound email or SMS6 months after the most recent oneRecordCaslInquiry listener

A withdrawal revokes every basis. A later purchase or inquiry does not restore consent; only a new express consent does, and recording one clears the withdrawal.

Two legal consequences shape the feature. A request for consent sent by email is itself a commercial message, so the consent link may only be emailed while consent is still valid. Every commercial message must offer a way to withdraw, which the public consent page provides.

Storage ​

client_shop.casl_consent is a nullable JSON column cast to App\ValueObjects\CaslConsent through CaslConsentCast:

json
{
    "last_transaction_at": "2026-09-01T14:00:00+00:00",
    "last_transaction_work_order_id": 31,
    "last_inquiry_at": "2026-08-20T09:12:00+00:00",
    "last_inquiry_channel": "email",
    "express": {
        "source": "in_person",
        "granted_at": "2026-09-05T10:00:00+00:00",
        "granted_by": 12,
        "reason": "Signed the consent form at the counter",
        "ip": "203.0.113.4"
    },
    "withdrawn_at": null
}

The work order id and the inquiry channel travel with their dates, so the client page can link the last invoice and name the channel. The value object is immutable and derives everything else: status() returns a CaslConsentStatus (express, implied, none), impliedExpiresAt() is the later of the two implied windows, and canReceiveCommercialMessages() is what senders check. The with*() methods return a new instance; withTransaction() and withInquiry() keep the later date so an old invoice cannot move the clock back.

Read it with Client::caslConsentFor(Shop $shop). Write it only through Client::updateCaslConsentFor(Shop $shop, callable $mutate), which reloads the pivot row before applying the change so two listeners cannot overwrite each other.

Client::caslConsentUrl(Shop $shop) returns a temporary signed URL valid for 30 days (Client::CASL_LINK_DAYS). The routes live under the {shop} public prefix in routes/web.php, all behind the signed middleware:

MethodRouteAction
GETpublic.caslConsent.showShowCaslConsent
POSTpublic.caslConsent.grantGrantCaslConsentByLink
DELETEpublic.caslConsent.withdrawWithdrawCaslConsentByLink

The page (Pages/Public/CaslConsent/Show.vue) posts back to the exact URL the visitor arrived on so the signature survives. A client that is not attached to the shop in the URL gets a 404 even with a valid signature.

The link is available to automations as the {{ work_order.client.casl_link }} token, backed by the casl_consent_link accessor on WorkOrder.

Shop side ​

The client page's overview tab shows a CASL section with the status badge, the basis and its expiry, the last purchase and inquiry dates, a copy-link button and two actions:

  • Record in-person consent opens a modal that requires a reason. GrantCaslConsentInPerson stores the reason, the acting user and the IP as proof.
  • Withdraw consent calls WithdrawCaslConsent after confirmation.

Both are PUT / DELETE shop.clients.caslConsent.* under clients/{client} and are authorised by ClientPolicy::update.

The gate ​

ActionSender checks consent before rendering any SEND_SMS or SEND_EMAIL run and marks the run skipped with reason no_casl_consent when the subject's client cannot receive commercial messages for the subject's shop. SEND_NOTIFICATION targets staff and is not gated. Inbox replies are answers to an inquiry and go out regardless.

Consent is one of several skip checks, in this order: no_recipient, no_shop, module_disabled (the subject's shop has no automations module), no_casl_consent, then plan_limit_reached (the shop's monthly sms / emails quota is used up). A run without consent is therefore skipped as no_casl_consent whether or not the quota is left, and a shop without automations never reaches the consent check. Every skip is final. See Plans & modules.

Tests that expect an automation run to reach sent must give the fixture client consent first; see DispatchAutomationActionsTest::setUp().