n products × m providers
Duplicated integrations, credentials in every repo, inconsistent retry behaviour, no shared history.
This page is about the system: the problem it solves, the decisions behind it, and the trade-offs each one carried. For who I am and the rest of my work, see buildwithabdallah.com.
I run several products on one server — Kirada, Djib Payroll, SMKit and client work. Each of them needed WhatsApp messaging, and most of them needed subscription billing.
The obvious path was to integrate Meta and Stripe into each product. That path costs the same work n times, scatters production credentials across n codebases, gives every product its own half-built retry logic, and leaves no shared place to answer “what happened to this message?”. Rotating a leaked token would have meant touching every deployment.
So the integration moved up one level. This service owns the provider relationships; products own their own domain. One contract to learn, one place to rotate secrets, one audit trail.
Duplicated integrations, credentials in every repo, inconsistent retry behaviour, no shared history.
One signed API, credentials in a single runtime secret store, uniform delivery guarantees, one audit trail.
Controllers only handle transport concerns. Domain work happens in dedicated classes, and anything that talks to a third party runs on a queue.
AuthenticateConnectedApplication resolves the application by slug, rejects it if disabled, checks the timestamp is within 300 seconds, rejects a reused request ID, verifies the HMAC against the raw body, and applies a per-application rate limit. The nonce is written inside a guarded insert, so two concurrent identical requests cannot both pass.
CreateOutboundWhatsAppMessage checks the product belongs to the calling application, resolves the idempotency key, and enforces the 24-hour customer-service window — free-form text outside it is refused, templates are allowed. Only then is a message row created.
SendWhatsAppMessage runs on its own queue. It returns immediately if the message already has a provider ID, so a retry can never double-send. If live sending is switched off, it records a LIVE_SEND_DISABLED failure rather than reaching the network.
Provider webhooks arrive on their own route, are signature-checked, stored raw, and processed asynchronously into contacts, conversations, messages and statuses. Unknown message and status types are tolerated rather than throwing, so a provider adding a field does not take the pipeline down.
WhatsAppMessageObserver turns a status change into an ApplicationEventDelivery row with a stable ULID, which DispatchApplicationEvent signs and posts to the product's webhook. The event is persisted before it is sent, so delivery can always be retried or replayed.
The same canonical string is used in all three directions, so there is a single rule to audit rather than one scheme per integration.
The signature binds method, path, timestamp, request ID and a SHA-256 of the raw body, compared with hash_equals. Signing a re-encoded body would let a mismatch slip through, so the raw payload is always used.
A 300-second window bounds how long a captured request stays useful, and every request ID is persisted, so even inside that window it only works once.
Phone numbers, WhatsApp IDs, display names and message bodies use authenticated encryption. Because you cannot query ciphertext, each one has a deterministic SHA-256 sibling column that carries the index.
Each product holds a request secret and an event secret. Compromising the one it uses to call in does not let an attacker forge events coming out, and either can be rotated independently from the console.
Structured log lines record message and event IDs, never phone numbers, message text, headers or raw payloads. Raw webhook bodies are redacted after their retention window while the normalised audit trail survives.
The admin panel requires app-based MFA and every policy denies create, update and delete. Anything that mutates state is an explicit console command, which leaves a shell history instead of an anonymous click.
Each of these had a cheaper alternative. These are the ones where the cheaper option would have cost more later.
| Decision | Alternative | Why |
|---|---|---|
| Persist the webhook, then queue | Process inline during the request | Providers retry on timeout. Returning 200 as soon as the payload is durable turns a slow processor into a queue backlog instead of duplicate deliveries. |
| Three separate queues | One default queue | A burst of inbound webhooks would otherwise delay outbound sends and event delivery. Isolation means a stuck lane degrades one workload, not all three. |
| ULIDs as public identifiers | Auto-increment integers | Sequential IDs leak volume and invite enumeration. ULIDs stay sortable by creation time without exposing how much traffic the platform handles. |
| Deterministic hash beside each encrypted column | Plaintext column with an index | Lookups still need an index, but a stolen database dump should not hand over phone numbers. The hash carries the index; the ciphertext carries the value. |
| Idempotency key plus request hash | Idempotency key alone | A retried key with identical content should return the original message. The same key with different content is a caller bug, and gets a 409 rather than a silent surprise. |
| Provider interface with a disabled fallback | Call the Meta client directly | A second provider can be swapped in without touching the tables or the product contract, and it stays hard-disabled so a mistyped env value cannot reroute live traffic. |
| Read-only admin panel | Full CRUD in the panel | Nothing in this data model should be edited by hand. Making the panel observational removes a whole class of accidental production damage. |
| Tests refuse any non-disposable database | Trust the phpunit config | A cached config file silently overrides phpunit.xml, which would point the refresh-database trait at real data. The suite now aborts instead of finding out afterwards. |
A distributed integration fails partially, not cleanly. The design assumes any call can time out after the far side already acted on it.
Each job checks whether its record already shows the work as done — a processed timestamp, a provider message ID, a delivered timestamp — and returns immediately if so.
Five attempts with a 1, 5, 30, 120 second backoff. Enough to survive a provider blip; not so many that a genuinely broken request retries all day.
Every job implements a failure handler that records the code and message back onto the row, so a dead job leaves a diagnosis rather than silence.
Live sending, automatic replies and the fallback provider are three independent flags. A new deployment starts inert and is switched on deliberately.
The documentation covers authentication, signing, every endpoint, the error codes and the receiver contract for events.