← Connector Hub
Developer reference

How Connector Hub actually works

This is the long version. The main page tells you what's built; this page tells you why it's built that way, with the code, the trade-offs, and a couple of things that went wrong along the way so the next person (possibly you, building the Stripe adapter) doesn't trip over the same thing.

01 · The shape of the problem

Why a shared core at all

Picture five Drupal projects, each wiring up one external system: Mollie for payments, an ERP for stock levels, a CRM for leads. Left alone, every one of them reinvents the same five things:

Connector Hub is the plumbing, built once. A connector endpoint is a connection: which external system, which secret, whether it talks outbound, inbound, or both. A Connector plugin is the one small piece that actually knows that system's dialect. Everything else – the queue, the retries, the webhook route, the rate limiting – is shared.

Payments are the first thing built on top of that core, which is why they live in their own module, connector_hub_payment, instead of inside the core itself. The core has never heard of a payment, a refund, or a euro, and it never will – see below for why that boundary is enforced on purpose.

A second kind of thing got pulled out once a second domain existed: how a connector authenticates. Every payment adapter had been resolving one static secret through the Key module, which was fine until Google needed OAuth2 – a real protocol with a browser consent step, a refresh token, and an ID token to verify, not a string to compare. Rather than build that straight into a Google-specific class, it became its own layer, connector_hub_auth, that any connector – payment, Google, or whatever comes next – can use. See below for the actual shape of that decision.

connector_hub connector_hub_auth connector_hub_payment connector_hub_payment_mollie connector_hub_payment_stripe connector_hub_payment_multisafepay connector_hub_payment_buckaroo connector_hub_google connector_hub_google_login connector_hub_google_calendar
02 · The foundation

Connector Hub: connections, plugins, and the webhook route

The connection: ConnectorEndpoint

A ConnectorEndpoint is a config entity – one row of configuration, not a database record of events. It holds everything generic about one connection:

FieldWhat it's for
plugin_idwhich Connector plugin speaks for this connection
key_ida reference into the Key module – never the secret itself
outbound / inboundwhich directions this connection is allowed to use
inbound_auth_typehmac or bearer_token – the generic default scheme, ignored by plugins with their own
webhook_tokena random path segment, so the endpoint's machine name alone can't be used to probe for a live webhook URL
settingswhatever the specific plugin needs beyond the above

You can create several endpoints with the same plugin_id – that's how one site runs two Mollie profiles, or a test and a live connection side by side, without the core needing to know anything about "profiles".

Why a config entity, not a content entity

A connection is configuration – it belongs in config export, moves between environments the normal Drupal way, and there's no reasonable number of them on a given site. The moment something stops being "configuration" and starts being "something that happened" – a payment, a delivery attempt – it moves to a content entity instead. That's exactly the line between this module and connector_hub_payment's PaymentOperation.

The plugin contract: ConnectorInterface

Every Connector plugin implements four methods. The first three are about outbound/inbound capability; the fourth is the one every new adapter actually has to think hard about.

interface ConnectorInterface extends ConfigurableInterface, PluginFormInterface {
  // Send $payload to the external system. Must never throw for an
  // ordinary failure (timeout, 4xx/5xx) - return a failure result
  // instead, so the dispatcher can log and retry consistently.
  public function send(array $payload, ConnectorEndpointInterface $endpoint): ConnectorResponse;

  public function supportsOutbound(): bool;
  public function supportsInbound(): bool;

  // Decides whether an inbound webhook call is who it claims to be.
  // The core controller handles rate limiting and the path token;
  // this is the one place that knows the provider's real scheme.
  public function verifyInboundRequest(Request $request, string $rawBody, ConnectorEndpointInterface $endpoint): bool;
}

A new plugin is a PHP attribute on a class that extends ConnectorPluginBase (which wires up the HTTP client, a logger, and the Key repository for you):

#[Connector(
    id: 'mollie',
    label: new TranslatableMarkup('Mollie'),
    supportsOutbound: FALSE,
    supportsInbound: TRUE,
)]
class MollieConnector extends ConnectorPluginBase implements PaymentInitiatorInterface /* , ... */ {

Secrets: always through the Key module

No plugin ever sees a raw secret sitting in configuration. ConnectorPluginBase::getSecret() resolves it on demand from the Key module, and that's the only path in:

protected function getSecret(ConnectorEndpointInterface $endpoint): ?string {
  $keyId = $endpoint->getKeyId();
  if ($keyId === NULL) { return NULL; }
  $key = $this->keyRepository->getKey($keyId);
  return $key?->getKeyValue();
}

Any place that compares a secret or a signature uses hash_equals(), never ===, so a comparison can't leak timing information about how much of the secret an attacker already guessed right.

Outbound: the dispatcher

For a plugin with supportsOutbound: TRUE, other code calls ConnectorDispatcher rather than the plugin directly:

// Fire-and-forget, queued, retried automatically on failure:
$dispatcher->dispatch('my_endpoint', ['foo' => 'bar']);

// Needed the result right now (e.g. to build a redirect URL):
$response = $dispatcher->sendNow('my_endpoint', ['foo' => 'bar']);

This is the right fit for "post this JSON somewhere" integrations. Mollie doesn't use it at all – see the Mollie section for why a provider with real, structured operations (start a payment, read a status, issue a refund) skips the generic outbound path entirely.

Inbound: the webhook route, step by step

Every endpoint with inbound: TRUE gets the same route: /connector-hub/webhook/{connector_endpoint}/{token}. A request hitting it goes through the same four gates for every plugin, in this order:

1

Rate limiting

Twenty failed attempts per endpoint+IP per hour, tracked by Drupal's flood service, before anything else is even looked at.

2

The endpoint must be enabled and inbound-active

A disabled or outbound-only endpoint is rejected before its plugin is even instantiated.

3

The path token

Compared with hash_equals(). This is not the authentication – it's what stops someone from finding a live webhook URL just by guessing an endpoint's machine name.

4

The plugin's own verifyInboundRequest()

HMAC, bearer token, a provider-specific signature header, or – for Mollie's classic webhook – an honest "there's nothing to check, go re-fetch".

Only after all four, and a deduplication check, does the controller durably queue the call for later processing - it does not dispatch ConnectorInboundReceivedEvent itself any more; see "Verify, queue, process" below for why and how. The controller itself never creates an entity or calls business logic either way; that's always a subscriber's job in whichever module cares, once the event actually fires.

Lesson learned, the hard way

While wiring up the Mollie webhook subscriber, the very first version simply didn't fire. Not an exception – just silence. The cause: getSubscribedEvents() was keyed on the event's ::NAME string constant, but every dispatch in this codebase calls $eventDispatcher->dispatch(new SomeEvent(...)) with no second argument – which means Symfony routes it by the event's class, not by any string it happens to carry. The NAME constants on these events are informational only. Key your listener on SomeEvent::class, always:

public static function getSubscribedEvents(): array {
  return [ConnectorInboundReceivedEvent::class => 'onInboundReceived'];
}

EndpointHealthTracker: the admin dashboard, and what actually counts as a failure

The endpoint list at /admin/config/services/connector-hub has two more columns now: Last sync and Errors. Both read from EndpointHealthTracker – deliberately Drupal's State API, not a field on ConnectorEndpoint itself: that entity stays config-entity-pure on purpose, and writing a sync timestamp onto it would mean every delivery mutates config. This is derived, disposable-if-lost data, the same category core's own "last cron run" lives in.

Recording a success is the easy part – the interesting design question was what honestly counts as a failure, since over-counting makes a perfectly healthy integration look constantly broken.

Not every rejection is a failure

A webhook request can be rejected for being flood-blocked, hitting a disabled endpoint, carrying the wrong path token, or being oversized – none of those say anything about whether this specific, correctly-configured connection is working. They're either deliberate config state or unauthenticated noise hitting a guessable-but-unverified URL. Only two rejection reasons get recorded as a real failure: the plugin's own verifyInboundRequest() returning false (a signature/secret mismatch on an already-identified, enabled, correctly-tokened endpoint), and a valid-signature body that still fails to parse as JSON (the real, authenticated provider sending something this adapter can't read). Everything upstream of signature verification is noise by construction; everything after it is signal.

The other real design wrinkle: none of the four shipped payment adapters route through the generic outbound dispatcher at all – each calls its provider's API directly inside initiatePayment()/readStatus()/refund(). So two separate call sites report outbound health: ConnectorOutboundWorker for the generic dispatcher/queue path (today, only the generic_rest example uses it), and a new pair of protected helpers on ConnectorPluginBase itself – recordOutboundSuccess()/recordOutboundFailure() – that every payment adapter now calls at each of its own provider-call exit points:

try {
  $session = $stripe->checkout->sessions->create([/* ... */]);
}
catch (ApiErrorException $e) {
  $this->recordOutboundFailure($endpoint);
  throw new PaymentOperationException('Stripe checkout session creation failed: ' . $e->getMessage(), 0, $e);
}
$this->recordOutboundSuccess($endpoint);

direction ('inbound'/'outbound') is deliberately not the same vocabulary as supportsOutbound()/supportsInbound() – those describe plugin capability, this describes which way one specific call actually went. Every payment adapter has supportsOutbound() === FALSE (so ConnectorDispatcher never routes to it) and still reports real outbound activity here, through the other path.

Verify, queue, process: webhooks that survive a crash

The webhook route used to verify a call and dispatch ConnectorInboundReceivedEvent inline, in the same HTTP request. Two real problems with that: a crash between verification and a subscriber finishing lost the event outright, and a slow or failing subscriber held the provider's own HTTP request open for however long its timeout allowed. ConnectorWebhookController::receive() now only verifies and durably enqueues; a new WebhookProcessingWorker – the exact same shape as ConnectorOutboundWorker, just facing inward – is what actually dispatches the event, on a cron run, with retries if a subscriber throws.

// receive(), after verifyInboundRequest() has already accepted the call:
$eventIdentity = $plugin->extractEventIdentity($payload, $request) ?? hash('sha256', $rawBody);
$isNewEvent = $this->keyValueExpirableFactory->get('connector_hub_webhook_dedup')
  ->setWithExpireIfNotExists($endpoint->id() . ':' . $eventIdentity, time(), 604800);

if (!$isNewEvent) {
  return new JsonResponse(['status' => 'already_processed'], 200); // not 202 - don't retry
}
$this->queueFactory->get(WebhookProcessingWorker::QUEUE_NAME)->createItem([/* ... */]);
return new JsonResponse(['status' => 'received'], 202);
A dedup key core has no business inventing

setWithExpireIfNotExists() is the one call doing the real work – a single atomic "was this the first time" check against Drupal's own expirable key/value store, with cleanup built in for free (the entry just expires after 7 days; no cron job needed to prune it). The harder question was what to check it against. A new, optional ConnectorInterface::extractEventIdentity() defaults to NULL – core genuinely doesn't know what "the event ID" means for any given provider, and isn't allowed to guess. Stripe's adapter overrides it with the event's own id, because that field is both unique per event and stable across a redelivery of that same event. Nothing else does, on purpose: Mollie/MultiSafepay/Buckaroo's webhooks only carry a payment ID, which legitimately repeats across several different, real notifications over that payment's lifecycle – using it as a dedup key would make the second genuine notification silently disappear. When a plugin doesn't override the method, the controller falls back to hashing the raw body instead, which still catches the one case every provider's docs warn about (an exact-duplicate redelivery) without core ever needing to know anything provider-specific.

Retry and give-up mirror ConnectorOutboundWorker exactly: up to 5 attempts, then a new ConnectorInboundProcessingFailedEvent (mirroring ConnectorDeliveryFailedEvent) for real dead-letter visibility, via an event rather than a parallel table. The reconstructed Request the worker builds for the subscriber is deliberately minimal – just the parsed body params captured at verification time, enough for MollieWebhookSubscriber's form-encoded read, not a faithful copy of headers or anything else about the original call.

03 · The first real domain

Connector Hub Payment: amounts, operations, and the idempotency guarantee

This module knows nothing about Mollie, Stripe, or any other provider. It knows what a payment operation is, independent of who's processing it, and it gives every future adapter the same three things to build on.

Money: integer minor units, never a float

A float can't represent most decimal amounts exactly – and that's exactly the kind of rounding error that must never happen with money. Money stores an integer, a currency code, and the decimal exponent that tells you how to read the integer back:

$amount = Money::fromDecimalString('10.00', 'EUR'); // -> 1000 minor units
$amount->toDecimalString(); // -> '10.00'

The exponent is explicit rather than looked up, because providers don't agree on every currency's exceptions – an adapter converts at its own boundary, on purpose.

One record per thing that happened

PaymentOperation is a content entity: one row per initiation, capture, cancel, or refund attempt – never one row trying to represent a purchase's entire history. A payment can sit at Succeeded while a refund against it is still Processing, because they're different rows with different statuses. PaymentStatus is the normalized status every adapter maps onto; the provider's own raw code is kept alongside it, verbatim, for when you need to debug what actually happened.

StatusMeaning
Pendingregistered, nothing has happened yet
Processingunder way on the provider's side
RequiresActionthe customer still has to do something
Succeeded / Failed / Cancelled / Expiredfinal – isTerminal() returns true
Unknownthe provider's code didn't map to anything above – never silently guessed as Succeeded

OperationStore: idempotency that's actually enforced

Before any outbound call to a provider, code registers the operation first:

$operation = $operationStore->register(
  idempotencyKey: 'order-123-refund-1',
  requestFingerprint: $fingerprint,
  endpoint: $endpoint,
  type: OperationType::Refund,
  amount: $amount,
  businessReference: 'order-123',
);

A retry of the same logical operation reuses the key and gets the same record back; a genuinely new operation (a second, deliberate partial refund) gets a new key. The interesting part isn't the lookup – it's what happens when two requests race past that lookup at the same instant.

The actual guarantee, not the aspirational one

idempotency_key carries a real database-level unique constraint, added through a SqlContentEntityStorageSchema override (base field definitions have no declarative way to ask for one). So when two requests both pass the lookup – because neither has saved yet – only one of their save() calls actually succeeds. The database rejects the second with an IntegrityConstraintViolationException, which Drupal's entity storage re-wraps as EntityStorageException. register() catches exactly that, and loads the winner's record instead of creating a duplicate or crashing. This is covered by a test that bypasses OperationStore entirely and saves two conflicting entities directly through storage, specifically to prove the constraint is real and not just a story the code tells about itself.

The database constraint protects one thing, not two

It stops a double local record. It does nothing to stop a network-timeout retry from creating a second, real payment at the provider – your server loses the connection after Stripe already created the session, retries the same registered operation, and now there are two sessions. Mollie's SDK exposes a one-shot setIdempotencyKey(); Stripe's create() calls take a second $opts array with an idempotency_key entry mapping to the Idempotency-Key header. Both adapters now pass the operation's own idempotency key through, every time. MultiSafepay is assumed to never need this fix, since its order_id is client-chosen and already set to the same key - but whether MultiSafepay's API actually treats a repeated order_id as idempotent hasn't been confirmed against a real sandbox, so this is written down as an assumption, not a settled fact (see ROADMAP.md).

The companion method, attachRemoteReferences(), is the one place an adapter writes a provider's reference (a payment or refund ID) back onto the operation – optionally also setting primary_reference, an indexed column used for exactly one thing: letting an inbound webhook handler find the right operation when all it has is the provider's own opaque ID.

$operationStore->attachRemoteReferences(
  $operation,
  RemoteReferences::create()->withReference('payment_id', $payment->id),
  primaryReference: $payment->id,
);
// later, from a webhook handler that only has an opaque provider id:
$operation = $operationStore->findByPrimaryReference($paymentId);

The three capability interfaces

Rather than one bloated contract every adapter has to fully implement, there are three small ones. An adapter implements only what it can actually do, and the orchestration layer checks with instanceof:

interface PaymentInitiatorInterface {
  public function initiatePayment(PaymentOperationInterface $operation, ConnectorEndpointInterface $endpoint, string $returnUrl): PaymentInitiationResult;
}
interface PaymentStatusReaderInterface {
  public function readStatus(PaymentOperationInterface $operation, ConnectorEndpointInterface $endpoint): PaymentStatus;
}
interface RefundProviderInterface {
  public function refund(PaymentOperationInterface $originalPayment, PaymentOperationInterface $refundOperation, ConnectorEndpointInterface $endpoint): void;
}

Notice all three take an already-registered operation rather than a raw amount. That's a deliberate shape, not a default: the caller registers first (so the idempotency key exists before any network call happens), then hands the operation to the adapter, which reads what it needs off it and reports back through OperationStore – never by handing a record back for the caller to remember to save.

Why this changed after Mollie, not before

The first version of these two interfaces took a raw Money and a business reference instead of the operation, and had no way to get a provider reference back at all. Nobody had built a real adapter yet, so nobody had noticed. Building Mollie first and adjusting the contract from what it actually needed – rather than guessing upfront – is exactly why the roadmap always planned a second, differently-shaped provider (Stripe) before calling the contract settled.

PaymentStatusChangedEvent: one source of truth for "it changed"

OperationStore::updateStatus() is the only place that writes a new domain status, and it only dispatches PaymentStatusChangedEvent when the status actually moved. Fulfillment logic subscribes to that event – never to a return URL, and never to a webhook payload directly – because by the time this event fires, the status has already been confirmed through an authenticated read.

It also refuses one specific transition: moving a terminal status (Succeeded / Failed / Cancelled / Expired) back to a non-terminal one.

A late webhook can't undo a confirmed success

Two notifications for the same operation can arrive out of order: a final Succeeded, then a delayed, already-stale Processing that was actually sent first. For an adapter that trusts a verified webhook payload directly – Stripe, MultiSafepay, Buckaroo, none of which re-fetch – nothing stopped the second call from quietly dragging a finished payment back to "still processing". updateStatus() now checks $previousStatus->isTerminal() before applying a non-terminal $newStatus, logs a warning, and returns the operation unchanged instead. A terminal status is still free to move to a different terminal one – a late dispute turning a success into a failure, say – only backsliding into a non-terminal state is blocked. The check is provider-agnostic, in OperationStore itself, so every adapter gets it the same way rather than reimplementing it per webhook subscriber.

A real race, closed with Drupal's own lock service

The terminal-status guard above protects against a stale update. It does nothing against a genuinely concurrent one: two processes - a webhook racing a reconciliation read, say - each loading their own copy of the same operation, each deciding against their own now-outdated in-memory status, each calling save(). The second save wins outright, silently overwriting whatever the first one just wrote, because Drupal's entity save() writes the whole object, not a field-level diff.

A field that admitted it wasn't finished yet

PaymentOperation already had a lock_version field, incremented on every save - but its own docblock said outright: "Phase-1 scaffolding only: nothing enforces a compare-and-swap on this yet... treat concurrent updates as unsafe until that lands." updateStatus() and attachRemoteReferences() now both run their entire read-decide-write cycle inside a lock acquired through Drupal's own lock service (DB-backed via the core semaphore table - no new dependency), and inside that lock, every field on the caller's own operation object is refreshed in place from a fresh storage read before anything is decided:

protected function withOperationLock(PaymentOperationInterface $operation, callable $callback): PaymentOperationInterface {
  $lockName = 'connector_hub_payment:operation:' . $operation->id();
  if (!$this->lock->acquire($lockName, 10.0)) {
    $this->lock->wait($lockName, 5);
    if (!$this->lock->acquire($lockName, 10.0)) {
      throw new PaymentOperationException(/* ... */);
    }
  }
  // reload fresh, sync every field onto $operation in place, THEN:
  return $callback($operation);
}

Refreshing the caller's own object in place, rather than handing the callback a different object, matters: the rest of the codebase already relies on updateStatus($operation, ...) mutating $operation itself, not just its own return value. Returning a different object would have fixed the race while silently breaking every caller that keeps using its own reference afterward - caught before release, not after.

Reconciliation: what happens when the webhook never comes

Every adapter so far assumes a webhook arrives. Sometimes it doesn't - lost in transit, or the endpoint was briefly misconfigured when it tried. ReconciliationWorker is the fallback, built the same way connector_hub's own ConnectorOutboundWorker already is: a cron-driven queue worker, not a new paradigm.

#[QueueWorker(
    id: 'connector_hub_payment_reconciliation',
    cron: ['time' => 60],
)]
class ReconciliationWorker extends QueueWorkerBase {}

connector_hub_payment_cron() finds candidates via a new OperationStore::findStaleNonTerminalOperations() - non-terminal, older than 15 minutes, capped at 50 per run - and enqueues their IDs. The worker itself has no batching logic at all: it loads one operation, resolves its endpoint's plugin, skips anything that isn't a PaymentStatusReaderInterface, and calls readStatus() - the exact same re-check MollieWebhookSubscriber already does for every classic webhook ping.

No retry bookkeeping, on purpose

ConnectorOutboundWorker tracks attempts and gives up after five, because a single outbound delivery has a natural end state. Reconciliation doesn't: a failed provider call here just throws, and Drupal's own cron queue runner leaves the item for the next cycle - no custom counting needed, because the moment an operation actually resolves to a terminal status, it simply stops being found by the query that feeds this queue in the first place.

Two safeguards were added once "occasional duplicate, harmless" stopped being good enough on its own - both reuse something that already existed rather than adding new bookkeeping:

04 · The first real adapter

Mollie: hosted checkout, and the honest way to handle an unsigned webhook

The first of two adapters covered here - Stripe, deliberately built to not share its shape, is next.

Mollie supports two very different flavours of integration. This adapter deliberately picks the simpler, more current one of each pair:

Setting it up

1

Create a Mollie account

Free, and test mode works immediately. mollie.com/signup

2

Grab a test API key

Dashboard › Developers › API keys (or API access tokens, depending on your dashboard version). It starts with test_. Mollie's guide

3

Store it and connect it

A Key (Configuration › Key) holding the value, then a Connector endpoint (Configuration › Web services › Connector Hub) with plugin Mollie and that Key, inbound enabled.

4

Call it

initiatePayment(), below.

A payment, start to finish

This is the part worth actually understanding, because it's the pattern every future provider will roughly follow:

1

Register, then initiate

The caller registers an operation with an idempotency key, then calls MollieConnector::initiatePayment($operation, $endpoint, $returnUrl).

2

Mollie creates the payment

The adapter builds a webhookUrl from the endpoint's own webhook route, sends amount, description, redirectUrl (your $returnUrl) and that webhookUrl to Mollie, and stores the returned payment ID via attachRemoteReferences().

3

The customer pays

Redirected to the checkout URL the adapter handed back as a PaymentInitiationResult::redirect().

4

Mollie pings the webhook

With nothing but the payment ID, unsigned. verifyInboundRequest() accepts it unconditionally – there's genuinely nothing to check – and MollieWebhookSubscriber looks up the operation by that ID via findByPrimaryReference().

5

The subscriber re-fetches, never trusts

It calls readStatus(), which makes its own authenticated call back to Mollie's API, and only that answer is trusted. If the status actually changed, updateStatus() fires PaymentStatusChangedEvent.

Step 5 is the whole point of this design. A classic Mollie webhook is just a doorbell – it tells you someone's there, not who. Treating it as proof of payment would mean anyone who can guess or intercept a payment ID could ring that bell and have your site believe the order was paid. Re-fetching through an authenticated API call is what actually closes that hole.

Status mapping

Mollie statusMaps to
openPending
pendingProcessing
authorizedProcessing — see below
paidSucceeded
canceledCancelled
expiredExpired
failedFailed
anything elseUnknown

authorized only happens for methods with a separate capture step – cards, Klarna, Billie, Riverty – and this adapter doesn't build an explicit capture flow yet. Cards settle through Mollie's own default auto-capture behavior instead. A deliberate, delayed capture is a real gap, tracked on the roadmap, not something quietly papered over.

Refunds

Same registration-first pattern, mirrored:

$refundOperation = $operationStore->register(
  idempotencyKey: 'order-123-refund-1', /* ... */
  type: OperationType::Refund,
  amount: Money::fromDecimalString('2.00', 'EUR'),
  /* ... */
);
$mollieConnector->refund($originalPayment, $refundOperation, $endpoint);
// the refund's provider reference and its immediate status are
// already attached - there's nothing left for the caller to save
05 · The adapter built to not be Mollie-shaped

Stripe: a real signature, and a second secret

Stripe was deliberately built second, specifically to find out which parts of the payment contract were secretly assumptions baked in from Mollie alone. It found two real ones, both resolved without touching connector_hub's core:

The second secret, solved without a core change

The API secret key stays exactly where every plugin's main secret lives: the endpoint's own "Secret (Key)" field, resolved through the inherited getSecret(). The webhook signing secret becomes a plugin-level setting instead - a second key_select field Stripe's own buildConfigurationForm() adds, stored in the endpoint's generic settings blob exactly like generic_rest's uri/method fields already are:

public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
  $form['webhook_secret_key_id'] = [
    '#type' => 'key_select',
    '#title' => $this->t('Webhook signing secret'),
  ];
  return $form;
}

Resolved with its own small method, deliberately not reusing getSecret() (which resolves the endpoint's key_id - a different value here):

protected function getWebhookSecret(): ?string {
  $keyId = $this->configuration['webhook_secret_key_id'] ?? NULL;
  if (empty($keyId)) { return NULL; }
  return $this->keyRepository->getKey($keyId)?->getKeyValue();
}
One thing this needed that Mollie's didn't

Drupal's config schema is strict: every key a plugin writes into an endpoint's settings needs a matching schema entry, or saving (and testing) throws a SchemaIncompleteException. Mollie never hit this because it has no settings of its own - its configuration is empty, which trivially satisfies the default, keyless schema. Stripe's submodule ships its own config/schema/connector_hub_payment_stripe.schema.yml declaring connector_hub.connector_plugin.stripe, the same way connector_hub itself declares one for generic_rest. Any adapter that adds its own settings needs to do the same - caught by running the Kernel tests for real, not by reading the code back.

Checkout, the hosted way

A Checkout Session (mode: payment), with the amount built inline via price_data rather than a pre-created Stripe Price - every operation has its own Money, so there's nothing to pre-create:

$session = $stripe->checkout->sessions->create([
  'mode' => 'payment',
  'success_url' => $returnUrl,
  'cancel_url' => $cancelUrl ?? $returnUrl, // a real field Stripe's API has - now actually used
  'line_items' => [[
    'price_data' => [
      'currency' => strtolower($amount->currencyCode),
      'unit_amount' => $amount->minorUnits,
      'product_data' => ['name' => $operation->getBusinessReference()],
    ],
    'quantity' => 1,
  ]],
]);

The Session ID (cs_...) becomes the primary reference immediately - it exists the instant the session is created. The PaymentIntent ID doesn't: it's null on a fresh session and only appears once the customer actually completes checkout, so it gets attached later, from the webhook.

A real field, finally used for what it's for

Stripe's cancel_url isn't a guess or a workaround - it's a documented field on Checkout Sessions, same as success_url, that this adapter simply reused the wrong value for until PaymentInitiatorInterface::initiatePayment() gained an optional, trailing $cancelUrl parameter. Backward compatible on purpose: every existing 3-argument call keeps working, defaulting to $returnUrl, exactly like before. MultiSafepay and Buckaroo had the same gap in their own request bodies (payment_options.cancel_url, ReturnURLCancel) - Mollie is the one adapter with genuinely nothing to point this at, since its Payments API has no second URL concept at all.

Trust the signature - don't re-fetch anyway

This is the actual, deliberate difference from Mollie, not a smaller version of the same idea. verifyInboundRequest() calls Stripe's own Webhook::constructEvent(), which recomputes the HMAC-SHA256 over {timestamp}.{raw body} and compares it to the Stripe-Signature header:

public function verifyInboundRequest(Request $request, string $rawBody, ConnectorEndpointInterface $endpoint): bool {
  $secret = $this->getWebhookSecret();
  if ($secret === NULL) { return FALSE; } // fail closed
  try {
    Webhook::constructEvent($rawBody, (string) $request->headers->get('Stripe-Signature'), $secret);
  }
  catch (SignatureVerificationException | \UnexpectedValueException $e) {
    return FALSE;
  }
  return TRUE;
}

Once that has passed, the payload is authentic - Stripe's own cryptographic promise, not an assumption this module is making. So StripeWebhookSubscriber reads the status straight out of the verified event instead of calling back out to the API:

// $event->payload is the core controller's JSON-decoded body - Stripe
// sends application/json, unlike Mollie's form-encoded classic webhook.
$type = $event->payload['type'] ?? NULL;
if (!in_array($type, ['checkout.session.completed', 'checkout.session.expired'], TRUE)) {
  return; // Stripe sends many event types to one endpoint; only two matter here
}
$session = $event->payload['data']['object'];
$status = StripeConnector::mapCheckoutSessionStatus($session['status'], $session['payment_status']);
// no API call anywhere above this line

readStatus() still exists and still makes a real call - for reconciliation or manual polling, independent of whatever the webhook has or hasn't delivered yet. The webhook path just doesn't need it.

Status mapping

Session statuspayment_statusMaps to
openanyPending
expiredanyExpired
completepaid / no_payment_requiredSucceeded
completeunpaidProcessing — async method still settling
anything elseUnknown

Refunds

Needs the PaymentIntent ID, not the Session ID - thrown as a clear error if it isn't there yet (a refund only makes sense once a payment has actually gone through):

$refund = $stripe->refunds->create([
  'payment_intent' => $paymentIntentId,
  'amount' => $refundOperation->getAmount()->minorUnits,
]);

Refund status maps the same way as Mollie's, just with Stripe's own vocabulary: pending→Processing, requires_action→RequiresAction, succeeded→Succeeded, failed→Failed, canceled→Cancelled.

One more thing worth knowing: no constructor hook for testing

Mollie's SDK accepts a Guzzle client directly, so its tests swap Drupal's own http_client service. Stripe's StripeClient has no such hook at all - confirmed by reading what config keys BaseStripeClient actually recognises, not assumed from the docs. Its tests instead swap the SDK's own global \Stripe\ApiRequestor::setHttpClient(), and restore the real client in tearDown() - that state is static and process-wide, so leaving it swapped would leak into whichever test happens to run next.

06 · No SDK, a client-generated ID, and a real need for customer data

MultiSafepay: back to plain REST, with its own shape again

Three adapters in, and MultiSafepay still doesn't repeat the previous two. No SDK worth building against this time, an order ID you choose instead of one the provider hands back, and the first genuine need for customer data this whole module has seen.

Why there's no SDK here

Mollie and Stripe both had mature, well-documented PHP SDKs worth building against. MultiSafepay's official package couldn't be verified in enough usable detail to build against with any confidence - its own usage examples simply didn't surface. Its REST API, though, is plain JSON over HTTPS with query-string API-key auth - simple enough to call directly through the Guzzle client every ConnectorPluginBase subclass already has, the same way generic_rest does. One real upside: its Kernel tests use the familiar MockHandler swap, not a special global-state workaround like Stripe's.

An order ID you choose, not one you're given

Mollie hands back tr_..., Stripe hands back cs_... - both only exist once the provider has created something. MultiSafepay's order_id is the opposite: a required field you send when creating the order. This adapter uses the operation's own idempotency key for it, which means the primary reference is known before the API call is even made, not learned from its response:

$orderId = $operation->getIdempotencyKey(); // known immediately
// ... the create-order call uses $orderId in its body ...
$operationStore->attachRemoteReferences($operation, RemoteReferences::create()->withReference('order_id', $orderId), $orderId);
// still only attached once the call actually succeeds - attachRemoteReferences()
// keeps meaning "a confirmed round trip happened", even though the value itself
// didn't come from the response this time.

Refunds get their own refund_order_id the same way, from the refund operation's own idempotency key - not left to default to the original order's ID, which would collide with this adapter's own primary-reference lookups.

Signed, like Stripe - but with no second secret

The Auth header is base64 of {timestamp}:{signature}; the signature is HMAC-SHA512 over {timestamp}:{raw body}. The interesting part is the key: it's the same API key already resolved through getSecret() for the API calls themselves - no separate webhook secret to configure, unlike Stripe. Once verified (plus a timestamp-tolerance check against replay), MultisafepayWebhookSubscriber trusts the notification payload directly, the same model as Stripe's:

public function verifyInboundRequest(Request $request, string $rawBody, ConnectorEndpointInterface $endpoint): bool {
  $secret = $this->getSecret($endpoint); // the same secret, not a second one
  [$timestamp, $signature] = explode(':', base64_decode((string) $request->headers->get('Auth')), 2);
  if (abs(time() - (int) $timestamp) > 300) { return FALSE; } // replay guard
  return hash_equals(hash_hmac('sha512', "$timestamp:$rawBody", $secret), $signature);
}
A deliberate omission

MultiSafepay's own docs also suggest checking that the request's source IP is on its allowlist. This adapter doesn't implement that - a hardcoded, self-maintained IP list only ever goes stale, and the signature is already the real security boundary. If that extra layer matters for a given deployment, it belongs at the webserver or firewall, not baked into the plugin.

The first real need for customer data

MultiSafepay's create-order API expects a top-level customer object, and several payment methods (Bank Transfer, Trustly, cards, PayPal) require specific fields on it. Neither Mollie nor Stripe ever needed anything like this.

The first version of this got it wrong

The obvious move - add ?CustomerDetails $customer = NULL straight onto PaymentInitiatorInterface::initiatePayment() - shipped first, then got caught: it meant Mollie's and Stripe's own implementations carried a parameter they silently ignored. "Fillable but does nothing" is exactly the kind of interface smell this module's own three small, separate capability interfaces (PaymentInitiatorInterface/PaymentStatusReaderInterface/RefundProviderInterface) already exist to avoid. The fix was to apply that same pattern one level deeper instead of bolting onto the shared one.

A fourth small interface, implemented only where it's actually needed:

interface RequiresCustomerDetailsInterface {
  public function setCustomerDetails(?CustomerDetails $customer): void;
}

Only MultisafepayConnector implements it. The orchestration layer checks for it the same way it already checks for the other capability interfaces, and calls it before initiatePayment():

if ($plugin instanceof RequiresCustomerDetailsInterface) {
  $plugin->setCustomerDetails($customerDetails);
}
$result = $plugin->initiatePayment($operation, $endpoint, $returnUrl);
// Mollie/Stripe: that instanceof check is simply false, nothing happens
// to their call at all - not a parameter they receive and discard.

CustomerDetails itself is a small, all-nullable value object (email, name, locale, IP, country), held as ordinary mutable state on the plugin instance between the two calls - which only works because a plugin instance is created fresh per payment attempt, never shared across unrelated operations.

Honest about what's still open

Exactly which customer sub-fields are required depends on the payment method the customer ends up picking on MultiSafepay's own hosted page - this adapter's own documentation could only confirm the requirement clearly on a single-gateway reference page, which may not generalise to a gateway-less redirect order showing every method at once. It always sends whatever CustomerDetails it's given, defaulting anything missing to an empty string rather than omitting the key, and logs a warning when a field required for at least one common method is missing - but the exact per-method requirement is a tracked, open item in ROADMAP.md, to confirm against a live sandbox, not something this page pretends is settled.

07 · Two signing schemes that share nothing but a secret

Buckaroo: the most unusual shape yet

Fourth adapter, fourth new wrinkle. Buckaroo's outbound requests and its inbound push notification aren't two variations of the same idea - they're two unrelated schemes that happen to both exist on the same provider.

Outbound: fully specified, nothing to guess

Confirmed directly against docs.buckaroo.io/docs/json-authenticator: the Authorization header is hmac {websiteKey}:{signature}:{nonce}:{timestamp}, where the signature is HMAC-SHA256 over websiteKey + METHOD + uri + timestamp + nonce + contentHash - the secret key is deliberately not part of that string, only used as the HMAC key itself:

$signingString = $websiteKey . strtoupper($method) . $encodedUri . $timestamp . $nonce . $contentHash;
$signature = base64_encode(hash_hmac('sha256', $signingString, $secretKey, TRUE));

No SDK here either, same reasoning as MultiSafepay: the algorithm above is fully specified by Buckaroo's own docs, simple enough to implement directly, and a third-party package's quality couldn't be verified in enough depth to trust blindly.

Inbound: a completely different, older scheme

The push notification is a classic, form-encoded POST with brq_-prefixed fields - not JSON, so $event->payload is NULL for it, the same situation Mollie's webhook is already in. It carries its own brq_signature, computed by a plain hash (not HMAC): sort every brq_/add_/cust_ field (except brq_signature) alphabetically, concatenate as name=value with no separators, append the secret key, hash, hex-encode:

$concatenated = '';
foreach ($sortedFields as $key => $value) {
  $concatenated .= $key . '=' . $value;
}
$concatenated .= $secretKey;
$expected = hash($algorithm, $concatenated); // hex, not base64
The hash algorithm is a setting, not a constant

Buckaroo's own docs say only "the selected SHA algorithm" for the push signature and never name one universal default - checked against three separate official/semi-official sources, all in agreement that it's merchant-configurable in Buckaroo Plaza. This adapter adds a signature_algorithm setting (sha1/sha256/sha512) rather than guessing one and hoping - whoever configures an endpoint has to set it to match whatever that specific Buckaroo Plaza account actually uses, or every push silently fails verification.

Once verified, BuckarooWebhookSubscriber trusts it directly - the same model as Stripe and MultiSafepay, not Mollie's forced re-fetch - reading brq_statuscode and brq_transactions straight from the parsed form fields.

A second secret, the same way Stripe needed one

The website key stays on the endpoint's own "Secret (Key)" field, resolved via the inherited getSecret(). The secret key - used for both outbound signing and verifying the push - is a second, plugin-level setting, the same pattern Stripe's webhook secret already established: no change to connector_hub core.

Still genuinely open

Two things this adapter doesn't pretend are settled:

Both are tracked in ROADMAP.md, to confirm against a live sandbox.

08 · The orchestration layer, finally written down

connector_hub_payment_demo: the first real caller

Every adapter test in both of these projects registers an operation and calls initiatePayment() - but every single one of those calls happens inside a test. Nothing in either repository's real, shipped code had ever actually done this until this module existed. That's not a knock against the design; it's exactly why this module is worth reading before building anything on top of it - it's the first proof the pattern works for someone who isn't also the one who wrote the interfaces.

Eligibility: the mistake this had to avoid

Picking which connector endpoint can pay for an order looks like it should check $endpoint->isOutboundEnabled() - that's the flag the endpoint list literally calls "Direction: Outbound". It's the wrong flag here. It governs ConnectorDispatcher's generic queue, which none of the four payment adapters use - every one of them calls its provider directly from inside initiatePayment(), with supportsOutbound() === FALSE. The actual eligibility check is narrower and has nothing to do with that setting:

foreach ($storage->loadMultiple() as $endpoint) {
  if (!$endpoint->status()) { continue; }
  $plugin = $connectorPluginManager->createInstance($endpoint->getPluginId(), $endpoint->getPluginSettings());
  if ($plugin instanceof PaymentInitiatorInterface) {
    $eligible[] = $endpoint; // enabled + can start a payment - that's the whole check
  }
}

A separate business object, on purpose

DemoOrder is not PaymentOperation renamed, and it isn't a subclass of it. It's its own content entity with its own fulfillment_status, correlated to a payment operation through a plain entity-reference field it sets once it has one. That's deliberate: every real site has its own order, booking, subscription, or whatever - a separate object that a payment correlates back to, never the payment record itself wearing two hats.

The return URL still isn't trusted

The landing page at the return URL says "we'll confirm shortly" and nothing more - it does not read, and must not read, anything about the payment's outcome from the fact that the customer came back. Every adapter section above already explains why; this is the one place in either project that actually has to follow that advice instead of just stating it. The real, confirmed outcome always arrives separately:

public static function getSubscribedEvents(): array {
  return [PaymentStatusChangedEvent::class => 'onStatusChanged'];
}

public function onStatusChanged(PaymentStatusChangedEvent $event): void {
  $orders = $storage->loadByProperties(['payment_operation' => $event->operation->id()]);
  // Succeeded -> fulfilled, Failed/Cancelled/Expired -> failed,
  // anything else (Processing/RequiresAction/Pending/Unknown) -> no change yet.
}
What this deliberately doesn't cover

Refunds, and nothing past "create, pay, react to status" - scoped tightly on purpose, to match exactly what a new reader needs to see first. A refund demo is a reasonable follow-up: register a Refund operation, check instanceof RefundProviderInterface, call refund() - the same shape as everything above, just not built here yet.

09 · The layer payment never needed until Google did

connector_hub_auth: pick a mechanism, don't rebuild it

Stripe and Buckaroo already proved "a second secret, no core change" works – both just add a plugin-level key_select setting. Google needed something that pattern genuinely can't stretch to: a browser redirect to a consent screen, a stored refresh token that outlives the request, an ID token to cryptographically verify. Building that straight into a Google-specific class would have meant rebuilding the exact same OAuth2 machinery the moment a second OAuth2 provider showed up. So it became its own module instead – connector_hub core itself stays completely unchanged.

Composition, not inheritance

The first draft of this made a Google connector extend an OAuth2-specific base class. That's the wrong shape – it ties "is a Google connector" to "authenticates via OAuth2 Authorization Code + PKCE" through single inheritance, and PHP has no multiple inheritance for the day a connector needs two mechanisms, or a different provider needs OAuth2 Client Credentials instead. The actual shape: a connector plugin implements one small marker interface and hands back a configured instance.

interface UsesAuthenticationProviderInterface {
  public function getAuthenticationProvider(ConnectorEndpointInterface $endpoint): AuthenticationProviderInterface;
}

Any caller – the connector's own business method, the generic admin "connect" controller, a completely unrelated connector reusing the same mechanism – only ever talks to AuthenticationProviderInterface, never to one mechanism's internals.

Two separate systems, on purpose: outbound and inbound are not the same question

Authenticating a request this site makes and verifying a request this site receives are different problems with different shapes – conflating them was the first instinct, and the wrong one. They stayed two parallel, independently pluggable systems:

Outbound – AuthenticationProviderInterfaceInbound – InboundVerificationProviderInterface
api_key – a header or query paramhmac_signature – configurable algorithm (sha1/sha256/sha512), the exact shape Buckaroo's own push already needed
basic_auth – Guzzle's own auth optionbearer_token – the generic default connector_hub core already had, now a reusable plugin instead of inherited base-class logic
bearer_token – a static, manually issued token
oauth2_client_credentials – two-legged, no browser step, a cached token re-fetched on expiry
oauth2_authorization_code_pkce – the full browser-consent flow, with a stored, auto-refreshed token

A scheme that genuinely doesn't fit either shape – Stripe's timestamped Stripe-Signature header – stays exactly where it already was: that adapter's own verifyInboundRequest() override, documented in its own section above. Not every mechanism needs to be generalized, only the ones more than one connector would actually share.

Two ways to consume a provider, because not every caller builds a Guzzle request

Buckaroo and MultiSafepay call their REST APIs directly through Guzzle, so applyToRequest() fits them exactly. Mollie and Stripe hand a raw token to an SDK client instead – $mollieApiClient->setToken($secret), not a request-options array. Migrating those two surfaced a real gap the first design didn't have an answer for, so every provider got a second method too:

interface AuthenticationProviderInterface extends ConfigurableInterface, PluginFormInterface {
  public function applyToRequest(array $requestOptions, ConnectorEndpointInterface $endpoint): array;

  // For an SDK-based caller that needs the raw value(s) instead - shape
  // depends on the mechanism, e.g. bearer_token returns ['token' => ...].
  public function resolveCredentials(ConnectorEndpointInterface $endpoint): array;
}
// MollieConnector, after migrating off getSecret():
$token = $this->getAuthenticationProvider($endpoint)->resolveCredentials($endpoint)['token'];
$client->setToken($token);

// GoogleCalendarConnector, a raw-Guzzle caller:
$requestOptions = $this->getAuthenticationProvider($endpoint)->applyToRequest(['json' => $body], $endpoint);
$this->httpClient->request('POST', $url, $requestOptions);

The refresh-token quirk that would have broken silently

Google, and most OAuth2 providers, only return refresh_token on the very first authorization-code exchange – a later refresh-grant response omits it entirely. OAuth2TokenSet::fromTokenResponse() has to carry the previous refresh token forward when the response doesn't include one, or every refresh after the first would quietly erase it, and the following refresh would then fail outright with nothing left to use. This is the kind of bug that passes a quick manual test (the first refresh always works) and only shows up the second time – which is exactly why it's called out here rather than left to be rediscovered.

One Google-ism the generic layer still carries

Asking for a refresh token at all means adding access_type=offline&prompt=consent to the authorize URL – that's how Google specifically signals it. Other providers ask differently (some always include a refresh token, others use a distinct scope). That's hardcoded into the PKCE provider's offline_access handling for now, deliberately not generalized into a configurable shape before a second provider actually needs something different – speculative generalization for a provider that doesn't exist yet would just be guessing.

A table that doesn't exist until core does, not until a module says so

The interactive provider's own Kernel tests originally called $this->installSchema('system', ['key_value_expire']) – the same call tempstore.private-using code elsewhere in Drupal typically needs. It failed outright: "system module does not define a schema for table 'key_value_expire'." Not a mistake in the call – in this core version, that table is no longer declared through any module's hook_schema() at all. DatabaseStorageExpirable::ensureTableExists() creates it itself, lazily, the moment something actually writes to it. The fix was deleting the call entirely, not fixing its arguments.

What actually moved onto it, and what didn't

Mollie and Stripe's main secret both became bearer_token providers – same endpoint key_id as before, zero migration pain for an existing install. MultiSafepay's became api_key, matching its actual query-string scheme exactly. Stripe's separate webhook signing secret, and both of Buckaroo's signing schemes, stayed exactly as they were – see each adapter's own section above for why those specific shapes don't generalize. The point was never "migrate everything onto the generic layer"; it was "migrate what genuinely fits, and say plainly why the rest doesn't."

10 · The second domain

connector_hub_google: the one place a Google URL is hardcoded

Everything below connector_hub_google – connector_hub_auth, connector_hub itself – has never heard of Google. This module is deliberately thin: it supplies the facts only Google's own documentation can give you, and nothing else.

abstract class GoogleConnectorPluginBase extends ConnectorPluginBase implements UsesAuthenticationProviderInterface {

  // Left abstract: a login connector and a Calendar connector need
  // genuinely different scopes and a different answer here, even
  // though both are "Google" and both use the same OAuth2 flow.
  abstract public function getOauthScopes(): array;
  abstract public function needsOfflineAccess(): bool;

  public function getAuthenticationProvider(ConnectorEndpointInterface $endpoint): AuthenticationProviderInterface {
    return $this->authProviderManager->createInstance('oauth2_authorization_code_pkce', [
      'client_id' => $this->configuration['client_id'] ?? '',
      'client_secret_key_id' => $this->configuration['client_secret_key_id'] ?? NULL,
      'authorize_url' => 'https://accounts.google.com/o/oauth2/v2/auth',
      'token_url' => 'https://oauth2.googleapis.com/token',
      'jwks_url' => 'https://www.googleapis.com/oauth2/v3/certs',
      'issuer' => 'https://accounts.google.com',
      'scopes' => $this->getOauthScopes(),
      'offline_access' => $this->needsOfflineAccess(),
    ]);
  }
}

No inheritance from anything in connector_hub_auth – composition via the marker interface, same as any other connector. And no OAuth controller of its own: the generic "Connect"/"Reconnect" admin flow connector_hub_auth already ships works for this module automatically, since it only ever needs UsesAuthenticationProviderInterface to be implemented, regardless of which domain the connector belongs to.

CalendarEventManagerInterface: one capability so far

Same rule connector_hub_payment's own three capability interfaces already established – small, single-purpose, checked with instanceof, not a blanket flag:

interface CalendarEventManagerInterface {
  public function createEvent(ConnectorEndpointInterface $endpoint, CalendarEventDraft $draft): CalendarEvent;
  /** @return CalendarEvent[] */
  public function listEvents(ConnectorEndpointInterface $endpoint, \DateTimeImmutable $from, \DateTimeImmutable $to, int $limit = 50): array;
}

Gmail, Sheets & Drive, and Analytics would each get their own small interface the same way, when they're actually built – not speculatively added now for adapters that don't exist yet.

11 · Sign in with Google

connector_hub_google_login: registration and login, not an API call

This is the one adapter that doesn't fit the "site talks to an API on its own behalf" shape every payment adapter and Calendar already follow – here, a visitor authenticates with their own Google identity, once, and the site never needs to call anything as them afterward.

No stored token, on purpose

That difference shows up directly in the two abstract methods every Google connector answers:

public function getOauthScopes(): array {
  return ['openid', 'email', 'profile'];
}
public function needsOfflineAccess(): bool {
  return FALSE; // the one-time ID token is all this needs - no refresh
                 // token is requested, and connector_hub_auth's token
                 // store is never written to by this plugin at all.
}

Deliberately the opposite answer from Calendar's TRUE – not an oversight, the actual point: a login flow needs an identity claim, once; an API-access connector needs standing permission, indefinitely.

Account linking: externalauth, not a hand-rolled table

Mapping a Google account's stable sub claim to a Drupal user – create on first sign-in, recognize on every one after – is exactly the problem the externalauth module already solves, and already underlies social_auth/openid_connect on real sites. Reimplementing that matching/creation logic here would mean reimplementing something security-sensitive that a small, widely used module already gets right:

$this->externalAuth->loginRegister($identity->sub, 'connector_hub_google_login', [
  'name' => $identity->name ?: $identity->email,
  'mail' => $identity->email,
]);

Rejected before that call ever happens if the identity is missing or its email isn't verified – an unverified email is not a safe thing to register a Drupal account against.

A Block, not a form alter

GoogleSignInBlock is a placeable, cacheable, themeable Block – exactly how real-world SSO contrib modules ship this – not a hook_form_user_login_form_alter(). Altering a shared, heavily-altered core form directly ties the feature to one specific place in the UI; a block can go on the login page, in a header region, or in a modal, and renders nothing at all if no endpoint is configured yet, rather than a dead link.

Setting it up

1

Create an OAuth client in Google Cloud

APIs & Services › Credentials › Create OAuth client ID (Web application). Add the callback URL this site will show you once the endpoint exists.

2

Store the client secret, create the endpoint

A Key holding the client secret, then a Connector endpoint with plugin google_login, the client ID, and that Key.

3

Place the block

Block layout › place "Sign in with Google" wherever visitors should see it.

4

Try it

/user/login/google redirects to Google's own consent screen and back.

Testing a signature, not a mock of one

The Kernel test for the callback doesn't fake a verified identity – it generates a real RSA keypair with openssl_pkey_new(), signs a genuine test ID token with it, and mocks only the JWKS fetch to return that keypair's own public key. That exercises firebase/php-jwt's actual signature verification path against a crafted-but-real JWT, not a stub that assumes the verification code works. What it still doesn't cover, honestly: the real browser redirect to accounts.google.com itself – no Functional/browser test exists anywhere in either project, and this adapter doesn't introduce the first one.

12 · The first real API-access adapter

connector_hub_google_calendar: create and list events

The other side of the asymmetry in the Login section above: this adapter needs standing, site-level access, not a one-time identity claim.

public function getOauthScopes(): array {
  // Narrowest scope that does the job - not the full calendar scope,
  // which would also grant settings/ACL management this never uses.
  return ['https://www.googleapis.com/auth/calendar.events'];
}
public function needsOfflineAccess(): bool {
  return TRUE; // nobody's at a browser to re-consent every hour
}

Raw REST via Guzzle, no SDK – the same call connector_hub_payment_multisafepay and connector_hub_payment_buckaroo already made: the Calendar v3 surface actually used here is two endpoints, too small to justify a generated client library.

public function createEvent(ConnectorEndpointInterface $endpoint, CalendarEventDraft $draft): CalendarEvent {
  $requestOptions = $this->getAuthenticationProvider($endpoint)->applyToRequest([
    'json' => [
      'summary' => $draft->title,
      'description' => $draft->description,
      'start' => ['dateTime' => $draft->start->format(DATE_ATOM)],
      'end' => ['dateTime' => $draft->end->format(DATE_ATOM)],
    ],
  ], $endpoint);

  try {
    $response = $this->httpClient->request('POST', ".../calendars/{$calendarId}/events?sendUpdates=all", $requestOptions);
  }
  catch (RequestException $e) {
    $this->recordOutboundFailure($endpoint);
    throw new GoogleApiException(/* ... */);
  }
  $this->recordOutboundSuccess($endpoint);
  // ... map the JSON response to a CalendarEvent ...
}

Token refresh is entirely transparent to this method – applyToRequest() resolves a valid access token internally, refreshing from the stored refresh token first if the current one has expired, and the connector never has to think about that lifecycle itself.

13 · The first domain neither payment nor Google was designed around

Slack: a bespoke signature, and a reply that isn't immediate

Payment and Google both needed outbound authentication from connector_hub_auth; Slack is the first adapter to lean on the module's inbound side too — and the first to find a real scheme that doesn't fit the generic one.

Outbound: nothing new

The bot token is a single secret on the endpoint's own key_id, resolved as bearer_token – the exact shape Mollie and Stripe already established. Nothing about posting a message needed a new authentication mechanism.

public function getAuthenticationProvider(ConnectorEndpointInterface $endpoint): AuthenticationProviderInterface {
  return $this->authProviderManager->createInstance('bearer_token', [
    'token_key_id' => $endpoint->getKeyId(),
  ]);
}

HTTP 200 doesn't mean it worked

Confirmed against Slack's own chat.postMessage reference, not assumed: Slack's Web API returns a 200 status even when the call failed. The only place the real outcome lives is the response body's own ok field.

$body = json_decode((string) $response->getBody(), TRUE);
if (!is_array($body) || empty($body['ok'])) {
  $this->recordOutboundFailure($endpoint);
  throw new SlackApiException(/* ... */);
}

Inbound: a real scheme that doesn't fit hmac_signature

A Slack slash command arrives signed with a timestamp-prefixed HMAC over the raw body – close to, but not the same shape as, connector_hub_auth's generic hmac_signature provider as it exists today. Rather than force a fit or weaken the provider to accommodate it, verifyInboundRequest() stays a bespoke override, the same precedent Stripe's, MultiSafepay's and Buckaroo's own schemes already set. The generalization this opens up is tracked on connector_hub_auth's own roadmap, not solved here.

public function verifyInboundRequest(Request $request, string $rawBody, ConnectorEndpointInterface $endpoint): bool {
  $secret = $this->getSigningSecret();
  if ($secret === NULL) {
    return FALSE; // fails closed, same as every other adapter
  }

  $timestamp = (string) $request->headers->get('X-Slack-Request-Timestamp');
  if ($timestamp === '' || abs(time() - (int) $timestamp) > 300) {
    return FALSE; // stale - a 5-minute replay window, same as MultiSafepay's
  }

  $expected = 'v0=' . hash_hmac('sha256', "v0:$timestamp:$rawBody", $secret);
  $actual = (string) $request->headers->get('X-Slack-Signature');
  return $actual !== '' && hash_equals($expected, $actual);
}

The reply isn't in the same request

Slack expects an acknowledgement within 3 seconds – the core webhook route's own immediate 202 already covers that. The actual reply ("pong" in this first version) goes out later, through the response_url Slack includes in the slash command payload itself. That maps cleanly onto connector_hub's own verify-then-queue-then-process model instead of fighting it: the subscriber below reacts to the same ConnectorInboundReceivedEvent every other webhook subscriber already uses.

public function onSlashCommand(ConnectorInboundReceivedEvent $event): void {
  if ($event->endpoint->getPluginId() !== 'slack') {
    return;
  }
  $responseUrl = $event->request->request->get('response_url');
  if (!is_string($responseUrl) || $responseUrl === '') {
    return; // logged, not escalated - see the module's own test for this case
  }
  $this->httpClient->request('POST', $responseUrl, ['json' => ['text' => 'pong']]);
}

Setting it up

1

Create a Slack app

At api.slack.com/apps: a Bot Token Scope of chat:write, and a Slash Command pointing at this connection's webhook URL.

2

Store both secrets

Two separate Keys: the Bot User OAuth Token (starts xoxb-) and the app's own Signing Secret, from "Basic Information".

3

Create the connection

A Connector endpoint with plugin slack, the bot token as its main key_id, and the signing secret in the plugin's own settings.

4

Try it

Post a message through SlackNotifierInterface::postMessage(), or run the slash command in Slack and watch the "pong" come back.

14 · The second primitive

connector_hub_notify: the generic counterpart to auth

Authentication generalized "how do I authenticate" once a second domain (Google) needed exactly the mechanism payment already had. This module holds itself to the same bar, for the opposite direction: "how do I tell a human something happened." It did not generalize on the first domain's say-so alone - connector_hub_slack's own ROADMAP.md named a payment-status-change-wants-a-Slack-notification case as "not built here" before this module existed, and connector_hub_ai needing one when a draft is ready is the second, concrete case that actually triggered building it.

NotifierInterface: one capability, no assumed transport

Unlike connector_hub_auth's mechanisms (api_key, bearer_token, two OAuth2 flavors), which are genuinely generic - any HTTP API can use one - there is no equivalent generic notification transport. Slack, email, SMS each need their own real implementation. So this module ships only the interface and the dispatcher; every actual notifier is a leaf domain's own connector.

interface NotifierInterface {
  public function notify(ConnectorEndpointInterface $endpoint, string $message, array $context = []): void;
}

NotificationDispatcher: the one house rule is "never throw"

A caller using this (e.g. connector_hub_ai's own draft-ready subscriber) has already done the thing that matters - the draft exists either way. Letting a notification failure propagate would mean a flaky Slack call could make an unrelated domain's own operation look like it failed when it didn't.

public function notify(string $message, array $context = []): bool {
  $endpointId = $this->configFactory->get('connector_hub_notify.settings')->get('endpoint_id');
  // ... load the endpoint, check instanceof NotifierInterface ...
  try {
    $plugin->notify($endpoint, $message, $context);
  }
  catch (\Throwable $e) {
    $this->logger->error(/* ... */);
    return FALSE; // logged, never propagated
  }
  return TRUE;
}

SlackConnector implements it too

Alongside its own, Slack-specific SlackNotifierInterface::postMessage(). notify() is a thin wrapper, reading the channel out of $context['channel'] and falling back to a plugin-level default_channel setting when the caller supplied none.

public function notify(ConnectorEndpointInterface $endpoint, string $message, array $context = []): void {
  $channel = $context['channel'] ?? $this->configuration['default_channel'] ?? NULL;
  if (!is_string($channel) || $channel === '') {
    throw new SlackApiException(/* no channel known */);
  }
  $this->postMessage($endpoint, $channel, $message);
}
15 · The domain built to take tens of seconds

connector_hub_ai: a queued job, not a call-and-wait

Every existing adapter in this ecosystem (Mollie, Stripe, a Slack notification) calls its provider and gets an answer back in the same request. A real AI generation call can genuinely take tens of seconds - long enough that calling it synchronously would risk whatever triggered it timing out first. AiGenerationWorker runs as a queue job instead, the same infrastructure connector_hub's own ConnectorOutboundWorker and connector_hub_payment's ReconciliationWorker already use.

TextGeneratorInterface: $wordTarget is advisory, never enforced

interface TextGeneratorInterface {
  public function generate(ConnectorEndpointInterface $endpoint, string $prompt, ?int $wordTarget = NULL): GeneratedText;
}

No provider's API guarantees an exact word count, so this interface does not pretend to enforce one - $wordTarget is folded into the prompt itself ("Write approximately {n} words about..."), nothing more.

Two real findings about the draft node, checked, not assumed

The obvious choice for the generated text was node's own shared body field, attached via the well-known node_add_body_field() helper - until checking it against a real Drupal 11 install surfaced two things: that function is deprecated in 11.3 with no replacement, and the body field storage it depends on no longer ships with node module at all - it moved to a separate, also-deprecated, hidden submodule on its way out entirely by Drupal 12. Depending on either for a module written today would mean building on something already scheduled for removal.

So ai_draft gets its own field, shipped entirely as static config this module owns outright - no hook_install() needed:

# config/install/field.storage.node.field_draft_body.yml
field_name: field_draft_body
entity_type: node
type: text_long
# config/install/field.field.node.ai_draft.field_draft_body.yml
field_name: field_draft_body
entity_type: node
bundle: ai_draft

AiGenerationWorker, start to finish

public function processItem($data): void {
  $endpoint = /* load by $data['endpoint_id'] */;
  $plugin = /* resolve the endpoint's own connector plugin */;
  if (!$plugin instanceof TextGeneratorInterface) {
    // Configuration changed after the job was queued - logged and
    // dropped, the same case ConnectorOutboundWorker already handles.
    return;
  }
  $generated = $plugin->generate($endpoint, $data['prompt'], $data['word_target'] ?? NULL);
  $node = Node::create([
    'type' => 'ai_draft',
    'title' => mb_substr($data['prompt'], 0, 255),
    'field_draft_body' => ['value' => $generated->text, 'format' => 'basic_html'],
    'status' => 0, // unpublished is the entire point
  ]);
  $node->save();
  $this->eventDispatcher->dispatch(new AiDraftReadyEvent($node, $generated, $data['context'] ?? []));
}

Two providers, two different auth shapes - confirmed, not assumed

OpenAI's Chat Completions API takes Authorization: Bearer <key> - bearer_token, no new ground, confirmed against OpenAI's own API reference. Anthropic's Messages API does not use Bearer at all - it takes x-api-key: <key> plus a mandatory anthropic-version header, confirmed against Anthropic's own docs. That is exactly the shape connector_hub_auth's api_key provider (location: header, name: 'x-api-key') was built for - and this is the first real consumer of it anywhere in this project; every adapter so far has used bearer_token.

// AnthropicConnector::getAuthenticationProvider()
return $this->authProviderManager->createInstance('api_key', [
  'location' => 'header',
  'name' => 'x-api-key',
  'key_id' => $endpoint->getKeyId(),
]);

The anthropic-version header is not a secret, so it is not connector_hub_auth's concern at all - AnthropicConnector adds it itself, directly, as a plugin-level setting (default 2023-06-01), the same "plugin-level, non-secret setting" pattern Buckaroo's signature_algorithm already established.

Notifying: a direct dependency on a primitive, not a bridge

DraftReadyNotifiesSubscriber depends on connector_hub_notify directly - the same way every connector already depends directly on connector_hub_auth without needing a bridge for that. This module never hears of Slack, email, or anything else.

public function onDraftReady(AiDraftReadyEvent $event): void {
  $url = $event->node->toUrl('edit-form', ['absolute' => TRUE])->toString();
  $this->dispatcher->notify(sprintf('Draft ready: %s', $url), $event->context);
}
16 · A bridge that shrank once half of it generalized

connector_hub_ai_slack: trigger-only, and why

An earlier version of this module also posted the "draft ready" notification, checking $plugin instanceof SlackNotifierInterface directly. That half turned out not to need Slack specifically at all - "tell a human something happened" generalizes the same way "authenticate an outbound call" already did, so it moved to connector_hub_notify (see above) and connector_hub_ai's own direct dependency on it. What's left here is the one half that genuinely cannot generalize: parsing a Slack slash command's own payload shape.

Why a bridge module at all, for just this one half

connector_hub_ai and connector_hub_slack are unrelated domains, each with their own reason to exist independently. This third, small module depends on both - and does nothing else - so a site that only wants AI generation triggered from a form, say, is never forced to install Slack integration it never asked for.

A deliberately simple parse

The first standalone number anywhere in the slash command's free text is the word target; everything else, with that number removed and whitespace collapsed, is the prompt. Works equally for "5000 write about X" and "write 500 words about X" - not NLP, a regex.

protected function parseCommandText(string $text): array {
  if (preg_match('/\b(\d+)\b/', $text, $matches) !== 1) {
    return [NULL, trim($text)];
  }
  $wordTarget = (int) $matches[1];
  $prompt = trim(preg_replace('/\s+/', ' ', str_replace($matches[0], '', $text)));
  return [$wordTarget, $prompt];
}

The queued job's context carries only a generic 'channel' key (the Slack command's own channel_id) - not a Slack endpoint ID, since the notification now goes through whichever endpoint connector_hub_notify is configured with, not necessarily the one that triggered the command.

A known, open gap

connector_hub_slack's own demo slash-command subscriber replies "pong" to every command on a Slack endpoint, by its own design. This module reacts to the exact same event, unfiltered by command name - so today, a command meant to trigger generation also gets an immediate "pong" first. The real fix is a dedicated command name, not solved here.

17 · What's next

Building the next adapter

Adyen is next on the roadmap. If you're building it (or any future adapter), this is the actual checklist, not a guess at one:

18 · How any of this is actually verified

Nothing here is "should work"

Every claim on the main page and in this one was checked against a real Drupal 11 install through DDEV – not assumed correct from reading the code back. Concretely, before anything here is called done:

CheckWhat it actually catches
phpcs – Drupal + DrupalPracticestyle, but also real mistakes the standard happens to forbid
phpstan – level 6type mismatches, impossible conditions, the kind of bug that survives a casual read
phpunit – Kernel and Functionalactual behavior: a real database constraint firing, a real HTTP mock, a real event dispatch
a real DDEV installwhether the module even loads, which has caught fatal errors none of the above could

Security-relevant paths – the webhook's authentication, the idempotency race, the unsigned-webhook re-fetch – get both a positive and a negative test: "accepted when it should be" and "rejected when it shouldn't be" are both required, never assumed as a pair.