No application is an island, and the ways data crosses the boundary are more numerous than people expect: an API someone calls, a webhook someone posts to, an email a customer replies to, a scheduled job that pulls a file, an agent invoking a tool. Each one is an entry point. Each one needs authentication, validation, logging, and a failure story.

Left to grow organically, each gets its own ad-hoc answer. Tiknix has five integration surfaces, and the useful thing about them is that they share their machinery — the same key table, the same encryption service, the same logs.

Surface 1 — Outbound: connections and connectors

Calling someone else's API means holding their credential. That is handled by the connector registry and the broker model, covered in detail in Third-Party Credentials Without Sprawl: encrypted at rest with libsodium, one OAuth path for every registry-driven connector, tokens held only on the control plane, and instances given revocable broker keys instead of the credentials themselves.

The property to carry forward: a credential lives in exactly one place, and everything else gets a capability.

Surface 2 — Inbound API: keys, scopes, and one auth service

When something calls you, services/ApiAuthService.php is the front door. It reads a bearer token from Authorization (falling back to X-Api-Key), validates the key row, loads the owning member, and bumps usage stats:

$auth = ApiAuthService::authenticate('invoice', 'read');
if (!$auth['success']) {
    Flight::jsonError($auth['error'], 401);
    return;
}
$memberId = $auth['member_id'];

The validation chain is short and complete: token present → key exists and is active → not expired → owner exists → scope permits.

Scopes are checked against a small pattern language — *, <bean>.*, <bean>.<action>, or a bare <action> — and the docblock is candid about the default:

Scope checks are best-effort — an empty/absent scopes list allows all.

Know that. A key created without scopes is a key that can do everything its owner can. The honest documentation of a permissive default is worth more than a stricter default nobody knew about, but set your scopes.

The important structural point is that this mirrors the API key auth in the MCP gateway rather than inventing a second scheme. One apikey table, one revocation flag, one place to see what exists. An agent calling MCP tools and a partner calling your JSON API are managed identically.

Surface 3 — Inbound webhooks: verify, then decide what your status code means

controls/Webhook.php receives inbound mail and delivery events from Mailgun. It is a small file and an unusually good model for webhook handling.

It verifies the signature:

HMAC over (timestamp + token) against [mail].mailgun_signing_key, with a ±5 min freshness window. With no signing key configured we accept unverified but log a warning (dev only).

Signature plus timestamp window is the correct pair — a signature alone permits replay. And the unverified fallback is explicit, logged, and labeled as development-only rather than silently permissive.

And it chooses status codes on purpose:

CodeSituationEffect on the sender
403Signature or auth failureMailgun retries — harmless
200Unknown token or threadMailgun stops retrying — unrecoverable, don't loop
500Storage failureMailgun retries with backoff — transient, try again

This is the detail most webhook endpoints get wrong. A status code is not decoration; it is an instruction to the sender's retry machinery. Return 500 for an unrecoverable condition and you have built a retry storm against yourself. Return 200 for a transient failure and you have silently dropped data.

Every webhook you write should have this table written down. If you cannot say what each code tells the sender to do, you have not finished the endpoint.

Surface 4 — Two-way email as a data channel

services/NotifyService.php is the piece people do not expect: threaded, two-way email as a one-liner.

NotifyService::create()
    ->to('dealer@example.com', 'Dealer Name')
    ->subject('Your Order #42')
    ->relatedTo('order', 42)      // polymorphic link + thread-reuse key
    ->owner($memberId)            // who monitors this thread in the inbox
    ->send('<p>Hello</p>', ['/path/to/invoice.pdf']);

The recipient replies from their own mail client. That reply hits the Mailgun webhook, is matched to the thread by a token baked into the reply address (reply-{token}@domain), and lands back in the app on the same conversation — visible in the in-app inbox at /communications.

Three design choices make this more than a mail wrapper:

  • relatedTo(type, id) is polymorphic. Any domain object attaches to a thread. The comms subsystem never learns what an order is; it owns the channel, and the consuming code owns the meaning.
  • The record is written regardless of delivery. Every send writes an outbound row and reuses a thread even when mail is disabled or demo mode is on. The conversation is complete in-app whether or not an email left the building — so tests and demos run offline, and the audit trail never has holes.
  • Threads have an owner. Root sees everything; everyone else, admins included, sees only threads they own. Customer conversations are private by default rather than by convention.

There is a fourth capability worth noting: lib/PublicLink.php resolves a reply token to its thread, letting you expose a token-gated page with no login at all — an approval screen, a status page, a document view. The link in the email becomes the authentication. Comms deliberately imposes no TTL on those tokens; expiry, if you need it, is your domain's decision (compare against a deadline on the related record).

Surface 5 — Pipelines as the integration glue

Multi-step integrations belong in a pipeline: an http step to call out, a connection step to use stored credentials without ever naming them, a dbquery step to persist, a branch to handle the unhappy path, a notify to tell someone, and a wait for anything asynchronous.

The integration then lives in the repository as JSON — reviewable, diffable, deployable, and revertable — instead of as an external automation nobody can roll back.

What ties them together

ConcernShared mechanism
Who is calling?The apikey table — same rows for MCP and REST
Which credential?connections, encrypted via EncryptionService
Is it authentic?HMAC verification with a freshness window
What happened?mcpusage, mcplog, notify, piperun, Monolog
How do we stop it?One is_active flag per key or connection

That shared machinery is the actual asset. Any single integration is a day of work. Twenty integrations that each invented their own auth, logging, and revocation is a system nobody can audit — and the reason "who has access to our Shopify data?" becomes an unanswerable question at most companies.

The checklist for any new integration

  1. Identity — which key or connection, owned by which member?
  2. Authenticity — signature or token, verified, with a timestamp window.
  3. Authorization — scopes set explicitly, not left empty.
  4. Idempotency — inbound calls will be delivered twice. Design for it.
  5. Status semantics — know what each code tells the sender to do.
  6. Logging — enough to reconstruct a bad day, with a retention policy.
  7. Revocation — one operation, doable in a hurry by someone who is stressed.
Honest notes
  • Empty scopes allow everything. Stated in the source, repeated here because it is the most likely way to over-grant an API key by accident.
  • The comms subsystem is single-tenant by design. The reply-address format maps one token to one thread with no workspace segment. Multi-tenancy would need a wider local-part; the code reserves the slot but does not implement it.
  • Public links have no expiry. Token-gated pages stay reachable until you add your own expiry check against the related record. Anyone with the link has access — treat those URLs as credentials.
  • Mail is Mailgun-specific. Both the mailer and the comms layer assume Mailgun. Another provider means a new driver, not a config change.
  • Webhooks with no signing key are accepted. Verification is skipped with a logged warning if mailgun_signing_key is unset — a development convenience that becomes an unauthenticated write endpoint if it reaches production unconfigured. Check it on deploy.
  • Idempotency is your job. Nothing here deduplicates a redelivered webhook for you. If the same event arriving twice would double-charge someone, that check is yours to write.
  • Retention is unset by default. Log tables grow forever. Decide how long you keep request bodies before the answer is "since launch."