Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel Signed API Requests for Third Parties

By Kokil Thapa | Last reviewed: September 2026

When a payment gateway, logistics partner, or government portal pushes data into your application, username-and-password auth is the wrong tool. Laravel Signed API Requests for Third Parties let external systems prove request integrity and authenticity using a shared secret and an HMAC signature—without issuing user tokens or building a full OAuth server. On production Laravel applications I maintain, signed inbound webhooks and partner callbacks fail less often than token-based flows because the partner signs exactly what they send. This guide walks through a pattern that works on Laravel 12 and 13 with PHP 8.3+, from canonical string construction through middleware, replay protection, and partner documentation. For broader API design context, see our Laravel API best practices and building RESTful APIs with Laravel.

What Are Laravel Signed API Requests for Third Parties?

Signed API requests solve a specific integration problem: a third-party server needs to POST order updates, payment confirmations, or document status changes to your Laravel app, and you must verify that the payload was not tampered with and actually came from that partner. Unlike Laravel Passport or Sanctum, which issue bearer tokens tied to users or clients, request signing binds authentication to the exact bytes on the wire.

The pattern is familiar if you have integrated Stripe, Khalti, or PayPal webhooks. The sender computes HMAC-SHA256(canonical_string, secret), sends the digest in a header (commonly X-Signature or X-Hub-Signature-256), and your Laravel middleware recomputes the same value. If the digests differ, you return 401 Unauthorized before any business logic executes—a fail-closed posture that prevents forged callbacks from creating orders or marking invoices paid.

Signed API Request FlowThird PartyPartner serverSigned HTTPHMAC + timestampLaravelMiddlewareControllerBusiness logicVerification Steps Inside Middleware1. Read raw body bytes2. Parse timestamp header3. Build canonical string4. Compare HMAC with hash_equals5. Reject stale timestamps6. Check nonce in Redis cache7. Attach partner model8. Pass to route handler
Architecture of Laravel Signed API Requests for Third Parties — verification happens in middleware before controllers execute

Signed requests differ from Laravel signed URLs, which protect browser-accessible links with query-string signatures. API request signing operates on POST, PUT, and PATCH bodies where the raw payload must be preserved. A common production mistake is letting Laravel parse JSON before verification; any middleware that reads $request->all() first can change whitespace or key order relative to what the partner signed. Always call $request->getContent() for the canonical input.

When signing beats token auth

  • Server-to-server webhooks — payment gateways, SMS delivery reports, shipping label updates.
  • Batch importers — partners push nightly CSV or JSON files to a single endpoint.
  • Legacy integrations — partners cannot implement OAuth but can add two HTTP headers.
  • High-trust, low-volume partners — law-firm document portals or notary status feeds where you issue one secret per organisation.

For public mobile or SPA clients, stick with Sanctum or Passport. Signing shines when both ends are servers you control or trust with a shared secret. If you need help scoping an integration, our API development services cover partner onboarding and webhook hardening.

How Do You Implement HMAC Signature Verification in Laravel?

Implementation breaks into four pieces: a partner credentials table, a canonical string builder, verification middleware, and route registration. The following works on Laravel 13 with PHP 8.3 or higher and Redis 8.10 for nonce storage.

Step 1: Store partner secrets

Create an api_partners migration with at least slug, secret (encrypted), is_active, and optional allowed_ips. Never expose secrets in API responses. Rotate by adding a secondary secret_previous column and accepting signatures from either secret for a grace window.

php artisan make:migration create_api_partners_table
php artisan make:model ApiPartner
php artisan make:middleware VerifyPartnerSignature

Step 2: Define the canonical string

Document the exact format partners must sign. Consistency matters more than cleverness. A format I use on production integrations:

METHOD + "\n" +
PATH + "\n" +
TIMESTAMP + "\n" +
SHA256(raw_body)

Example canonical string for POST /api/v1/webhooks/orders:

POST
/api/v1/webhooks/orders
1725589200
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Partners send three headers: X-Partner-Id, X-Timestamp (Unix epoch seconds), and X-Signature (hex-encoded HMAC-SHA256). Use the Base64 encoder and decoder during partner testing if they prefer Base64 digests—just pick one encoding and document it.

Canonical String BuilderHTTP MethodRequest PathTimestampBody SHA256Concatenate with newline separatorsPOST + path + timestamp + hashHMAC-SHA256 with partner secretOutput hex digest in X-Signature header
Building the canonical string is the core of Laravel Signed API Requests for Third Parties — every field must match on both sides

Step 3: Write verification middleware

Register the middleware in bootstrap/app.php (Laravel 11+) or app/Http/Kernel.php on older apps:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'partner.signed' => \App\Http\Middleware\VerifyPartnerSignature::class,
    ]);
})

Core middleware logic:

<?php

namespace App\Http\Middleware;

use App\Models\ApiPartner;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;

class VerifyPartnerSignature
{
    public function handle(Request $request, Closure $next): Response
    {
        $partnerId = $request->header('X-Partner-Id');
        $timestamp = $request->header('X-Timestamp');
        $signature = $request->header('X-Signature');

        if (! $partnerId || ! $timestamp || ! $signature) {
            return response()->json(['error' => 'Missing signature headers'], 401);
        }

        if (abs(time() - (int) $timestamp) > 300) {
            return response()->json(['error' => 'Request expired'], 401);
        }

        $partner = ApiPartner::query()
            ->where('slug', $partnerId)
            ->where('is_active', true)
            ->first();

        if (! $partner) {
            return response()->json(['error' => 'Unknown partner'], 401);
        }

        $bodyHash = hash('sha256', $request->getContent());
        $path = '/'.ltrim($request->path(), '/');
        if (! str_starts_with($path, '/api')) {
            $path = '/api/'.$request->path();
        }

        $canonical = implode("\n", [
            strtoupper($request->method()),
            $path,
            $timestamp,
            $bodyHash,
        ]);

        $expected = hash_hmac('sha256', $canonical, $partner->secret);

        if (! hash_equals($expected, $signature)) {
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        $nonceKey = "partner_nonce:{$partnerId}:{$signature}";
        if (! Cache::add($nonceKey, 1, now()->addMinutes(6))) {
            return response()->json(['error' => 'Replay detected'], 409);
        }

        $request->attributes->set('api_partner', $partner);

        return $next($request);
    }
}

Apply it to webhook routes:

// routes/api.php
Route::middleware(['partner.signed', 'throttle:partner-webhooks'])
    ->prefix('v1/webhooks')
    ->group(function () {
        Route::post('/orders', [OrderWebhookController::class, 'store']);
        Route::post('/payments', [PaymentWebhookController::class, 'store']);
    });

Pair signing with rate limiting and API throttling so a leaked secret cannot flood your endpoints. On the Nepal Gift Card platform and similar Laravel eCommerce builds, signed payment callbacks sit behind both signature middleware and IP allowlists where the gateway publishes fixed egress ranges.

Step 4: Provide a reference client

Ship a minimal PHP or curl example in your partner docs. PHP signing side:

$method = 'POST';
$path = '/api/v1/webhooks/orders';
$timestamp = (string) time();
$body = json_encode(['order_id' => 'ORD-9921', 'status' => 'paid']);
$bodyHash = hash('sha256', $body);

$canonical = implode("\n", [$method, $path, $timestamp, $bodyHash]);
$signature = hash_hmac('sha256', $canonical, $partnerSecret);

// Send with headers X-Partner-Id, X-Timestamp, X-Signature

For deeper outbound patterns—when your Laravel app signs requests to external APIs—see Laravel payment integrations and the Khalti integration guide, which follow vendor-specific canonical formats.

How Do You Protect Signed API Requests from Replay Attacks?

A valid signature alone is not enough. An attacker who intercepts a signed POST can replay it until the secret rotates. You need timestamp skew limits and idempotency or nonce tracking.

  1. Timestamp window — reject requests older than five minutes. Allow small clock skew (±30 seconds) if partners run unsynchronised VMs.
  2. Nonce or signature deduplication — store each X-Signature value in Redis with a TTL slightly longer than your timestamp window. Duplicate signatures return 409 Conflict.
  3. Idempotent handlers — even with nonce checks, design controllers to tolerate duplicate delivery. Payment webhooks especially may arrive twice.
  4. HTTPS only — signing does not encrypt; TLS prevents interception on the wire.
  5. Secret rotation — document a quarterly rotation process and support dual secrets during migration.
Replay Protection WindowPastFutureAccept: ±5 minute windowStale request401 expiredFresh requestVerify HMACDuplicate sig409 replayRedis Nonce CacheKey: partner_nonce:{id}:{signature}TTL: 6 minutes — Cache::add fails on replay
Timestamp windows and Redis nonce storage prevent replay of valid Laravel signed API requests

On legal-tech portals such as Mijar Law Associates, document status webhooks use idempotency keys derived from the partner's case reference so duplicate POSTs update nothing rather than creating duplicate audit rows. Queue the handler with ShouldBeUnique or a database unique constraint on (partner_id, external_id) for belt-and-braces protection.

Log verification failures with partner ID and reason, but never log the raw secret or full signature in production. Forward repeated failures to alerting—three invalid signatures from one IP in a minute often means someone is probing with a stolen payload.

Laravel Signed Requests vs OAuth Tokens: Which Should You Use?

Both authenticate API traffic, but they optimise for different shapes of integration. The table below summarises what I recommend when scoping partner work.

CriteriaHMAC signed requestsOAuth 2 / Sanctum tokens
Best forServer-to-server webhooks, fixed partnersUser-delegated access, mobile/SPA clients
Credential typeShared secret per partnerBearer token with expiry and scopes
Payload bindingSigns exact body bytesToken authorises action, body unsigned
RevocationRotate secret, disable partner rowRevoke token, shorten TTL
Partner effortLow — two or three headersHigher — token exchange flow
Laravel packagesCustom middleware (~80 lines)Sanctum, Passport built-in
Replay riskRequires timestamp + nonce designToken expiry reduces window

Use signed requests when the partner sends data to you and will not maintain token refresh logic. Use Sanctum or Passport when partners call your API on behalf of users who logged in through your application. Hybrid setups are normal: Sanctum for your frontend, HMAC middleware for inbound webhooks. Read how to build a REST API in Laravel the right way for routing and versioning patterns that keep both auth modes cleanly separated under /api/v1/.

Auth Method DecisionThird party integration?Webhook inboundUser contextHMAC signedFixed secretOAuth / SanctumScoped tokensProduction recommendationPayments, shipping, SMS callbacks → sign the raw bodyMobile apps, partner dashboards → issue bearer tokens
Choose Laravel Signed API Requests for Third Parties when partners push server-to-server webhooks without user sessions

How Do You Document and Test Signed Endpoints for Integration Partners?

Partners break integrations when the canonical string rules live only in your head. Treat signing spec as part of the API contract, alongside response schemas and error codes. I document four sections: header names, canonical format with a worked example, sample request in curl, and error responses (401, 409).

Generate OpenAPI descriptions with API documentation using Scribe for Laravel, adding custom security schemes for your HMAC headers. For SDK distribution patterns, see SDK design for your public API. Version webhook paths under /api/v1/ and plan breaking canonical changes for v2 per your Laravel API versioning strategy.

Local testing workflow

  1. Create a sandbox partner row with a known secret in your local database seeders.
  2. Run php artisan serve and POST from a standalone PHP script or Postman pre-request script that computes HMAC.
  3. Assert middleware returns 401 when you alter one body byte—proves binding works.
  4. Replay the same request twice; second call should return 409.
  5. Write a Feature test using $this->call() with raw content and custom headers so CI catches regressions.

Feature test skeleton:

public function test_partner_webhook_requires_valid_signature(): void
{
    $partner = ApiPartner::factory()->create(['slug' => 'acme']);
    $body = json_encode(['ref' => 'INV-1']);
    $timestamp = (string) time();
    $path = '/api/v1/webhooks/orders';
    $canonical = "POST\n{$path}\n{$timestamp}\n".hash('sha256', $body);
    $sig = hash_hmac('sha256', $canonical, $partner->secret);

    $response = $this->call('POST', $path, [], [], [], [
        'HTTP_X-Partner-Id' => 'acme',
        'HTTP_X-Timestamp' => $timestamp,
        'HTTP_X-Signature' => $sig,
        'CONTENT_TYPE' => 'application/json',
    ], $body);

    $response->assertOk();
}

External references worth linking in your partner PDF: the Laravel middleware documentation for registration details, and RFC 2104 on HMAC for the cryptographic baseline. If partners ask about constant-time comparison, point them to PHP's hash_equals()—never use == on signature strings.

For long-running enterprise application development engagements, I deliver a Postman collection with pre-request scripts that auto-sign payloads so partner QA teams can test without writing code first. That cuts integration time from weeks to days on projects like directory platforms and booking systems.

Key Takeaways

  • Build the canonical string from method, path, timestamp, and SHA-256 of the raw body—never from parsed JSON.
  • Verify signatures in dedicated middleware with hash_equals() before controllers run; fail closed on any mismatch.
  • Combine a five-minute timestamp window with Redis nonce caching to block replay attacks.
  • Make webhook handlers idempotent with unique constraints on partner-supplied reference IDs.
  • Document the signing spec with worked examples; ship a Postman collection or PHP reference client.
  • Use HMAC for inbound server webhooks; reserve Sanctum or Passport for user-context outbound API access.

People Also Ask

Does Laravel have built-in API request signing like signed URLs?

Laravel ships ValidateSignature middleware for signed URLs with query parameters, but not for JSON POST bodies from third parties. You implement API request signing with custom middleware using hash_hmac()—typically under 100 lines. The mental model matches Laravel signed URLs, but the canonical input includes the request body hash.

Should the signature cover query parameters too?

If partners send meaningful data in query strings, include the sorted, URL-encoded query string in your canonical format—or reject query parameters entirely for webhook POSTs. Mixed approaches cause signature drift. Most production webhook designs use POST bodies only and ignore query strings except for optional versioning flags excluded from signing.

How do you rotate partner secrets without downtime?

Store secret and secret_previous on the partner model. Middleware tries validation against the current secret first, then the previous one. Give partners two weeks to switch, then null out secret_previous. Log which secret matched so you know who has not migrated.

Is HMAC-SHA256 enough for compliance-sensitive data?

HMAC proves integrity and authenticity between parties who share a secret. It does not encrypt payload contents—TLS does that in transit. For legal or financial document webhooks, require HTTPS, restrict source IPs where possible, and audit every verified request. Signing satisfies most PCI-adjacent webhook requirements when combined with transport encryption.

Ship Partner Integrations You Can Trust

Laravel Signed API Requests for Third Parties give you a boring, testable authentication layer that survives real-world webhook retries, clock skew, and partners who read documentation once. The pattern scales from a single Khalti callback on a Nepali eCommerce site to multi-tenant legal portals receiving document status feeds. Start with a explicit canonical format, middleware that reads raw bytes, and Feature tests that break when someone refactors the signing string.

If you are planning payment gateways, partner data feeds, or outbound API integrations and want the signing layer built correctly from day one, review our web development services or modern Laravel architecture best practices. For ongoing webhook monitoring after launch, support and maintenance covers alerting and secret rotation. Contact us to discuss your integration scope.

Frequently Asked Questions

They let external systems prove request integrity and authenticity using a shared secret and HMAC signature, without user tokens or a full OAuth server. Your Laravel app verifies the partner signed the exact bytes sent before any controller logic runs.

The partner builds a canonical string from HTTP method, path, timestamp, and SHA-256 of the raw body, then computes HMAC-SHA256 with their secret. Custom middleware recomputes the same digest with hash_equals() and returns 401 if they differ.

Three headers: X-Partner-Id (partner slug), X-Timestamp (Unix epoch seconds), and X-Signature (hex-encoded HMAC-SHA256 of the canonical string).

Use signing for server-to-server webhooks, batch importers, and legacy partners who cannot maintain token refresh. Sanctum or Passport fit mobile apps, SPAs, and APIs where partners act on behalf of logged-in users. Hybrid setups with both are normal.

Four lines joined by newline: uppercase HTTP method, path (e.g. /api/v1/webhooks/orders), timestamp, and SHA-256 hash of the raw request body. Both sides must match this format exactly or verification fails.

Parsed JSON can differ in whitespace or key order from what the partner signed. Always call $request->getContent() before verification. Middleware that reads $request->all() first is a common production mistake that causes valid signatures to fail.

Reject timestamps older than five minutes, store each X-Signature in Redis with a TTL slightly longer than that window, and return 409 on duplicates. Also design handlers to be idempotent so duplicate deliveries cannot create duplicate records.

Create an api_partners table, a VerifyPartnerSignature middleware class, and register it as partner.signed in bootstrap/app.php. Apply it to webhook routes alongside throttle:partner-webhooks. The middleware checks headers, timestamp, partner secret, signature, and nonce before passing the request to your controller.

At minimum: slug, encrypted secret, and is_active. Optional allowed_ips for IP allowlists. Add secret_previous to support dual-secret rotation during a grace window without breaking live integrations.

Signed URLs protect browser-accessible links with query-string signatures. API request signing operates on POST, PUT, and PATCH bodies where the raw payload must be preserved. They solve different problems and should not be confused.

Seed a sandbox partner with a known secret, POST from a PHP script or Postman pre-request script that computes HMAC, assert 401 when one body byte changes, and confirm the second identical request returns 409. Write Feature tests using $this->call() with raw content and custom headers for CI.

401 for missing headers, expired timestamps, unknown partners, or invalid signatures. 409 when a duplicate signature is detected as a replay. Fail closed on any verification mismatch before business logic executes.

Document header names, canonical format with a worked example, a sample curl request, and error responses. Treat the signing spec as part of the API contract. Scribe for Laravel can generate OpenAPI with custom HMAC security schemes.

Add a secret_previous column, accept signatures from either secret during a grace window, document quarterly rotation, and notify partners before disabling the old secret. Never expose secrets in API responses.

A leaked secret could flood endpoints if signing alone were the only gate. Pair partner.signed middleware with throttle:partner-webhooks and optional allowed_ips where the partner publishes fixed egress ranges, as used on payment callback routes in production Laravel eCommerce builds.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: