---
title: "Návod pre príjemcov — Webhooks for Laravel | Roundly"
description: "Stránka pre vašich zákazníkov: čo príde, surové telo v rôznych prostrediach, overenie v PHP, Node, Pythone, Go a Ruby, vektory a ako odpovedať."
url: https://roundly-consulting.com/sk/open-source/navody/webhooks-for-laravel/navod-pre-prijemcov
language: sk
---

[Všetky balíky](https://roundly-consulting.com/sk/open-source.md)

[Webhooks for Laravel](https://roundly-consulting.com/sk/open-source/navody/webhooks-for-laravel.md)

# 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

```http
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.

```json
{"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

| 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
<?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;
}
```

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

## Node

```js
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 };
```

```js
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

```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
```

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

## Go

```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
}
```

```go
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

```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
```

```ruby
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:

| 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.

```json
{"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.

```php
$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](https://roundly-consulting.com/sk/podporte-nas.md)

Odoslaním daru súhlasíte s našimi [podmienkami prijímania darov](https://roundly-consulting.com/sk/podmienky-darov.md).

[Podporte našu open-source prácu (otvorí sa v novej karte)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Pridajte sa k nám na Patreone (otvorí sa v novej karte)](https://www.patreon.com/cw/roundly)

## 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.

[Ponuka do 48 hodín](https://roundly-consulting.com/sk/kontakt.md) [Prezrieť všetky balíky](https://roundly-consulting.com/sk/open-source/navody/webhooks-for-laravel.md)
