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

Návod pre príjemcov

Táto časť je určená tým, ktorí prijímajú webhooky z aplikácie postavenej na Webhooks for Laravel — vašim zákazníkom. Odovzdajte im ju a prispôsobte názvy. Znalosť Laravelu nie je potrebná: tieto správy overí akýkoľvek jazyk, ktorý vie vypočítať HMAC-SHA256.

Každý webhook je HTTP POST s telom JSON, podpísaný tajným kľúčom, ktorý poznáte len vy a odosielateľ. Formát je Standard Webhooks v1: existujúce knižnice Standard Webhooks ho overia bez úprav a ukážky nižšie robia to isté na niekoľkých riadkoch.

Čo príde

POST /your/webhook/url HTTP/1.1
Content-Type: application/json
User-Agent: Acme Webhooks
webhook-id: msg_01JCDEF0000000000000000000
webhook-timestamp: 1794139200
webhook-signature: v1,dBcYxACDimm+8xx9Ykx71LVUgk5H4STQXxAUHZ5xcNk=

{"type":"invoice.paid","timestamp":"2026-11-08T12:00:00Z","data":{"invoice":42}}
HlavičkaVýznam
webhook-idId správy (msg_ + 26 znakov). Rovnaké pri každom opakovaní aj ručnom opätovnom odoslaní — deduplikujte podľa neho.
webhook-timestampKedy bol tento pokus podpísaný, v Unix sekundách (UTC). Pri každom pokuse nová hodnota.
webhook-signatureJedna alebo viac položiek v1,<base64> oddelených medzerou. Počas rotácie tajného kľúča sú dve — jedna na každý kľúč, nový prvý. Správu prijmite, ak sa zhoduje ktorákoľvek položka.
User-AgentNázov odosielateľa (nastaviteľný) — len informatívne, nikdy mu nedôverujte.

Telo je obálka. type je typ udalosti, ktorý odoberáte, timestamp je čas, keď udalosť nastala (RFC 3339, UTC — udalosti radíte podľa neho, nie podľa príchodu), data je samotná udalosť. Odosielateľ môže obálku vypnúť a poslať dáta tak, ako sú; podpis funguje rovnako.

{"type": "invoice.paid", "timestamp": "2026-11-08T12:00:00Z", "data": {"invoice": 42}}

Tajný kľúč

Tajný kľúč sa vám zobrazil raz, pri vytvorení endpointu: whsec_ nasledované base64. Kľúčom HMAC sú bajty po dekódovaní base64 za prefixom (32 náhodných bajtov). Tajný kľúč bez prefixu whsec_ — taký, ktorý ste zvolili vy a dali odosielateľovi — sa použije ako vlastné bajty. Chráňte ho ako heslo. Počas rotácie odosielateľ istý čas podpisuje starým aj novým kľúčom (predvolene deň), takže prejdete vlastným tempom.

Overenie — algoritmus

key            = base64_decode(secret without "whsec_")          # or the secret's own bytes
signed_content = webhook-id + "." + webhook-timestamp + "." + raw_body
expected       = base64(HMAC-SHA256(key, signed_content))
valid          = any entry "v1,<x>" in webhook-signature where <x> == expected   (constant-time compare)
                 and |now - webhook-timestamp| <= 300 seconds

Použite surové bajty tela presne tak, ako prišli. Nikdy znova neserializujte rozparsovaný JSON (zmení sa poradie kľúčov, medzery aj escapovanie unicode), nikdy ho neorezávajte a nikdy ho nenechajte najprv dekódovať frameworku.

Získanie surového tela

ProstredieSurové bajty
Node — Expressapp.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => …) → req.body (Buffer)
Node — Fastifyfastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => done(null, body)) → request.body
Python — Flaskrequest.get_data() — before touching request.json
Python — Djangorequest.body
Go — net/httpbody, err := io.ReadAll(r.Body)
Ruby — Railsrequest.raw_post
PHP — plainfile_get_contents('php://input')
PHP — Laravel$request->getContent() — keep TrimStrings and ConvertEmptyStringsToNull off the route

PHP

Každá funkcia nižšie vráti true len pre čerstvú a správne podpísanú správu. Je to presne ten kód, ktorý testy balíka spúšťajú voči zverejneným testovacím vektorom aj skutočným doručeniam.

<?php

declare(strict_types=1);

function verifyWebhook(string $secret, string $id, string $timestamp, string $signature, string $body, ?int $now = null): bool
{
    // "whsec_" + base64 → the decoded bytes; any other secret is used as its own bytes.
    $key = str_starts_with($secret, 'whsec_') ? base64_decode(substr($secret, 6), true) : $secret;

    if ($key === false || preg_match('/^\d{1,12}$/', $timestamp) !== 1 || abs(($now ?? time()) - (int) $timestamp) > 300) {
        return false;
    }

    $expected = base64_encode(hash_hmac('sha256', $id.'.'.$timestamp.'.'.$body, $key, true));

    // Several "v1,<base64>" entries during a secret rotation: any one may match.
    foreach (explode(' ', $signature) as $entry) {
        [$version, $mac] = array_pad(explode(',', $entry, 2), 2, '');

        if ($version === 'v1' && hash_equals($expected, $mac)) {
            return true;
        }
    }

    return false;
}
$valid = verifyWebhook($secret, $_SERVER['HTTP_WEBHOOK_ID'], $_SERVER['HTTP_WEBHOOK_TIMESTAMP'], $_SERVER['HTTP_WEBHOOK_SIGNATURE'], file_get_contents('php://input'));

Node

const crypto = require('node:crypto');

function verifyWebhook(secret, id, timestamp, signature, rawBody, now = Math.floor(Date.now() / 1000)) {
  // "whsec_" + base64 → the decoded bytes; any other secret is used as its own bytes.
  const key = secret.startsWith('whsec_') ? Buffer.from(secret.slice(6), 'base64') : Buffer.from(secret, 'utf8');

  if (!/^\d{1,12}$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) {
    return false;
  }

  const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();

  // Several "v1,<base64>" entries during a secret rotation: any one may match.
  return signature.split(' ').some((entry) => {
    const [version, mac = ''] = entry.split(',');
    const given = Buffer.from(mac, 'base64');

    return version === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

module.exports = { verifyWebhook };
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhook(process.env.WEBHOOK_SECRET, req.get('webhook-id'), req.get('webhook-timestamp'), req.get('webhook-signature'), req.body);
  if (!ok) return res.status(401).end();
  res.status(204).end();   // then process req.body asynchronously
});

Python

import base64
import hashlib
import hmac
import re
import time


def verify_webhook(secret, msg_id, timestamp, signature, raw_body, now=None):
    # "whsec_" + base64 → the decoded bytes; any other secret is used as its own bytes.
    key = base64.b64decode(secret[6:]) if secret.startswith("whsec_") else secret.encode()
    now = time.time() if now is None else now

    if not re.fullmatch(r"\d{1,12}", timestamp) or abs(now - int(timestamp)) > 300:
        return False

    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    # Several "v1,<base64>" entries during a secret rotation: any one may match.
    for entry in signature.split(" "):
        version, _, mac = entry.partition(",")
        if version == "v1" and hmac.compare_digest(expected, mac):
            return True

    return False
valid = verify_webhook(secret, request.headers["webhook-id"], request.headers["webhook-timestamp"], request.headers["webhook-signature"], request.get_data())

Go

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"regexp"
	"strconv"
	"strings"
	"time"
)

var unixSeconds = regexp.MustCompile(`^\d{1,12}$`)

func VerifyWebhook(secret, id, timestamp, signature string, body []byte, now time.Time) bool {
	// "whsec_" + base64 → the decoded bytes; any other secret is used as its own bytes.
	key := []byte(secret)
	if strings.HasPrefix(secret, "whsec_") {
		decoded, err := base64.StdEncoding.DecodeString(secret[6:])
		if err != nil {
			return false
		}
		key = decoded
	}

	if !unixSeconds.MatchString(timestamp) {
		return false
	}
	signedAt, _ := strconv.ParseInt(timestamp, 10, 64)
	if age := now.Unix() - signedAt; age > 300 || age < -300 {
		return false
	}

	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + timestamp + "."))
	mac.Write(body)
	expected := mac.Sum(nil)

	// Several "v1,<base64>" entries during a secret rotation: any one may match.
	for _, entry := range strings.Split(signature, " ") {
		version, encoded, _ := strings.Cut(entry, ",")
		given, err := base64.StdEncoding.DecodeString(encoded)
		if version == "v1" && err == nil && hmac.Equal(given, expected) {
			return true
		}
	}

	return false
}
body, _ := io.ReadAll(r.Body)
valid := VerifyWebhook(secret, r.Header.Get("webhook-id"), r.Header.Get("webhook-timestamp"), r.Header.Get("webhook-signature"), body, time.Now())

Ruby

require 'base64'
require 'openssl'

def verify_webhook(secret, id, timestamp, signature, raw_body, now = Time.now.to_i)
  # "whsec_" + base64 → the decoded bytes; any other secret is used as its own bytes.
  key = secret.start_with?('whsec_') ? Base64.strict_decode64(secret[6..]) : secret

  return false unless timestamp.match?(/\A\d{1,12}\z/) && (now - timestamp.to_i).abs <= 300

  expected = OpenSSL::HMAC.digest('SHA256', key, "#{id}.#{timestamp}.".b + raw_body.b)

  # Several "v1,<base64>" entries during a secret rotation: any one may match.
  signature.split(' ').any? do |entry|
    version, mac = entry.split(',', 2)
    given = Base64.decode64(mac.to_s)

    version == 'v1' && given.bytesize == expected.bytesize && OpenSSL.secure_compare(given, expected)
  end
rescue ArgumentError
  false
end
valid = verify_webhook(secret, request.headers['webhook-id'], request.headers['webhook-timestamp'], request.headers['webhook-signature'], request.raw_post)

Ruby 3.0 alebo novšie, kvôli OpenSSL.secure_compare.

Testovacie vektory

Pred spustením do prevádzky overte svoju implementáciu voči týmto vektorom, s now nastaveným na časovú pečiatku:

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=

Ako odpovedať

OdpovieteOdosielateľ
Akékoľvek 2xx do 10 sekúnd (pripojenie: 5 s)Považuje ju za doručenú. Odpovedzte rýchlo a spracujte až potom — pomalá odpoveď je timeout a timeout sa opakuje.
410 GoneOkamžite prestane posielať na tento endpoint (vypne sa) — spôsob, ako sa odhlásiť.
429 alebo 503 s Retry-After (sekundy alebo HTTP dátum)Pred ďalším pokusom počká aspoň toľko (najviac deň).
Čokoľvek iné — iné 4xx, 5xx, 3xx, timeout, odmietnuté spojenie, chyba TLSOpakuje s rastúcimi pauzami: 8 pokusov počas približne 24 hodín (1 min, 4 min, 16 min, 1 h, 4 h, potom každých 9 h, s rozptylom).

Po poslednom pokuse sa správa označí ako zlyhaná. Ak doručenia zlyhávajú opakovane — predvolene päť správ po sebe — odosielateľ môže váš endpoint vypnúť a upozorniť svoj tím; keď budete znova dostupní, môže všetko zlyhané odoslať znova. V zázname odosielateľa sa uchová len prvých 4 KB vašej odpovede, preto doň nedávajte nič tajné.

Osvedčené zásady

  • Deduplikujte podľa webhook-id. Správa môže prísť viackrát — opakovanie po stratenej odpovedi, ručné opätovné odoslanie. Ukladajte spracované id (stačí unikátny index) a na opakovanie odpovedzte 2xx bez opätovného spracovania.
  • Raďte podľa timestamp v tele, nie podľa príchodu: kvôli opakovaniam môže neskoršia udalosť prísť skôr.
  • Najprv overte, potom parsujte, a odmietnite správu, ktorej časová pečiatka je od vašich hodín vzdialená viac ako 5 minút — hodiny synchronizujte cez NTP.
  • Rotácia bez výpadku: keď odosielateľ rotuje kľúč, prijímajte oba, kým starý neprestane platiť — podpisová hlavička nesie položku pre každý.
  • Vyskúšajte si to: odosielateľ môže poslať udalosť webhook.ping bez ohľadu na vaše odbery.
{"type":"webhook.ping","data":{"ping":true}}

Príjem v inej aplikácii Laravel

Aplikácia s Webhooks for Laravel ich prijme bez ďalšej práce: vytvorte prichádzajúci endpoint s tajným kľúčom, ktorý ste dostali, a odosielateľovi dajte jeho URL adresu.

$created = Webhooks::inbound()->create(handler: 'billing', secret: $secretTheSenderShowedYou);
$created->url;   // the address to paste into the sender's settings

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.