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čka | Význam |
|---|---|
webhook-id | Id správy (msg_ + 26 znakov). Rovnaké pri každom opakovaní aj ručnom opätovnom odoslaní — deduplikujte podľa neho. |
webhook-timestamp | Kedy bol tento pokus podpísaný, v Unix sekundách (UTC). Pri každom pokuse nová hodnota. |
webhook-signature | Jedna 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-Agent | Ná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 secondsPouž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
| Prostredie | Surové bajty |
|---|---|
| Node — Express | app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => …) → req.body (Buffer) |
| Node — Fastify | fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => done(null, body)) → request.body |
| Python — Flask | request.get_data() — before touching request.json |
| Python — Django | request.body |
| Go — net/http | body, err := io.ReadAll(r.Body) |
| Ruby — Rails | request.raw_post |
| PHP — plain | file_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 Falsevalid = 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
endvalid = 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:
| secret | webhook-id | webhook-timestamp | body | webhook-signature |
|---|---|---|---|---|
whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw | msg_p5jXN8AQM9LWM0D4loKWxJek | 1614265330 | {"test": 2432232314} | v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= | msg_01JCDEF0000000000000000000 | 1794139200 | {"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ššie | ako vyššie | ako vyššie | v1,Nwl36Rvbn1xO29I4VEpYVwe7HJ3vZuLKGz6KoEVZDFI= v1,dBcYxACDimm+8xx9Ykx71LVUgk5H4STQXxAUHZ5xcNk= |
Ako odpovedať
| Odpoviete | Odosielateľ |
|---|---|
| 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 Gone | Okamž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 TLS | Opakuje 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 settingsPrejavte 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 kryptomienOdoslaní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.