NovinkaZverejnili sme 50+ Laravel balíkov ako open source
Custom AI apps, agents and automation — Roundly ConsultingRoundly
Všetky balíky
Webhooks for Laravel

Podpisové schémy

Schémy sú ovládače registrované podľa názvu: default, rfc9421 a vaše vlastné (webhooks.schemes alebo Webhooks::schemes()->extend() v boot() service providera). Endpoint si ukladá názov schémy; neznámy názov odpovie 401 unknown_scheme a webhooks:doctor ho nahlási. Každý HMAC — overenie aj podpis — beží cez Sentinel::macs() zo Sentinelu: v konštantnom čase voči každému kandidátskemu kľúču, bez predčasného ukončenia. Webhooks nikdy sám nepočíta ani neporovnáva MAC a schéma nikdy neuvidí tajný kľúč.

default — Standard Webhooks v1

Otvorený formát Standard Webhooks bajt po bajte. Tá istá schéma overuje prichádzajúce požiadavky aj podpisuje odchádzajúce doručenia, takže existujúce knižnice Standard Webhooks vaše doručenia overia bez úprav:

HlavičkaHodnota
webhook-idId doručenia: 1–255 bajtov viditeľného ASCII. Naše: msg_<26-znakový ULID>, rovnaké pri každom pokuse aj opätovnom doručení.
webhook-timestampCelé Unix sekundy (UTC) tohto pokusu — len číslice.
webhook-signatureJedna alebo viac položiek v1,<base64> oddelených jednou medzerou — štandardné base64 (s doplnením =) 32-bajtového HMAC-SHA256. Neznáme verzie sa ignorujú.
content-typeapplication/json (odchádzajúce).
key            = base64_decode(secret without the "whsec_" prefix)
signed_content = webhook-id + "." + webhook-timestamp + "." + body     # body = the exact raw bytes
signature      = "v1," + base64(HMAC_SHA256(key, signed_content))

Žiadna kanonizácia, žiadny znova serializovaný JSON, žiadny pridaný nový riadok: overuje sa surové telo. Tajný kľúč bez prefixu whsec_ (importovaný od odosielateľa, ktorý rozdáva obyčajné reťazce) sú jeho vlastné bajty. Na strane príjmu:

  • Všetky tri hlavičky musia byť prítomné — inak missing_signature, missing_timestamp alebo missing_id.
  • webhook-timestamp zodpovedá ^[0-9]{1,12}$ (invalid_timestamp) a je v rozsahu ± tolerancie od teraz — predvolene 300 s, do minulosti aj budúcnosti (stale_timestamp).
  • Najviac 8 položiek a 4 096 bajtov vo webhook-signature; prijme sa, ak ktorákoľvek položka v1 zodpovedá ktorémukoľvek platnému kľúču — aktuálnemu a počas prechodného obdobia aj predchádzajúcemu.
  • webhook-id je kľúčom idempotencie.

Testovacie vektory

secretwebhook-idwebhook-timestampbodywebhook-signature
whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSwmsg_p5jXN8AQM9LWM0D4loKWxJek1614265330{"test": 2432232314}v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=msg_01JCDEF00000000000000000001794139200{"type":"invoice.paid","timestamp":"2026-11-08T12:00:00Z","data":{"invoice":42}}v1,dBcYxACDimm+8xx9Ykx71LVUgk5H4STQXxAUHZ5xcNk=
whsec_ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8= (nový) alebo ten z riadku vyššie (starý)ako vyššieako vyššieako vyššiev1,Nwl36Rvbn1xO29I4VEpYVwe7HJ3vZuLKGz6KoEVZDFI= v1,dBcYxACDimm+8xx9Ykx71LVUgk5H4STQXxAUHZ5xcNk=

Prvý je zverejnený vektor Standard Webhooks; tretí ukazuje rotáciu s podpisom novým aj starým kľúčom. Testy balíka každý z nich prepočítajú obyčajným hash_hmac.

rfc9421 — podpisy HTTP správ

Prichádzajúci endpoint overuje cez HTTP podpisy Sentinelu voči svojmu scheme_options.profile (predvolene webhooks.rfc9421.profile). max_age a clock_skew zo Sentinelu nahrádzajú toleranciu, jeho úložisko nonce kontrolu opakovania a jeho content digest kontrolu tela. Podpisový kľúč musí byť importovaný do Sentinelu a naviazaný na tento endpoint — kľúč, ktorý vlastní niečo iné, odpovie 401 key_not_bound:

$endpoint = Webhooks::inbound()->create(scheme: 'rfc9421', options: new InboundEndpointOptions(
    schemeOptions: ['profile' => 'partners', 'id_header' => 'webhook-id'],
))->endpoint;

Sentinel::keys()->ring('http')->import('acme-2026', Algorithm::Ed25519, $partnerPublicKeyPem, owner: $endpoint);
  • Id doručenia sa berie zo scheme_options.id_header (predvolene webhook-id), inak z Idempotency-Key, inak z nonce podpisu.
  • Za podpísané sa považuje len vtedy, ak je táto hlavička medzi komponentmi, ktoré podpis pokrýva. Predvolený profil Sentinelu pokrýva @method @authority @path (+ content-digest), preto hlavičku s id pridajte do komponentov profilu — aj odosielateľa; inak webhooks:doctor upozorní.
  • Ochrana balíka pred opakovaním a väzba na telo sa spoliehajú na require_nonce a require_content_digest; webhooks:doctor označí profil, ktorý niektorú z nich vypína.
  • Odchádzajúce endpointy RFC 9421 podpisujú kľúčom Sentinelu, ktorým aplikácia vie podpisovať (scheme_options.key_id, inak webhooks.rfc9421.outbound_key_id), a pokrývajú @method @authority @path content-digest content-type webhook-id webhook-timestamp, pri URL s query aj @query. Bez id kľúča doručenie zlyhá so signing, bez požiadavky.

Vlastná schéma

interface SignatureScheme
{
    /** @throws WebhookSignatureException */
    public function verify(IncomingWebhook $request, EndpointSecrets $secrets): VerifiedWebhook;
}

interface SignsOutboundWebhooks   // optional: the scheme can sign outbound deliveries too
{
    public function sign(OutgoingWebhook $message, EndpointSecrets $secrets): SignatureHeaders;
}
  • IncomingWebhook nesie headers (malými písmenami, prvá hodnota), body (surové bajty), receivedAt, endpointKey, toleranceSeconds, schemeOptions a request; header($name).
  • EndpointSecrets nikdy neprezradí tajný kľúč: matches($message, $macBytes, SecretEncoding, Algorithm = HmacSha256): ?SecretSlot a sign($message, SecretEncoding, Algorithm): list<string> (surové MAC, aktuálny prvý) — oba cez Sentinel.
  • SecretEncoding::StandardWebhooks číta bajty za whsec_ (alebo bajty obyčajného kľúča); SecretEncoding::Raw použije vlastné bajty reťazca vrátane whsec_. Vyberá ho schéma — podpisové kľúče Stripe začínajú na whsec_, no používajú sa surové.
  • Vráťte VerifiedWebhook(deliveryId, timestamp, eventType, idIsSigned, slot, replayProtected: false, commit: null). idIsSigned nastavte len vtedy, ak MAC pokrýva vrátené id; verdikt bez id sa nikdy nepovažuje za podpísaný. replayProtected nastavte len vtedy, ak schéma sama odmietne druhé použitie presne tej istej požiadavky; commit je closure, ktorá toto použitie zaznamená a beží v transakcii ukladajúcej doručenie.
  • Vyhoďte WebhookSignatureException::because(RejectionReason::…). Ak implementujete aj SignsOutboundWebhooks, schéma podpisuje odchádzajúce doručenia — a SignedWebhookRequest ňou podpíše vaše testovacie požiadavky.

Vygenerovanie receptu

Balík neobsahuje ovládače konkrétnych poskytovateľov. webhooks:make-scheme zapíše triedu schémy do app/Webhooks/Schemes z receptu — github, stripe alebo blank — a trieda je váš vlastný kód, ktorý si prispôsobíte:

php artisan webhooks:make-scheme github            # app/Webhooks/Schemes/GitHubScheme.php
php artisan webhooks:make-scheme stripe            # app/Webhooks/Schemes/StripeScheme.php
php artisan webhooks:make-scheme blank MySenderScheme
// a service provider's boot()
Webhooks::schemes()->extend('github', GitHubScheme::class);

Webhooks::inbound()->create(scheme: 'github', secret: $secretYouEnteredInGitHub, handler: 'github');

V štýle GitHubu: X-Hub-Signature-256: sha256=<hex HMAC-SHA256(body)>, X-GitHub-Delivery a X-GitHub-Event; kľúčom sú vlastné bajty tajného kľúča. Časová pečiatka chýba, takže ochranu pred opakovaním tvorí len deduplikácia — pri takejto schéme je telo zhodné bajt po bajte duplicitou, kým existuje riadok prvého doručenia, a odosielateľ, ktorý oprávnene opakuje rovnaké telo, ho musí odlíšiť. Čo zapíše recept github:

<?php

declare(strict_types=1);

namespace App\Webhooks\Schemes;

use RoundlyConsulting\Crypto\Codec\InvalidEncodingException;
use RoundlyConsulting\Crypto\Facades\Crypto;
use RoundlyConsulting\Webhooks\Contracts\SignatureScheme;
use RoundlyConsulting\Webhooks\Contracts\SignsOutboundWebhooks;
use RoundlyConsulting\Webhooks\DataTransferObjects\IncomingWebhook;
use RoundlyConsulting\Webhooks\DataTransferObjects\OutgoingWebhook;
use RoundlyConsulting\Webhooks\DataTransferObjects\SignatureHeaders;
use RoundlyConsulting\Webhooks\DataTransferObjects\VerifiedWebhook;
use RoundlyConsulting\Webhooks\Enums\RejectionReason;
use RoundlyConsulting\Webhooks\Enums\SecretEncoding;
use RoundlyConsulting\Webhooks\Exceptions\WebhookSignatureException;
use RoundlyConsulting\Webhooks\Schemes\EndpointSecrets;

final readonly class GitHubScheme implements SignatureScheme, SignsOutboundWebhooks
{
    public function verify(IncomingWebhook $request, EndpointSecrets $secrets): VerifiedWebhook
    {
        $header = $request->header('x-hub-signature-256') ?? throw WebhookSignatureException::because(RejectionReason::MissingSignature);

        try {
            $mac = str_starts_with($header, 'sha256=') ? Crypto::hexDecode(substr($header, 7)) : '';
        } catch (InvalidEncodingException) {
            $mac = '';
        }

        $slot = $secrets->matches($request->body, $mac, SecretEncoding::Raw) ?? throw WebhookSignatureException::because(RejectionReason::InvalidSignature);

        return new VerifiedWebhook($request->header('x-github-delivery'), null, $request->header('x-github-event'), false, $slot);
    }

    public function sign(OutgoingWebhook $message, EndpointSecrets $secrets): SignatureHeaders
    {
        return new SignatureHeaders([
            'X-Hub-Signature-256' => 'sha256='.Crypto::hexEncode($secrets->sign($message->body, SecretEncoding::Raw)[0]),
            'X-GitHub-Delivery' => $message->id,
            'X-GitHub-Event' => $message->eventType,
        ]);
    }
}

V štýle Stripe: Stripe-Signature: t=<ts>,v1=<hex HMAC-SHA256(„t.body“)>; kľúčom je celý reťazec whsec_… ako surové bajty. Id udalosti je vnútri podpísaného tela, preto sa považuje za podpísané; telo bez reťazcového id si podpísané id nenárokuje a spracovanie použije nepodpísaný Idempotency-Key, prípadne odmietne s 400. Čo zapíše recept stripe:

<?php

declare(strict_types=1);

namespace App\Webhooks\Schemes;

use Carbon\CarbonImmutable;
use RoundlyConsulting\Crypto\Codec\InvalidEncodingException;
use RoundlyConsulting\Crypto\Facades\Crypto;
use RoundlyConsulting\Webhooks\Contracts\SignatureScheme;
use RoundlyConsulting\Webhooks\Contracts\SignsOutboundWebhooks;
use RoundlyConsulting\Webhooks\DataTransferObjects\IncomingWebhook;
use RoundlyConsulting\Webhooks\DataTransferObjects\OutgoingWebhook;
use RoundlyConsulting\Webhooks\DataTransferObjects\SignatureHeaders;
use RoundlyConsulting\Webhooks\DataTransferObjects\VerifiedWebhook;
use RoundlyConsulting\Webhooks\Enums\RejectionReason;
use RoundlyConsulting\Webhooks\Enums\SecretEncoding;
use RoundlyConsulting\Webhooks\Exceptions\WebhookSignatureException;
use RoundlyConsulting\Webhooks\Schemes\EndpointSecrets;

final readonly class StripeScheme implements SignatureScheme, SignsOutboundWebhooks
{
    /** The most `v1=` entries checked, and the longest header — each entry costs a MAC per secret. */
    private const int MAX_ENTRIES = 8;

    private const int MAX_HEADER_BYTES = 4096;

    public function verify(IncomingWebhook $request, EndpointSecrets $secrets): VerifiedWebhook
    {
        $header = $request->header('stripe-signature') ?? throw WebhookSignatureException::because(RejectionReason::MissingSignature);
        $parts = [];

        if (strlen($header) > self::MAX_HEADER_BYTES) {
            throw WebhookSignatureException::because(RejectionReason::InvalidSignature);
        }

        foreach (explode(',', $header) as $part) {
            [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
            $parts[$key][] = $value;
        }

        $timestamp = $parts['t'][0] ?? '';

        if (preg_match('/^\d{1,12}$/', $timestamp) !== 1) {
            throw WebhookSignatureException::because(RejectionReason::InvalidTimestamp);
        }

        if (abs($request->receivedAt->getTimestamp() - (int) $timestamp) > $request->toleranceSeconds) {
            throw WebhookSignatureException::because(RejectionReason::StaleTimestamp);
        }

        if (count($parts['v1'] ?? []) > self::MAX_ENTRIES) {
            throw WebhookSignatureException::because(RejectionReason::InvalidSignature);
        }

        foreach ($parts['v1'] ?? [] as $hex) {
            try {
                $slot = $secrets->matches($timestamp.'.'.$request->body, Crypto::hexDecode($hex), SecretEncoding::Raw);
            } catch (InvalidEncodingException) {
                continue;
            }

            if ($slot !== null) {
                $event = json_decode($request->body, true);
                $id = is_array($event) && is_string($event['id'] ?? null) ? $event['id'] : null;

                // Signed only when the body carries it; without one the id is an unsigned header.
                return new VerifiedWebhook($id, CarbonImmutable::createFromTimestampUTC((int) $timestamp), null, $id !== null, $slot);
            }
        }

        throw WebhookSignatureException::because(RejectionReason::InvalidSignature);
    }

    public function sign(OutgoingWebhook $message, EndpointSecrets $secrets): SignatureHeaders
    {
        $timestamp = (string) $message->timestamp->getTimestamp();
        $signatures = array_map(static fn (string $mac): string => 'v1='.Crypto::hexEncode($mac), $secrets->sign($timestamp.'.'.$message->body, SecretEncoding::Raw));

        return new SignatureHeaders(['Stripe-Signature' => 't='.$timestamp.','.implode(',', $signatures)]);
    }
}

Prejavte lásku k open source

Tento balík je zadarmo pod licenciou MIT. Ak vám šetrí čas, jednorazový príspevok alebo členstvo na Patreone nám pomôže ho ďalej udržiavať, testovať a dokumentovať.

Ďalšie spôsoby podpory vrátane kryptomien

Odoslaním daru súhlasíte s našimi podmienkami prijímania darov.

Chcete to zabudovať do svojho produktu?

Naše balíky integrujeme do zákazkových Laravel a AI riešení. Napíšte nám, na čom pracujete, a ozveme sa do 48 hodín.