---
title: "Receiving webhooks — Webhooks for Laravel | Roundly"
description: "Inbound endpoints, the fixed-order pipeline and its status codes, replay protection and idempotency, and mounting the route yourself."
url: https://roundly-consulting.com/open-source/docs/webhooks-for-laravel/receiving
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

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

# Receiving webhooks

One endpoint per sender. The URL key — 32 random alphanumeric characters, about 190 bits — is the address; the secret is shown once. Generate one, or import the secret the sender gave you verbatim:

```php
use RoundlyConsulting\Webhooks\DataTransferObjects\InboundEndpointOptions;
use RoundlyConsulting\Webhooks\Facades\Webhooks;

$created = Webhooks::for($team)->inbound()->create(handler: 'billing');   // owned by $team
$created = Webhooks::inbound()->create(handler: 'billing');               // owner-less
$created = Webhooks::inbound()->create(
    handler: 'github', scheme: 'github', name: 'GitHub',
    secret: $theSecretGitHubShowedYou,                                   // imported verbatim
    options: new InboundEndpointOptions(rateLimitPerMinute: 120, maxPayloadBytes: 1_048_576,
        contentTypes: ['application/json'], toleranceSeconds: 60),
);

$created->endpoint;   // InboundEndpoint
$created->url;        // route('webhooks.inbound', key) — or inbound.route.url_base + '/' + key
$created->secret;     // whsec_… (generated) — never readable again
```

An unknown handler alias throws UnknownHandlerException, an unknown scheme UnknownSchemeException, and an owner whose key does not fit webhooks.key\_type throws InvalidWebhooksConfigurationException before anything is written. Options are checked against the limits in Managing endpoints.

## The pipeline — fixed order, first failure wins

POST {prefix}/{key} (default /webhooks/{key}, route name webhooks.inbound) runs these steps. None of the refusals stores a delivery or queues anything:

| # | Check | Answer | Event |
| --- | --- | --- | --- |
| 1 | The key is 32 alphanumerics | `404 not_found` | — |
| 2 | Endpoint lookup — unknown, soft-deleted or disabled | The same 404 body | Disabled only: WebhookRejected(endpoint\_disabled) |
| 3 | Rejection guard per endpoint and IP (60 a minute, hit only by rejections at 4–7) | `429 rate_limited + Retry-After` | Once per window |
| 4 | Size — Content-Length over the limit, or the body read up to limit + 1 bytes | `413 payload_too_large` | `WebhookRejected` |
| 5 | Content type (parameters ignored) | `415 unsupported_content_type` | `WebhookRejected` |
| 5b | Per-endpoint limit on verified deliveries (600 a minute) — checked here, counted only once the signature verified | `429 + Retry-After` | Once per window |
| 6 | Signature and timestamp — the endpoint’s scheme | `401 missing_signature · invalid_signature · missing_timestamp · invalid_timestamp · stale_timestamp · missing_id · key_not_bound · unknown_scheme · replayed` | `WebhookRejected` |
| 7 | JSON decode — arrays only, depth ≤ 64, big integers as strings; a scalar top level is refused | `400 invalid_payload` | `WebhookRejected` |
| 8 | Replay and idempotency | Duplicate → 200 (or 409 conflict); same id, other body → 409 idempotency\_key\_reused; in flight → 409 in\_progress + Retry-After: 1; no id → 400 missing\_id | Reused or missing id: WebhookRejected |
| 9 | Sanitize → store (+ commit an RFC 9421 nonce, one transaction) → queue ProcessInboundDelivery | 202 accepted; a failing sanitizer → 500 sanitizer\_failed | WebhookReceived fires on the worker |

- Size and content type run before the signature: an unauthenticated caller cannot make the app compute MACs over megabytes. The delivery limit is checked before it too, but only a verified delivery counts.
- The rejection guard and the delivery limit are separate counters — a stranger’s bad signatures from another IP never spend the real sender’s budget, as long as $request->ip() is the client’s address (configure trusted proxies).
- Unknown keys fire no event, so a stranger cannot spam your listeners; a rate-limited rejection fires once per limiter window; a duplicate fires nothing.
- Refusals are application/problem+json (RFC 9457) with a translated title — no internals, no class names.

```json
// 202 — stored and queued
{"status": "accepted", "id": "01JCDEF0000000000000000000"}

// 200 — a duplicate (inbound.on_duplicate = accept)
{"status": "duplicate"}

// any refusal — application/problem+json (RFC 9457), no internals, no class names
{"title": "…", "status": 401, "code": "invalid_signature"}
```

## Replay protection and idempotency

- The key is the scheme’s delivery id (default scheme: the signed webhook-id), else the Idempotency-Key header (quoted or a bare token, while inbound.accept\_idempotency\_header is on), else 400. A key over 255 bytes, or not printable ASCII, becomes sha256:<hex>.
- Deduplication runs through Sentinel’s idempotency store, scoped per endpoint, fingerprinted by the body’s SHA-256 and remembered for inbound.dedupe\_ttl (7 days): the first request stores and queues, a repeat is a duplicate, the same key with another body is idempotency\_key\_reused.
- A signed id wins: a forged Idempotency-Key cannot split one signed message into two.
- Unsigned ids (a GitHub-style delivery header, a bare Idempotency-Key) get a second guard keyed on the body digest, so an identical body under a fresh id is a duplicate. For a format with no signed timestamp either, that guard also checks the stored deliveries — an identical body stays a duplicate for as long as the first delivery’s row is kept (payloads.delivery\_retention\_days), not only for the dedupe window.
- A unique index on (endpoint\_id, idempotency\_key) is the backstop: a delivery whose id the store has forgotten is still one delivery.
- A store that fails after the delivery was stored and queued does not turn the sender’s success into an error — the request still answers 202.
- A delivery stored while the queue was down is queued by its next duplicate, and by the sweep after inbound.requeue\_after (60 s).

RFC 9421 nonces use Sentinel’s deferred-nonce API: the signature step checks the nonce is unused, and the store step records it in the same database transaction that writes the delivery, before the 202 and before the handler’s job is queued. A request refused on the way — 400, 500 sanitizer\_failed, a database error — leaves its nonce free, so the sender’s unchanged resend is judged afresh; of two concurrent copies exactly one is stored. Sentinel’s nonce store must implement InspectsNonces (its database and cache stores do). See [Sentinel’s deferred nonces](https://roundly-consulting.com/open-source/docs/sentinel-for-laravel/http-signatures.md) and [idempotency](https://roundly-consulting.com/open-source/docs/sentinel-for-laravel/idempotency.md).

## Verify without the pipeline

For a flow of your own, verify() runs the endpoint’s scheme only — no size check, no dedupe, nothing stored (an RFC 9421 nonce is recorded at once):

```php
use RoundlyConsulting\Webhooks\Exceptions\WebhookSignatureException;

try {
    $verified = Webhooks::inbound()->verify($endpoint, $request);   // the scheme only — nothing stored
    $verified->deliveryId;   // also: timestamp, eventType, idIsSigned, slot
} catch (WebhookSignatureException $e) {
    $e->reason();            // RejectionReason, e.g. RejectionReason::StaleTimestamp
}
```

## Your own route

Turn the package route off with WEBHOOKS\_INBOUND\_ROUTE=off and mount it where you like. Route::webhooks() mounts POST {prefix}/{key} named webhooks.inbound (endpoint URLs follow it) and switches TrimStrings and ConvertEmptyStringsToNull off for that prefix — the pipeline verifies and decodes the raw body itself. Never put it under the web middleware group: a sender has no session or CSRF token.

```php
// WEBHOOKS_INBOUND_ROUTE=off, then in routes/web.php or routes/api.php:
Route::webhooks('hooks', middleware: ['throttle:120,1']);
```

A route of your own design calls the pipeline directly — then register the two skip callbacks yourself:

```php
Route::post('/hooks/{key}', fn (Request $request, string $key) => Webhooks::inbound()->receive($key, $request));
TrimStrings::skipWhen(fn (Request $r) => $r->is('hooks/*'));
ConvertEmptyStringsToNull::skipWhen(fn (Request $r) => $r->is('hooks/*'));
```

Set webhooks.inbound.route.url\_base (WEBHOOKS\_INBOUND\_URL) so ->url shows the address senders actually use, for example behind a proxy.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/webhooks-for-laravel.md)
