4. Webhooks and identities

Implement InboundGateway and IdentityResolver for Agora — verify the HMAC signature, decode events into envelopes with stable idempotency keys, resolve the actor, and write the route the core's gate protects.

Agora posts one event per webhook call, signed with the secret we minted at install. The core's inbound gate authenticates and deduplicates through our InboundGateway; our own controller then handles what is new.

The gateway

<?php

declare(strict_types=1);

namespace Acme\Connectors\Agora\Ports;

use DateTimeImmutable;
use Illuminate\Http\Request;
use Subscriby\Connector\Contracts\Ports\InboundGateway;
use Subscriby\Connector\Core\Installations;
use Subscriby\Connector\Data\InboundEnvelope;
use Symfony\Component\HttpFoundation\Response;

final class AgoraInboundGateway implements InboundGateway
{
    public function __construct(private readonly Installations $installations) {}

    public function authenticate(Request $request): bool
    {
        $installation = $this->installationFor($request);
        $secret = $installation === null ? null : $this->secretFor($installation);
        $signature = (string) $request->header('X-Agora-Signature', '');

        if ($secret === null || ! str_starts_with($signature, 'sha256=')) {
            return false;
        }

        return hash_equals('sha256='.hash_hmac('sha256', $request->getContent(), $secret), $signature);
    }

    public function decode(Request $request): iterable
    {
        $event = $request->json()->all();
        $installation = $this->installationFor($request);

        if ($installation === null || ! isset($event['id'], $event['type'])) {
            return [];
        }

        return [new InboundEnvelope(
            connector: 'agora',
            installation: $installation,
            idempotencyKey: $installation->externalId.':'.$event['id'],
            kind: (string) $event['type'],
            payload: $event,
            receivedAt: new DateTimeImmutable,
        )];
    }

    public function immediateResponse(Request $request): ?Response
    {
        return null;
    }

    public function shouldDefer(InboundEnvelope $envelope): bool
    {
        return $envelope->kind !== 'board.picked';
    }

    private function installationFor(Request $request): ?\Subscriby\Connector\Data\InstallationRef
    {
        return $this->installations->findByExternalId('agora', (string) $request->route('botUser'));
    }

    private function secretFor(\Subscriby\Connector\Data\InstallationRef $installation): ?string
    {
        return $this->installations->credentials($installation)->get('webhook_secret');
    }
}

Three decisions:

  • Which installation is calling? The webhook URL we registered ends in the bot user's id, so the route's botUser parameter names the installation and Core\Installations::findByExternalId() finds it. An unknown id authenticates false, and the gate answers 403.
  • The idempotency key is the bot user's id and Agora's event id together, because two forums could reuse an event id and the key must be unique per connector.
  • decode() is side-effect free and returns an empty list for a body it does not recognise, which is what the kit's inbound.tolerates_empty_request sends.

The secret comes from the installation's stored bag through Core\Installations::credentials(): chapter 3 put it in the summary's meta, the core merged it into the credentials it stored, and the gate can read it before any port is called. shouldDefer() is honest about what should run in the request (a picked board, which the creator is waiting for) and what could wait; the core does not read it yet.

The identity resolver

<?php

declare(strict_types=1);

namespace Acme\Connectors\Agora\Ports;

use Acme\Connectors\Agora\Agora;
use Subscriby\Connector\Contracts\Ports\IdentityResolver;
use Subscriby\Connector\Data\CredentialBag;
use Subscriby\Connector\Data\IdentityRecord;
use Subscriby\Connector\Data\IdentityRef;
use Subscriby\Connector\Data\IdentitySummary;
use Subscriby\Connector\Data\InboundEnvelope;
use Subscriby\Connector\Data\InstallationRef;
use Subscriby\Connector\Data\Recipient;

final class AgoraIdentityResolver implements IdentityResolver
{
    public function __construct(private readonly Agora $agora) {}

    public function resolveInbound(InboundEnvelope $envelope): ?IdentitySummary
    {
        $actor = $envelope->payload['data']['actor'] ?? null;

        if (! is_array($actor) || ! isset($actor['id'])) {
            return null;
        }

        return new IdentitySummary((string) $actor['id'], (string) ($actor['name'] ?? 'A forum member'), $actor['username'] ?? null, $actor['avatar'] ?? null);
    }

    public function describe(InstallationRef $installation, CredentialBag $credentials, string $externalId): IdentitySummary
    {
        $user = $this->agora->for($installation, $credentials)->get('/users/'.$externalId)->json('data', []);

        return new IdentitySummary($externalId, (string) ($user['name'] ?? 'A forum member'), $user['username'] ?? null, $user['avatar'] ?? null);
    }

    public function adopt(InstallationRef $installation, IdentitySummary $identity): IdentityRecord
    {
        return new IdentityRecord(
            connector: 'agora',
            externalId: $identity->externalId,
            installationId: $installation->id,
            displayName: $identity->displayName,
            username: $identity->username,
            avatarUrl: $identity->avatarUrl,
            storageRef: null,
        );
    }

    public function deliveryTarget(InstallationRef $installation, CredentialBag $credentials, IdentityRef $identity): ?Recipient
    {
        return $identity->connector === 'agora' && $installation->connector === 'agora' ? new Recipient($identity) : null;
    }
}

The one decision that matters for the life of the connector is installationId. An Agora user id is per forum (two forums each have a user 17), so the record names the installation and the core files the account per forum. Telegram's user id is global, so its records carry null. Get this right now; it is the natural key of the identity row.

adopt() keeps no row of ours (storageRef: null): everything we need to message an account is its id and the forum's key, both of which the core hands us per call.

deliveryTarget() is the core asking, before it sends, whether this forum can reach the account. Agora lets the bot message any member of its forum, so every account of ours is reachable and the answer is a Recipient; a platform where people can close their inbox would answer null for them, and the core would pass over the account for the member's next connected one, or email. Answer from what you hold, never by calling the platform: the core asks on every notice.

The route and its controller

// routes/inbound.php
use Acme\Connectors\Agora\Http\WebhookController;
use Illuminate\Support\Facades\Route;

Route::post(config('connector-agora.webhook_path').'/{botUser}', WebhookController::class)->name('connector-agora.webhook');

The SDK's provider wraps this file in connector.inbound:agora, so by the time the controller runs the call is authenticated, every event in it is recorded once, and a paused connector has already answered 503.

<?php

declare(strict_types=1);

namespace Acme\Connectors\Agora\Http;

use Acme\Connectors\Agora\Ports\AgoraInboundGateway;
use Acme\Connectors\Agora\Webhooks\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

final class WebhookController
{
    public function __construct(
        private readonly AgoraInboundGateway $gateway,
        private readonly Dispatcher $dispatcher,
    ) {}

    public function __invoke(Request $request): Response
    {
        foreach ($this->gateway->decode($request) as $envelope) {
            $this->dispatcher->dispatch($envelope);
        }

        return response()->noContent();
    }
}

Dispatcher is ours: a match on $envelope->kind that hands message.created to the portal-login and reply handling of chapters 5 and 7, board.member_left to a reconcile nudge in chapter 6, and board.picked to the space catalogue. The core does not dispatch envelopes itself yet, so this small class is where a connector's handling starts.

Test it through the gate

The gate is the core's, so the test posts through the real route on a Subscriby checkout:

it('refuses an unsigned webhook and accepts a signed one once', function (): void {
    $installation = connectAgoraForum();       // a helper from chapter 3's test
    $body = json_encode(['id' => 'evt_1', 'type' => 'message.created', 'data' => ['actor' => ['id' => '17', 'name' => 'Ada']]]);
    $signature = 'sha256='.hash_hmac('sha256', $body, webhookSecretOf($installation));

    $this->postJson(route('connector-agora.webhook', ['botUser' => 'u_bot']), json_decode($body, true))
        ->assertForbidden();

    $this->call('POST', route('connector-agora.webhook', ['botUser' => 'u_bot']), [], [], [], ['HTTP_X_AGORA_SIGNATURE' => $signature, 'CONTENT_TYPE' => 'application/json'], $body)
        ->assertNoContent();

    $this->call('POST', route('connector-agora.webhook', ['botUser' => 'u_bot']), [], [], [], ['HTTP_X_AGORA_SIGNATURE' => $signature, 'CONTENT_TYPE' => 'application/json'], $body)
        ->assertNoContent();               // replayed: recorded once, controller ran once
});

Sign the raw body

Compute the HMAC over $request->getContent(), never over a re-encoded array: JSON re-serialisation changes key order and whitespace and the signature stops matching on the first forum that formats JSON differently.

How is this guide?

On this page

Subscriby is a product designed by you — for you.

No boardroom full of executives deciding what we ships next. Our roadmap always shaped by you with your feedback.

Share feedback or a request