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.
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.
A ConnectorEndpoint is a config entity – one row of configuration, not a database record of events. It holds everything generic about one connection:
| Field | What it's for |
|---|---|
plugin_id | which Connector plugin speaks for this connection |
key_id | a reference into the Key module – never the secret itself |
outbound / inbound | which directions this connection is allowed to use |
inbound_auth_type | hmac or bearer_token – the generic default scheme, ignored by plugins with their own |
webhook_token | a random path segment, so the endpoint's machine name alone can't be used to probe for a live webhook URL |
settings | whatever 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".
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.
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 /* , ... */ {
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.
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.
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:
Twenty failed attempts per endpoint+IP per hour, tracked by Drupal's flood service, before anything else is even looked at.
A disabled or outbound-only endpoint is rejected before its plugin is even instantiated.
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.
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.
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'];
}
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.
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.
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);
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.
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.
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.
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.
| Status | Meaning |
|---|---|
Pending | registered, nothing has happened yet |
Processing | under way on the provider's side |
RequiresAction | the customer still has to do something |
Succeeded / Failed / Cancelled / Expired | final – isTerminal() returns true |
Unknown | the provider's code didn't map to anything above – never silently guessed as Succeeded |
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.
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.
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);
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.
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.
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.
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.
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.
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.
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.
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:
connector_hub_payment_cron() marks the operation "pending" with KeyValueStoreExpirableInterface::setWithExpireIfNotExists() - an atomic check, so the same operation is never enqueued twice while a previous attempt hasn't finished. ReconciliationWorker clears that mark in a finally block, success or failure, so it becomes eligible again immediately rather than waiting out the mark's own 10-minute TTL (which exists purely as a fallback, in case a worker crashes before its own cleanup runs).EndpointHealthTracker health - already fed by every readStatus() call, reconciliation's own included - and skips it entirely for the cron run if it has 3 or more consecutive failures within the last 10 minutes. A sustained provider outage no longer gets a fresh API call for every one of its stale operations, every single cron cycle; a fixed cooldown, not exponential backoff, deliberately, to stay simple.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:
Free, and test mode works immediately. mollie.com/signup
Dashboard › Developers › API keys (or API access tokens, depending on your dashboard version). It starts with test_. Mollie's guide
A Key (Configuration › Key) holding the value, then a Connector endpoint (Configuration › Web services › Connector Hub) with plugin Mollie and that Key, inbound enabled.
initiatePayment(), below.
This is the part worth actually understanding, because it's the pattern every future provider will roughly follow:
The caller registers an operation with an idempotency key, then calls MollieConnector::initiatePayment($operation, $endpoint, $returnUrl).
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().
Redirected to the checkout URL the adapter handed back as a PaymentInitiationResult::redirect().
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().
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.
| Mollie status | Maps to |
|---|---|
open | Pending |
pending | Processing |
authorized | Processing — see below |
paid | Succeeded |
canceled | Cancelled |
expired | Expired |
failed | Failed |
| anything else | Unknown |
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.
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
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:
sk_...) and the webhook signing secret (whsec_...) come from different places in the Dashboard. ConnectorEndpoint only ever had one key_id.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();
}
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.
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.
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.
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.
| Session status | payment_status | Maps to |
|---|---|---|
open | any | Pending |
expired | any | Expired |
complete | paid / no_payment_required | Succeeded |
complete | unpaid | Processing — async method still settling |
| anything else | Unknown |
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.
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.
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.
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.
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.
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);
}
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.
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 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.
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.
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.
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.
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
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.
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.
Two things this adapter doesn't pretend are settled:
Services/"Action": "Refund" block - the only confirmed example was Klarna-specific.ServicesSelectableByClient defaults to for a gateway-less transaction showing every activated method - left unset rather than guessed at.Both are tracked in ROADMAP.md, to confirm against a live sandbox.
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.
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
}
}
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 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.
}
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.
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.
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.
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 – AuthenticationProviderInterface | Inbound – InboundVerificationProviderInterface |
|---|---|
api_key – a header or query param | hmac_signature – configurable algorithm (sha1/sha256/sha512), the exact shape Buckaroo's own push already needed |
basic_auth – Guzzle's own auth option | bearer_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.
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);
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.
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.
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.
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."
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.
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.
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.
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.
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.
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.
APIs & Services › Credentials › Create OAuth client ID (Web application). Add the callback URL this site will show you once the endpoint exists.
A Key holding the client secret, then a Connector endpoint with plugin google_login, the client ID, and that Key.
Block layout › place "Sign in with Google" wherever visitors should see it.
/user/login/google redirects to Google's own consent screen and back.
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.
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.
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.
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(),
]);
}
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(/* ... */);
}
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);
}
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']]);
}
At api.slack.com/apps: a Bot Token Scope of chat:write, and a Slash Command pointing at this connection's webhook URL.
Two separate Keys: the Bot User OAuth Token (starts xoxb-) and the app's own Signing Secret, from "Basic Information".
A Connector endpoint with plugin slack, the bot token as its main key_id, and the signing secret in the plugin's own settings.
Post a message through SlackNotifierInterface::postMessage(), or run the slash command in Slack and watch the "pong" come back.
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.
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;
}
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;
}
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);
}
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.
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.
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
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'] ?? []));
}
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.
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);
}
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.
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.
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.
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.
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:
ConnectorPluginBase, tagged with a #[Connector] attribute declaring its id and which directions it supports (the Mollie, Stripe, MultiSafepay and Buckaroo examples above show the full shape).verifyInboundRequest() with the provider's real scheme. If it's genuinely unsigned like Mollie's classic webhook, say so explicitly in a comment and re-fetch. If it's signed like Stripe's, MultiSafepay's or Buckaroo's, verify for real and trust what you've verified - don't re-fetch out of habit once you've actually checked a signature. Don't assume the outbound and inbound schemes are related just because they're the same provider - Buckaroo's aren't.ConnectorInboundReceivedEvent keyed on ::class, not ::NAME – see the aside in the Core section if you want to know why that matters.OperationStore::register(), and report back through attachRemoteReferences() / updateStatus() – never by handing a record back for the caller to remember to persist.key_id, the rest become the plugin's own settings (with their own config schema - see the aside above) rather than a change to ConnectorEndpoint itself. If it only needs the one secret for everything, follow MultiSafepay's instead.connector_hub_auth for a mechanism that already fits (API key, Basic Auth, Bearer token, OAuth2 Client Credentials, OAuth2 Authorization Code + PKCE) before hand-rolling authentication - implement UsesAuthenticationProviderInterface and compose it, the way every migrated payment adapter and both Google adapters already do. Only build something provider-specific, as Stripe's and Buckaroo's signing schemes do, when nothing generic actually fits.RequiresCustomerDetailsInterface above, and the mistake it replaced.recordOutboundSuccess()/recordOutboundFailure() at every exit point of every direct provider call (the existing catch block, and any "response parsed but logically invalid" throw after it) - the admin dashboard's "Last sync"/"Errors" columns stay empty for this adapter otherwise, since none of the direct-call adapters route through the generic dispatcher that would record this for free.initiatePayment() interface's optional $cancelUrl parameter for it - don't reuse $returnUrl the way the first version of every adapter here did, before that parameter existed.extractEventIdentity() only if the provider's webhook payload has a field that is both genuinely unique per event and stable across a redelivery of that same event (Stripe's id qualifies; a payment ID that can legitimately repeat across different real events does not) - otherwise leave the default, which falls back to hashing the raw body.connector_hub_payment/modules/, with its own composer.json only if it actually needs one – a site that only wants Mollie should never have to pull in another provider's dependencies.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:
| Check | What it actually catches |
|---|---|
phpcs – Drupal + DrupalPractice | style, but also real mistakes the standard happens to forbid |
phpstan – level 6 | type mismatches, impossible conditions, the kind of bug that survives a casual read |
phpunit – Kernel and Functional | actual behavior: a real database constraint firing, a real HTTP mock, a real event dispatch |
| a real DDEV install | whether 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.