diff --git a/resources/js/components/WebhookStatus.vue b/resources/js/components/WebhookStatus.vue
index cbc3dad..5866fbe 100644
--- a/resources/js/components/WebhookStatus.vue
+++ b/resources/js/components/WebhookStatus.vue
@@ -10,6 +10,7 @@
Topic
Callback URL
Status
+ Last received
@@ -19,13 +20,15 @@
+ {{ formatLastReceived(webhook.last_received_at) }}
{{ topic }}
- {{ expected[topic] }}
+ {{ expected[topic] }}
+ —
@@ -67,6 +70,13 @@ export default {
},
},
+ methods: {
+ formatLastReceived(timestamp) {
+ if (!timestamp) return 'Never';
+ return new Date(timestamp).toLocaleString();
+ },
+ },
+
mounted() {
axios.get(this.url)
.then(res => {
diff --git a/src/Http/Controllers/CP/WebhooksStatusController.php b/src/Http/Controllers/CP/WebhooksStatusController.php
index 5601962..1be3cf6 100644
--- a/src/Http/Controllers/CP/WebhooksStatusController.php
+++ b/src/Http/Controllers/CP/WebhooksStatusController.php
@@ -3,6 +3,7 @@
namespace StatamicRadPack\Shopify\Http\Controllers\CP;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
use Shopify\Clients\Graphql;
use Statamic\Http\Controllers\CP\CpController;
use Statamic\Support\Arr;
@@ -39,6 +40,7 @@ public function index(Request $request)
$webhooks = collect($registered)->map(fn ($webhook) => array_merge($webhook, [
'expected' => isset($expected[$webhook['topic']])
&& $webhook['callbackUrl'] === $expected[$webhook['topic']],
+ 'last_received_at' => $this->getLastReceivedAt($webhook['topic'], $storeHandle),
]))->all();
return response()->json([
@@ -47,6 +49,15 @@ public function index(Request $request)
]);
}
+ private function getLastReceivedAt(string $topic, ?string $storeHandle): ?string
+ {
+ $cacheKey = $storeHandle
+ ? "shopify::webhook_last_received::{$storeHandle}::{$topic}"
+ : "shopify::webhook_last_received::{$topic}";
+
+ return Cache::get($cacheKey);
+ }
+
private function fetchWebhooks(Graphql $graphql): array
{
$query = <<<'QUERY'
diff --git a/src/Http/Middleware/VerifyShopifyHeaders.php b/src/Http/Middleware/VerifyShopifyHeaders.php
index 8568397..b131d5c 100644
--- a/src/Http/Middleware/VerifyShopifyHeaders.php
+++ b/src/Http/Middleware/VerifyShopifyHeaders.php
@@ -4,6 +4,7 @@
use Closure;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
use StatamicRadPack\Shopify\Support\StoreConfig;
class VerifyShopifyHeaders
@@ -34,9 +35,29 @@ public function handle(Request $request, Closure $next)
return response()->json(['error' => true], 403);
}
+ $this->recordLastReceived($request);
+
return $next($request);
}
+ protected function recordLastReceived(Request $request): void
+ {
+ $rawTopic = $request->header('X-Shopify-Topic');
+
+ if (! $rawTopic) {
+ return;
+ }
+
+ $topic = str_replace('/', '_', strtoupper($rawTopic));
+ $storeHandle = $request->attributes->get('shopify_store_handle');
+
+ $cacheKey = $storeHandle
+ ? "shopify::webhook_last_received::{$storeHandle}::{$topic}"
+ : "shopify::webhook_last_received::{$topic}";
+
+ Cache::forever($cacheKey, now()->toIso8601String());
+ }
+
/**
* Verify integrity
*/
diff --git a/tests/Unit/WebhookLastReceivedTest.php b/tests/Unit/WebhookLastReceivedTest.php
new file mode 100644
index 0000000..5d8b000
--- /dev/null
+++ b/tests/Unit/WebhookLastReceivedTest.php
@@ -0,0 +1,206 @@
+set('shopify.ignore_webhook_integrity_check', true);
+ }
+
+ private function actingAsSuperUser()
+ {
+ $user = User::make()->email('admin@example.com')->makeSuper()->save();
+
+ return $this->actingAs($user);
+ }
+
+ private function shopifyWebhooksResponse(array $nodes = []): string
+ {
+ return json_encode([
+ 'data' => [
+ 'webhookSubscriptions' => [
+ 'nodes' => $nodes,
+ ],
+ ],
+ ]);
+ }
+
+ #[Test]
+ public function records_last_received_timestamp_on_webhook_request()
+ {
+ Cache::flush();
+
+ $this->mock(Graphql::class, function (MockInterface $mock) {
+ $mock->shouldReceive('query')->andReturn(new HttpResponse(status: 200, body: '{}'));
+ });
+
+ $this->postJson('/!/shopify/webhook/product/create', ['id' => 1], [
+ 'X-Shopify-Topic' => 'products/create',
+ ]);
+
+ $this->assertNotNull(Cache::get('shopify::webhook_last_received::PRODUCTS_CREATE'));
+ }
+
+ #[Test]
+ public function normalises_topic_header_to_enum_format()
+ {
+ Cache::flush();
+
+ $this->mock(Graphql::class, function (MockInterface $mock) {
+ $mock->shouldReceive('query')->andReturn(new HttpResponse(status: 200, body: '{}'));
+ });
+
+ $this->postJson('/!/shopify/webhook/collection/update', ['id' => 1], [
+ 'X-Shopify-Topic' => 'collections/update',
+ ]);
+
+ $this->assertNotNull(Cache::get('shopify::webhook_last_received::COLLECTIONS_UPDATE'));
+ }
+
+ #[Test]
+ public function does_not_record_timestamp_when_verification_fails()
+ {
+ Cache::flush();
+ config()->set('shopify.ignore_webhook_integrity_check', false);
+ config()->set('shopify.webhook_secret', 'secret');
+
+ $this->postJson('/!/shopify/webhook/product/create', ['id' => 1], [
+ 'X-Shopify-Topic' => 'products/create',
+ 'X-Shopify-Hmac-Sha256' => 'invalid',
+ ]);
+
+ $this->assertNull(Cache::get('shopify::webhook_last_received::PRODUCTS_CREATE'));
+ }
+
+ #[Test]
+ public function records_timestamp_per_store_in_multi_store_mode()
+ {
+ Cache::flush();
+
+ config(['shopify.multi_store' => [
+ 'enabled' => true,
+ 'mode' => 'unified',
+ 'stores' => [
+ 'uk' => [
+ 'url' => 'uk.myshopify.com',
+ 'webhook_secret' => 'secret',
+ ],
+ ],
+ ]]);
+
+ $this->mock(Graphql::class, function (MockInterface $mock) {
+ $mock->shouldReceive('query')->andReturn(new HttpResponse(status: 200, body: '{}'));
+ });
+
+ $this->postJson('/!/shopify/webhook/product/update', ['id' => 1], [
+ 'X-Shopify-Topic' => 'products/update',
+ 'X-Shopify-Shop-Domain' => 'uk.myshopify.com',
+ ]);
+
+ $this->assertNotNull(Cache::get('shopify::webhook_last_received::uk::PRODUCTS_UPDATE'));
+ $this->assertNull(Cache::get('shopify::webhook_last_received::PRODUCTS_UPDATE'));
+ }
+
+ #[Test]
+ public function status_controller_includes_last_received_at_for_each_webhook()
+ {
+ $callbackUrl = route('statamic.shopify.webhook.product.create', [], true);
+
+ Cache::forever('shopify::webhook_last_received::PRODUCTS_CREATE', '2025-06-01T12:00:00+00:00');
+
+ $this->mock(Graphql::class, function (MockInterface $mock) use ($callbackUrl) {
+ $mock->shouldReceive('query')
+ ->once()
+ ->andReturn(new HttpResponse(status: 200, body: $this->shopifyWebhooksResponse([
+ [
+ 'id' => 'gid://shopify/WebhookSubscription/1',
+ 'topic' => 'PRODUCTS_CREATE',
+ 'endpoint' => ['callbackUrl' => $callbackUrl],
+ 'createdAt' => '2025-01-01T00:00:00Z',
+ ],
+ ])));
+ });
+
+ $response = $this->actingAsSuperUser()
+ ->getJson(cp_route('shopify.webhooks.status'));
+
+ $response->assertOk();
+ $this->assertSame('2025-06-01T12:00:00+00:00', $response->json('webhooks.0.last_received_at'));
+ }
+
+ #[Test]
+ public function status_controller_returns_null_last_received_at_when_never_fired()
+ {
+ Cache::flush();
+
+ $callbackUrl = route('statamic.shopify.webhook.product.create', [], true);
+
+ $this->mock(Graphql::class, function (MockInterface $mock) use ($callbackUrl) {
+ $mock->shouldReceive('query')
+ ->once()
+ ->andReturn(new HttpResponse(status: 200, body: $this->shopifyWebhooksResponse([
+ [
+ 'id' => 'gid://shopify/WebhookSubscription/1',
+ 'topic' => 'PRODUCTS_CREATE',
+ 'endpoint' => ['callbackUrl' => $callbackUrl],
+ 'createdAt' => '2025-01-01T00:00:00Z',
+ ],
+ ])));
+ });
+
+ $response = $this->actingAsSuperUser()
+ ->getJson(cp_route('shopify.webhooks.status'));
+
+ $response->assertOk();
+ $this->assertNull($response->json('webhooks.0.last_received_at'));
+ }
+
+ #[Test]
+ public function status_controller_uses_store_scoped_cache_key_in_multi_store_mode()
+ {
+ Cache::flush();
+
+ config(['shopify.multi_store' => [
+ 'enabled' => true,
+ 'mode' => 'unified',
+ 'stores' => [
+ 'uk' => ['url' => 'uk.myshopify.com', 'admin_token' => 'tok'],
+ ],
+ ]]);
+
+ $callbackUrl = route('statamic.shopify.webhook.product.create', [], true);
+
+ Cache::forever('shopify::webhook_last_received::uk::PRODUCTS_CREATE', '2025-06-01T12:00:00+00:00');
+
+ $this->app->instance('shopify.graphql.uk', tap($this->mock(Graphql::class), function (MockInterface $mock) use ($callbackUrl) {
+ $mock->shouldReceive('query')
+ ->once()
+ ->andReturn(new HttpResponse(status: 200, body: $this->shopifyWebhooksResponse([
+ [
+ 'id' => 'gid://shopify/WebhookSubscription/1',
+ 'topic' => 'PRODUCTS_CREATE',
+ 'endpoint' => ['callbackUrl' => $callbackUrl],
+ 'createdAt' => '2025-01-01T00:00:00Z',
+ ],
+ ])));
+ }));
+
+ $response = $this->actingAsSuperUser()
+ ->getJson(cp_route('shopify.webhooks.status').'?store=uk');
+
+ $response->assertOk();
+ $this->assertSame('2025-06-01T12:00:00+00:00', $response->json('webhooks.0.last_received_at'));
+ }
+}