Back to Journal

Slack notifications for recipient updates

Listen to this articleAI narration
0:00 / 0:00

Freelancers change their invoice recipients more often than you'd expect. New clients, updated billing addresses, corrected VAT numbers. The admin team wanted visibility into these changes without digging through database logs.

What started as a simple "send a Slack message" ticket evolved into a proper event-driven implementation - touching Symfony Messenger, async SQS workers, and the inevitable "channel not found" debugging session.

The Request

GitHub ticket number 941 was straightforward: when a user updates where the invoice should be sent, notify via Slack with the client name (Uppdragsgivarens namn), the user's name and email, and the updated email address.

But as we dug in, the scope expanded. Why only email? What about address changes, contact name updates, VAT number corrections? The team wanted visibility into all recipient modifications.

The Architecture

The platform already had a pattern for this: Symfony Messenger with event-driven handlers. Here's how it works:

Event-Driven Notification FlowUser PATCH/recipients/{id}RecipientListenerPOST_READ priorityEvent Bus[sync, workqueue]SQSWorker Lambda (Async Processing)Message HandlerfromTransport: workqueueSlackClientHTTP POST to Slack APISlack Channel#recipient-notifications

The dual routing to both sync and workqueue means events process synchronously first (for immediate handlers), then get queued to SQS for async handlers. The Slack notification runs async - no reason to block the API response waiting for a third-party HTTP call.

The Event

First, we needed an event to carry the change data. The initial version only tracked email changes with a simple class containing the recipient ID, previous email, and new email.

But we expanded it to track all fields. The new RecipientFieldsChanged class takes a recipient ID and an array of changed fields. Each entry in that array uses the field's Swedish label as the key, with the old and new values stored together. This makes the Slack message human-readable without translation lookups at notification time.

Detecting Changes

The existing RecipientListener already hooked into recipient PATCH requests. We added a detectChangedFields method that compares the incoming request content against the entity's current values.

The method defines mappings between request keys, getter methods, and human-readable Swedish labels. For example, the "email" request key maps to the getEmail getter method and the label "E-post". Similarly, "name" maps to getName and "Namn", "contactName" maps to getContactName and "Kontaktperson", and so on for government ID, VAT number, invoice language, e-invoice requirements, and reverse VAT settings.

The method iterates through these mappings, checking if each key exists in the request content. If so, it compares the new value from the request against the old value from the entity. Boolean values get normalized before comparison. When values differ, the field gets added to the changed fields array with both old and new values.

Listener Timing: Why POST_READ WorksRequest Arrives{email: "new@..."}POST_READEntity has OLD valuesDeserializationValues mergedPOST_WRITEEntity has NEW valuesAt POST_READ: $recipient->getEmail() returns OLD value$requestContent['email'] contains NEW value - perfect for comparison

The key insight: we compare against the request content, not the entity state after Doctrine hydration. The listener runs at POST_READ priority - the entity still has its old values.

We also handle nested address fields. If the request contains an address object, the method processes it separately with its own mappings for street name, postal code, city, country, and phone number.

The Slack Client

Following the existing pattern (the codebase already had a MondayClient for another integration), we created a SlackClient service. It takes an HTTP client configured for the Slack API, a logger, the bot token, and the default channel as constructor dependencies.

The sendMessage method first checks if the bot token is empty - if so, it throws a SlackApiDisabledException. Otherwise, it makes a POST request to the Slack chat.postMessage endpoint with the channel, text, and markdown formatting enabled. The response gets checked for the "ok" field; if not present or false, it throws a runtime exception with the error message from Slack.

The SlackApiDisabledException is important - it lets us gracefully disable Slack in environments where it's not configured like local development or test environments. The handler catches it and logs instead of failing. Other exceptions get logged as errors but don't crash the worker.

The Message Handler

The message handler class is decorated with the AsMessageHandler attribute specifying fromTransport as workqueue, ensuring it only runs from the async worker rather than synchronously during the API request.

When invoked with a RecipientFieldsChanged event, it fetches the recipient from the repository using the ID from the event, then gets the owner of that recipient. It builds a Slack message in Swedish with the header "Mottagare uppdaterad" (Recipient updated), followed by the client name (Uppdragsgivare), the user's full name and email (Användare), and then the list of changed fields (Ändrade fält).

For each changed field, it formats the old and new values. A helper method handles formatting: null or empty values become "tom" (empty) in italics, boolean values become "Ja" or "Nej", and everything else gets converted to a string.

Configuration

The HTTP client for Slack is configured in the http_client.yaml file with the base URI set to https://slack.com, appropriate JSON headers, and the Authorization header using the Bearer token from the SLACK_BOT_TOKEN environment variable.

The Messenger routing in messenger.yaml ensures the RecipientFieldsChanged event gets routed to both sync and workqueue transports.

Environment variables come from AWS SSM Parameter Store in production, with the serverless.yml referencing the bot token and default channel from their respective parameter paths.

The Debugging Journey

Deployed to production. Updated a recipient. Nothing happened.

First stop: CloudWatch logs for the worker Lambda.

Debugging the Silent FailureCheck Website LambdaEvent dispatched? ✓Check Worker LambdaHandler executed? ✓Slack API Response"channel_not_found"Solution: Invite the bot to the channel/invite @SlackBot in #recipient-change-notification

The channel ID was correct. The bot token was valid. But Slack apps need explicit channel membership - they can't post to channels they haven't joined.

After inviting the bot, the notifications started working. A typical message shows the header "Mottagare uppdaterad" with the client company name, user's name and email, and then lists each changed field with old and new values, like "Gatuadress: Old Street 1 → New Street 2" and "Postnummer: 111 22 → 333 44".

What We Didn't Break

The implementation reused existing patterns. Important to verify nothing broke:

Existing Flows - UnchangedInvoice Provider SyncUpdateInvoice ProviderCustomerStill dispatched on changesEmail VerificationverificationEmailSentAt = nullStill reset on email changeE-Invoice NotificationRecipientChangedInvoiceSendMethodStill dispatched separatelyAll existing handlers continue to workThe new Slack notification is purely additive - no existing behavior modified

Files Created and Modified

Four new files were created: the SlackClient service, the SlackApiDisabledException class, the RecipientFieldsChanged event class, and the SendSlackNotificationWhenRecipientFieldsChanged handler.

Six existing files were modified: the RecipientListener gained the detectChangedFields method, the http_client.yaml got the Slack API client configuration, messenger.yaml received the event routing, services.yaml got the SlackClient service definition, serverless.yml added the SSM parameter references, and the .env file got local placeholder variables.

Lessons

First, event-driven architecture pays off. Adding a new notification meant creating a handler, not modifying existing code. The listener just dispatches; handlers decide what to do.

Second, graceful degradation matters. The SlackApiDisabledException pattern lets the feature be "off" without breaking anything. Empty token equals no notification, no error.

Third, timing matters in listeners. POST_READ versus POST_WRITE determines what state the entity has. We needed old values for comparison, so POST_READ was correct.

Fourth, Slack bots need explicit access. Even with the right channel ID and valid token, bots can't post where they're not members. The error message "channel_not_found" is misleading - the channel exists, the bot just can't see it.

Fifth, CloudWatch is your friend. The worker Lambda logs showed exactly what failed. Without that visibility, we'd still be guessing.

Now every recipient change triggers a Slack notification. The admin team has visibility they didn't have before - and the implementation follows patterns the codebase already established.