Laravel PayMongo

Webhooks

PayMongo calls a URL on your app, a webhook endpoint, whenever something happens on your account: a payment is paid, a refund goes through, a subscription renews. Whichever way you take payments, the webhook is how you learn the money arrived. A customer reaching your success page only means they came back.

There are two halves. You register an endpoint with PayMongo through Paymongo::webhooks(), and you receive its deliveries with one route, which verifies them, drops repeats, and dispatches a Laravel event for each. For PayMongo's side, see its Webhooks guide and Events.

Receive events

1. Register the route

use Illuminate\Support\Facades\Route;

Route::paymongoWebhooks(); // POST /paymongo/webhook, named paymongo.webhooks

Route::paymongoWebhooks() registers POST /paymongo/webhook, named paymongo.webhooks, pointing at the package's controller. Pass a different URI as the first argument: Route::paymongoWebhooks('webhooks/paymongo'). It turns off CSRF protection for the route itself, so it works from routes/web.php as well as routes/api.php.

For every delivery, the route:

  1. Verifies the signature, and answers 401 to anything PayMongo did not sign.
  2. Drops repeat deliveries of an event it has already handled, answering 200 without dispatching anything.
  3. Dispatches Luigel\Paymongo\Events\WebhookReceived for every event, and the typed event for its name, such as PaymentPaid for payment.paid.
  4. Answers 200 with {"received": true}.

2. Add the endpoint's secret

Each endpoint has its own secret_key, which PayMongo uses to sign every delivery to it. Put it in .env:

PAYMONGO_WEBHOOK_SECRET=whsk_...

PayMongo shows the secret when you create the endpoint, either in the dashboard or from create(). The route throws a RuntimeException on every delivery until it is set.

3. Listen for the event

Write a listener for the typed event you care about:

namespace App\Listeners;

use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Luigel\Paymongo\Events\PaymentPaid;

class MarkOrderPaid implements ShouldQueue
{
    public function handle(PaymentPaid $event): void
    {
        $webhookEvent = $event->event; // Luigel\Paymongo\Webhooks\WebhookEvent

        $order = Order::where('reference', $webhookEvent->resourceAttribute('metadata.order_reference'))->first();

        // Not ours, already handled, or not the amount we asked for.
        if ($order === null || $order->paid_at !== null || $webhookEvent->resourceAttribute('amount') !== $order->amount) {
            return;
        }

        $order->payment_id = $webhookEvent->resourceId(); // "pay_..."
        $order->paid_at = $webhookEvent->timestamp ?? now();
        $order->save();
    }
}

Laravel discovers the listener from the type hint on handle(), so there is nothing to register. $event->event is a Luigel\Paymongo\Webhooks\WebhookEvent:

Member What it is
id The event's id, evt_...
type Its name, such as payment.paid
eventType() The name as a Luigel\Paymongo\Enums\WebhookEventType, or null for a name the package does not know
livemode true for a live-mode event
timestamp When PayMongo created the event, a ?CarbonImmutable
resourceId() The id of the resource it is about, such as pay_...
resourceAttribute($key, $default) One of that resource's attributes, by dot path
data The whole resource, {id, type, attributes}
raw The payload exactly as PayMongo posted it

The resource is a snapshot from when the event happened, as a plain array rather than a data object. When you need its current state, retrieve it, for example with Paymongo::payments()->retrieve($webhookEvent->resourceId()).

To see every event, including names the package has no class for yet, listen for WebhookReceived. Every typed event extends it:

namespace App\Listeners;

use Illuminate\Support\Facades\Log;
use Luigel\Paymongo\Events\WebhookReceived;

class RecordWebhookEvent
{
    public function handle(WebhookReceived $event): void
    {
        $webhookEvent = $event->event;

        Log::info("PayMongo event {$webhookEvent->type}", [
            'event_id' => $webhookEvent->id,                    // "evt_..."
            'known' => $webhookEvent->eventType() !== null,     // ?WebhookEventType
            'resource_id' => $webhookEvent->resourceId(),
            'livemode' => $webhookEvent->livemode,
        ]);
    }
}

The Events reference lists every typed event and the PayMongo event name it is dispatched for. Choose a flow says which one confirms payment for each way of taking it.

Write listeners that survive retries

PayMongo expects a 2xx within 30 seconds. Otherwise it retries the delivery, up to 12 times with exponential backoff, so one event can reach you more than once. So:

  • Queue the work. Implement ShouldQueue, as MarkOrderPaid does, so the route answers at once and a slow or failing listener is retried by your queue worker. The package marks an event handled after it dispatches both Laravel events. If a synchronous listener throws or a queued listener cannot be pushed, the route fails and PayMongo can retry the delivery. A queued listener that fails after being pushed is retried by your queue worker.
  • Make the listener idempotent. Deduplication only remembers an event for 24 hours, in your cache. Check your own state, as MarkOrderPaid checks paid_at, before acting.
  • Check what you were paid. Compare the amount, and whatever reference you put in metadata, with your order before fulfilling it.

Signature verification

The route runs the paymongo.signature middleware (Luigel\Paymongo\Http\Middleware\VerifyWebhookSignature). It reads the Paymongo-Signature header, t=<timestamp>,te=<test-mode signature>,li=<live-mode signature>, and recomputes the HMAC-SHA256 of "{t}.{raw body}" with your secret, as PayMongo's Securing a webhook describes. It answers 401 when:

  • the header is missing, or has no timestamp;
  • the signature does not match. The default endpoint checks te unless PAYMONGO_LIVEMODE=true, when it checks li. Named endpoints can override this with paymongo.webhooks.modes.{name};
  • the timestamp is more than PAYMONGO_WEBHOOK_TOLERANCE seconds away from now (300 by default, 0 turns the check off). This stops an old delivery from being replayed.

Put the middleware on a route of your own when you want to handle deliveries yourself instead of through events:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Luigel\Paymongo\Webhooks\WebhookEvent;

// routes/api.php: a route in the web group also needs CSRF turned off for it.
Route::post('webhooks/paymongo/ledger', function (Request $request) {
    $event = WebhookEvent::fromArray($request->all());

    Log::info("Ledger entry for {$event->resourceId()}", [
        'amount' => $event->resourceAttribute('amount'),
    ]);

    return response()->json(['received' => true]);
})->middleware('paymongo.signature'); // or paymongo.signature:orders for a named secret

Deduplication

After dispatch succeeds, the route remembers each event id in your cache under paymongo:webhook:{event id} for 24 hours, and skips an event it has handled. While a delivery is in progress, another delivery of the same event gets 503 so PayMongo retries it if the first attempt fails. A short cache lock expires after 60 seconds if the request process stops. Settings:

Variable or key Default What it does
PAYMONGO_WEBHOOK_DEDUPE true Turn deduplication on or off
PAYMONGO_WEBHOOK_DEDUPE_STORE your default cache store The cache store to remember events in
paymongo.webhooks.dedupe.ttl 86400 How long to remember an event, in seconds

Running more than one server? Use a cache store they share, such as Redis, Memcached, or the database. With the file or array store, each server only knows the events it handled itself.

More than one endpoint

Each endpoint has its own secret, so a second endpoint, for another PayMongo account, say, needs its own. Publish the config file (php artisan vendor:publish --tag=paymongo-config) and name the secret under webhooks.secrets in config/paymongo.php, for example 'orders' => env('PAYMONGO_WEBHOOK_SECRET_ORDERS'). Then pass that name as the second argument:

use Illuminate\Support\Facades\Route;

// Verified with paymongo.webhooks.secret (PAYMONGO_WEBHOOK_SECRET):
Route::paymongoWebhooks();

// Verified with paymongo.webhooks.secrets.orders. Every call is named
// paymongo.webhooks, so give this one its own name: paymongo.webhooks.orders.
Route::paymongoWebhooks('webhooks/orders', 'orders')->name('.orders');

Set webhooks.modes.orders to false for a test-mode endpoint or true for a live-mode endpoint. An unlisted name uses PAYMONGO_LIVEMODE. This lets test and live endpoints share one app. On a route of your own, name the secret as the middleware's parameter: paymongo.signature:orders.

Test locally

PayMongo needs a public HTTPS URL. Expose your app with a tunnel such as ngrok http 8000, register a test-mode endpoint for the tunnel's URL, and put its secret in .env. Test-mode endpoints only receive test-mode events. PayMongo's dashboard can also send a test event to an endpoint.

In your test suite, post events built with Fixtures::event() to the route instead. See Testing.

Register an endpoint

Register endpoints from code or with the artisan commands. Every method lives on Paymongo::webhooks() and returns a Luigel\Paymongo\Data\Webhook (see the Webhook reference). An endpoint belongs to the mode of the key that created it, so register one with your test key and another with your live key.

create() takes the URL and the events to send to it, as WebhookEventType cases or strings:

use Luigel\Paymongo\Enums\WebhookEventType;
use Luigel\Paymongo\Facades\Paymongo;

$webhook = Paymongo::webhooks()->create('https://example.com/paymongo/webhook', [
    WebhookEventType::PaymentPaid,
    'payment.failed', // a string works too
]);

$webhook->id;        // "hook_..."
$webhook->secretKey; // "whsk_...": put it in PAYMONGO_WEBHOOK_SECRET

PayMongo only returns secretKey here, so store it straight away.

list() returns a page of endpoints; pass limit or url to narrow it, or call lazy() to walk every page. retrieve() returns one:

use Luigel\Paymongo\Enums\WebhookStatus;
use Luigel\Paymongo\Facades\Paymongo;

foreach (Paymongo::webhooks()->list()->lazy() as $webhook) { // every endpoint, one request per page
    $webhook->url;
    $webhook->events;                              // list<string>
    $webhook->status === WebhookStatus::Enabled;   // ?WebhookStatus
}

$webhook = Paymongo::webhooks()->retrieve('hook_9VrvpRkkYqK6twbhuvcVTtjM');

update() changes an endpoint's url or events:

use Luigel\Paymongo\Facades\Paymongo;

$webhook = Paymongo::webhooks()->update('hook_9VrvpRkkYqK6twbhuvcVTtjM', [
    'url' => 'https://example.com/paymongo/webhook',
    'events' => ['payment.paid', 'payment.failed', 'payment.refunded'],
]);

disable() stops deliveries to an endpoint without removing it, and enable() starts them again. PayMongo does not replay the events it skipped in between:

use Luigel\Paymongo\Facades\Paymongo;

$webhook = Paymongo::webhooks()->disable('hook_9VrvpRkkYqK6twbhuvcVTtjM');

// Events that happen while it is disabled are never delivered.

$webhook = Paymongo::webhooks()->enable('hook_9VrvpRkkYqK6twbhuvcVTtjM');

delete() removes an endpoint from the account for good, and returns true:

use Luigel\Paymongo\Facades\Paymongo;

Paymongo::webhooks()->delete('hook_9VrvpRkkYqK6twbhuvcVTtjM'); // true, or throws

PayMongo's API reference does not list this endpoint yet, though the API answers it with 204 No Content. Disable an endpoint instead when you may want it back.

From the command line

php artisan paymongo:webhook:create https://example.com/paymongo/webhook --event=payment.paid --event=payment.failed
php artisan paymongo:webhook:list
php artisan paymongo:webhook:toggle hook_9VrvpRkkYqK6twbhuvcVTtjM --disable
php artisan paymongo:webhook:toggle hook_9VrvpRkkYqK6twbhuvcVTtjM --enable

paymongo:webhook:create subscribes to payment.paid and payment.failed when you pass no --event, and prints the endpoint's secret. See Artisan commands.