Skip to content
Merged
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## [1.0.0] - 2026-06-16
- Incoming webhook management: token-protected ingress URLs with payload and header logging.
- Incoming webhook management: token-protected ingress URLs with payload and header logging. The incoming address is registered by the package, unauthenticated, since the sender holds only the token in the URL.
- Applications can use `RawPayload` to encode exact request bytes in JSON and decode them later. Incoming event payloads retain their existing parsed structure.
- Signed, retried outbound webhook delivery: outgoing webhooks POST to a customer URL with an HMAC signature, delivered through a queued job with exponential-backoff retries.
- Each delivery attempt is recorded with a stable delivery id for consumer-side deduplication; endpoints are auto-disabled after too many consecutive failures.
- Webhooks are owned through a polymorphic `owner` relation so any model can own one, with `user_id` retained as the creator.
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ return [
'register_routes' => env('WEBHOOKS_REGISTER_ROUTES', true),
'route_prefix' => env('WEBHOOKS_ROUTE_PREFIX', ''),
'route_middleware' => ['auth:sanctum'],
'ingress_middleware' => [],

// Signed, retried outbound delivery.
'signing' => [
Expand Down Expand Up @@ -87,12 +88,58 @@ echo $webhook->url; // https://your-app.com/webhooks/ingress/{token}

### Webhook Ingress

`ingress_middleware` controls middleware on `POST /webhooks/ingress/{token}`.
It defaults to `[]` and does not inherit `route_middleware`, which protects
management routes. The receiving controller checks the URL token and requires an
active webhook; callers do not need a Sanctum login.

To add throttling, set this in your published `config/webhooks.php`:

```php
'ingress_middleware' => ['throttle:60,1'],
```

This applies Laravel's 60-request-per-minute throttle. Application middleware for
provider signature checks can also go in this list. The package does not verify
incoming signatures automatically. Application-wide middleware still applies.

Both route groups use `route_prefix`; setting `register_routes` to `false` disables
both. See [route configuration](docs/customization.md#route-configuration).

Incoming webhooks are sent to a unique URL containing a secure token. When a webhook is triggered:
1. The token is validated.
2. The `trigger_count` and `last_triggered_at` fields are updated.
3. A `WebhookEvent` is recorded containing the payload and headers.
4. If `whilesmart/activities` is installed, an activity log is automatically created.

Incoming events keep the parsed request fields in `payload`. The package does not reserve
keys or choose an application-specific payload structure.

### Keeping raw bodies in an application

Applications with a custom incoming handler can use `RawPayload` to preserve exact
request bytes inside a JSON value:

```php
use Whilesmart\Webhooks\Support\RawPayload;

$envelope = RawPayload::encode($request->getContent());
$event->payload = $envelope;
$event->save();

$originalBody = RawPayload::decode($event->payload);
```

The application chooses where to store this envelope and how its readers consume it.
The default package ingress does not use it. Encoding produces:

```json
{"raw_body": "{\"event\": \"opened\"}", "encoding": "utf-8"}
```

Non-UTF-8 bodies use base64. Decoding returns the original bytes, or `null` for an
unsupported envelope. It does not parse JSON or form data and does not verify signatures.

### Outbound Webhooks

Outgoing webhooks deliver your application's events to a customer-supplied URL. Create one with `direction` set to `outgoing`, the destination `url`, and the `subscribed_events` it should receive (omit `subscribed_events` to receive all):
Expand Down
2 changes: 2 additions & 0 deletions config/webhooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
'route_prefix' => env('WEBHOOKS_ROUTE_PREFIX', ''),
'route_middleware' => ['auth:sanctum'],

'ingress_middleware' => [],

/*
|--------------------------------------------------------------------------
| Outbound Delivery
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ public function up(): void
$table->timestamps();
$table->softDeletes();

$table->index(['is_active']);
$table->index(['direction']);
$table->index(['owner_type', 'owner_id', 'direction', 'is_active'], 'webhooks_owner_dispatch');
$table->index(['direction', 'is_active']);
});
}

Expand Down
24 changes: 24 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,32 @@ The package registers API routes automatically. You can customize the route pref
'register_routes' => env('WEBHOOKS_REGISTER_ROUTES', true),
'route_prefix' => env('WEBHOOKS_ROUTE_PREFIX', ''),
'route_middleware' => ['auth:sanctum'],
'ingress_middleware' => [],
```

`route_middleware` applies to webhook management routes, including event listing.
`ingress_middleware` applies only to `POST /webhooks/ingress/{token}` and does not
inherit the management middleware. Its default is an empty list because webhook
senders authenticate with the URL token rather than a Sanctum login. The ingress
controller rejects unknown tokens and inactive webhooks. Global application
middleware still runs.

Add middleware aliases or classes to the published configuration. For example,
Laravel's throttle middleware can limit incoming requests to 60 per minute:

```php
'ingress_middleware' => ['throttle:60,1'],
```

Applications can also add middleware that checks a provider's signature against the
original request body. Incoming signature verification is not built into the package.
Adding `auth:sanctum` here requires senders to supply credentials accepted by the
application's Sanctum guard.

`route_prefix` applies to both groups. For example, `api` makes the ingress path
`/api/webhooks/ingress/{token}`. Setting `register_routes` to `false` disables both
groups so the application can register its own routes.

### Feature Flags

You can enable or disable built-in support for Workspace scoping, Project scoping, and event tracking based on your needs:
Expand Down
7 changes: 7 additions & 0 deletions routes/ingress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

use Illuminate\Support\Facades\Route;
use Whilesmart\Webhooks\Http\Controllers\WebhookController;

Route::post('/webhooks/ingress/{token}', [WebhookController::class, 'ingress'])
->name('webhooks.ingress');
38 changes: 38 additions & 0 deletions src/Support/RawPayload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Whilesmart\Webhooks\Support;

class RawPayload
{
public static function encode(string $body): array
{
$isUtf8 = preg_match('//u', $body) === 1;

return [
'raw_body' => $isUtf8 ? $body : base64_encode($body),
'encoding' => $isUtf8 ? 'utf-8' : 'base64',
];
}

public static function decode(array $payload): ?string
{
$body = $payload['raw_body'] ?? null;
$encoding = $payload['encoding'] ?? null;

if (! is_string($body)) {
return null;
}

if ($encoding === 'utf-8') {
return $body;
}

if ($encoding !== 'base64') {
return null;
}

$decoded = base64_decode($body, true);

return $decoded === false ? null : $decoded;
}
}
7 changes: 7 additions & 0 deletions src/WebhooksServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,12 @@ protected function registerRoutes(): void
], function () {
$this->loadRoutesFrom(__DIR__ . '/../routes/webhooks.php');
});

Route::group([
'prefix' => config('webhooks.route_prefix', 'api'),
'middleware' => config('webhooks.ingress_middleware', []),
], function () {
$this->loadRoutesFrom(__DIR__ . '/../routes/ingress.php');
});
}
}
54 changes: 54 additions & 0 deletions tests/Feature/IngressTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace Whilesmart\Webhooks\Tests\Feature;

use PHPUnit\Framework\Attributes\Test;
use Whilesmart\Webhooks\Models\Webhook;
use Whilesmart\Webhooks\Models\WebhookEvent;
use Whilesmart\Webhooks\Tests\TestCase;
use Workbench\App\Models\User;

class IngressTest extends TestCase
{
#[Test]
public function incoming_json_keeps_application_fields_at_the_top_level(): void
{
$webhook = $this->incomingWebhook();
$payload = ['action' => 'opened', 'raw_body' => 'application value', 'encoding' => 'application encoding'];

$this->postJson("/webhooks/ingress/{$webhook->token}?source=customer", $payload)->assertStatus(202);

$event = WebhookEvent::firstOrFail();

$this->assertEquals($payload + ['source' => 'customer'], $event->payload);
$this->assertEquals($event->payload, $event->toArray()['payload']);
}

#[Test]
public function incoming_forms_keep_application_fields_at_the_top_level(): void
{
$webhook = $this->incomingWebhook();

$this->post("/webhooks/ingress/{$webhook->token}", ['action' => 'opened', 'number' => '7'])
->assertStatus(202);

$this->assertEquals(['action' => 'opened', 'number' => '7'], WebhookEvent::firstOrFail()->payload);
}

private function incomingWebhook(): Webhook
{
$user = User::forceCreate([
'name' => 'Owner',
'email' => 'owner+'.uniqid().'@example.com',
'password' => bcrypt('secret'),
]);

return Webhook::create([
'owner_type' => $user->getMorphClass(),
'owner_id' => $user->getKey(),
'name' => 'Inbound',
'direction' => Webhook::DIRECTION_INCOMING,
'is_active' => true,
]);
}
}
33 changes: 33 additions & 0 deletions tests/Unit/RawPayloadTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Whilesmart\Webhooks\Tests\Unit;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Whilesmart\Webhooks\Support\RawPayload;

class RawPayloadTest extends TestCase
{
#[Test]
public function bodies_survive_json_storage_without_changing_their_signature(): void
{
$bodies = ['{"zeta":1, "alpha":{"nested":true}}', "Text with café\n", "\xFF\x00\x80", ''];

foreach ($bodies as $body) {
$stored = json_encode(RawPayload::encode($body), JSON_THROW_ON_ERROR);
$restored = RawPayload::decode(json_decode($stored, true, 512, JSON_THROW_ON_ERROR));

$this->assertSame($body, $restored);
$this->assertSame(hash_hmac('sha256', $body, 'test-secret'), hash_hmac('sha256', $restored, 'test-secret'));
}
}

#[Test]
public function ordinary_payloads_and_invalid_envelopes_are_not_decoded(): void
{
foreach ([['action' => 'opened'], ['raw_body' => 'text'], ['raw_body' => [], 'encoding' => 'utf-8'],
['raw_body' => 'text', 'encoding' => 'unknown'], ['raw_body' => '!!!', 'encoding' => 'base64']] as $payload) {
$this->assertNull(RawPayload::decode($payload));
}
}
}
Loading