DEV Community

Cover image for How to use BuyMeACoffees webhooks with Laravel.
Kim Hallberg
Kim Hallberg

Posted on • Originally published at buymeacoffee.com on

How to use BuyMeACoffees webhooks with Laravel.

In this post, I will demonstrate how to use BuyMeACoffees Webhooks in Laravel. BMCs Webhooks allows you to get near-instant notifications when an event happens on your account, as said by BMC themselves.

When the event occurs—a supporter buys coffee for you, or someone purchases your coffeelink, etc.—BMC creates an Event object.

When an account event is triggered, BMC will send a POST request to your desired URL with the event as its payload. Those payloads will be a JSON object with the event-specific values being under the response key.

{
  "response": {
    "supporter_email": "supporter@buymeacoffee.com",
    "number_of_coffees": "1",
    "total_amount": "5",
    "support_created_on": "2021-08-01 16:00:00"
  }
}
Enter fullscreen mode Exit fullscreen mode

So, let us receive this event in Laravel by creating a POST route that will be our webhook URL, a controller to handle the event, and a middleware that will verify its authenticity. We will begin by using artisan to make our controller and middleware.

php artisan make:controller BuyMeACoffeeWebhookController --invokable
php artisan make:middleware VerifyBuyMeACoffeeWebhook
Enter fullscreen mode Exit fullscreen mode

I will use an invokable controller simply because this controller will only handle one thing. Now we will register our POST route and attach our controller and middleware.

Route::post('/buymeacoffee', BuyMeACoffeeWebhookController::class)->middleware(VerifyBuyMeACoffeeWebhook::class);
Enter fullscreen mode Exit fullscreen mode

To verify that our webhook is authentic and is sent from BMC, we will use hash_hmac to verify that the SHA256 signatures match, this is done by hashing the incoming request and using our webhook secret as the hashing key, and checking that the resulting hash matches the given signature.

You can find your webhook secret in the webhook dashboard, each webhook you add will have its own secret, and can be regenerated by removing the webhook and adding it back again. It is best not to add our secret directly into our code, so we will create a new file under /config/webhooks.php which will get our secret from our environment variables.

<?php

return [
  'buymeacoffee' => [
    'secret' => env('BUYMEACOFFEE_WEBHOOK_SECRET'),
  ]
];
Enter fullscreen mode Exit fullscreen mode

Now when we need our secret we can use Laravels config function to grab it.

So, time now to move along and update our middleware to verify the request.

use Illuminate\Http\Response;
use Illuminate\Http\Request;
use Closure;

class VerifyBuyMeACoffeeWebhook
{
  private array $headers = [
    'x-bmc-event', 'x-bmc-signature',
  ];

  public function handle(Request $request, Closure $next)
  {
     return $this->verify($request) ? $next($request) : response("Webhook signature didn't match!", Response::HTTP_BAD_REQUEST);
  }

  private function verify(Request $request)
  {
    if (! collect($request->headers)->has($this->headers)) {
      return false;
    }

    $signature = $request->header('X-Bmc-Signature');
    $request->headers->remove('X-Bmc-Signature');

    return hash_equals(hash_hmac('sha256', $request->getContent(), config('webhooks.buymeacoffee.secret')), $signature);
  }
}
Enter fullscreen mode Exit fullscreen mode

Let me step over what we've done here. When the request first comes in we immediately pass it along to a private verify function that returns a boolean indicating if the request has been verified. If it has we let the request pass through to the next middleware and then on to our controller. Otherwise, we break the middleware chain by returning a 400 bad request response.

The private verify function will start by checking if the request has the necessary headers present that a BMC webhook request should have, mainly X-Bmc-Event and X-Bmc-Signature. We store those in a private array property on the middleware, so should the headers increase in the future we have an easy way of checking for those too.

This piece of logic is made easier with the help of Laravels collect method. Turning the headers into a collection gives us an easy method to call and check if the headers are present and if they're not we return false breaking the chain.

We could've also added a check in here for the BMC user-agent, which I omitted in favour of keeping this post shorter. Never the less that piece of logic could look something like this.

if (! str_contains($request->userAgent(), 'BMC-HTTPS-ROBOT')) {
  return false;
}
Enter fullscreen mode Exit fullscreen mode

Moving along to our next piece which is the signature check. We start by first storing the request signature and then removing it from our header bag. This is done to prevent the signature from leaking out of our middleware and into our controller.

Lastly, as mentioned previously we use hash_hmac to hash the request content using our secret and check if it matches our signature. Since this is the last piece of logic we can simply return the outcome of this check.

If all of those checks pass, we can safely assume the request is valid and pass the request along the chain.

Now with a verified request, we can use our controller to delegate what we should do based on a given event. While you could handle both events together, I've chosen to separate them in this post to show how can be done. This is how our controller will look.

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class BuyMeACoffeeWebhookController extends Controller
{

  public function __invoke(Request $request)
  {
     $event = Str::camel($request->header('x-bmc-event'));
     $payload = $request->json('response');

     if (method_exists($this, $event)) {
       return call_user_func([$this, $event], $payload);
     }

     return response()->json("The event {$request->header('x-bmc-event')} is unsupported.");
  }

  public function coffeePurchase(array $payload): JsonResponse
  {
     return response()->json("Thank you for your support {$payload['supporter_email']}.");
  }

  public function coffeeLinkPurchase(array $payload): JsonResponse
  {
     return response()->json("Thank you for your support {$payload['supporter_email']}.");
  }
}
Enter fullscreen mode Exit fullscreen mode

We start by transforming our event into a camel case version using Laravels string facade, this is done because PHP doesn't allow dashes in function names. Next, we use the json method on the request to grab the event-specific values from the response key, as mentioned in the beginning.

We then move along and check if we have a function to handle that event. This step might be redundant and unnecessary even in this context considering everything is located inside our controller.

I think it's useful to show how this check is done because. If this was implemented by yourself or another developer, these methods might be located in another class, or use snake case instead of camel case, maybe a method was misspelled of you haven't implemented a method to handle a specific event.

This simple check makes sure the event you want to handle can actually be handled, if it couldn't be handled, we return a message saying the event is unsupported. If a method was found, we pass in the payload into the method and call it.

Now it is up to each developer to decide how to handle this payload. Whether that is to associate the supporter_email to a user in your system, maybe give access to a private GitHub repository if the amount supported reaches a certain threshold, or simply send them an email thanking them.

Thank you for reading, I hope you enjoyed it. 🙌

Top comments (4)

Collapse
 
strangergithuber profile image
StrangerDev

Somehow it doesn't work anymore or I am implementing wrong way but I get answer from buymeacoffee error 400.

status: 400,
body: "Webhook signature didn't match

No idea if buymeacoffee did change something since this article or the problem if I am using cloudflare.

Collapse
 
strangergithuber profile image
StrangerDev

It looks like already this part fails when I am testing:
if (! collect($request->headers)->has($this->headers)) {
return false;
}

Collapse
 
therohitdas profile image
Rohit Das

Hi Kim, it was a super helpful post.
Do you know where I can find all the possible JSON objects?
Like when someone bought an extra?

Collapse
 
thinkverse profile image
Kim Hallberg

Hello Rohit, sorry for the late response. 👊

At the moment there is only the two events - Coffee purchase and Coffeelink purchase, who's JSON structure you can find on the webhook dashboard.

You'll need to add a webhook and from there click on Send Test link to open a modal that will show the structure. There's currently no close prompt on the modal but pressing esc closes it for you.

If you want more events from the BMC webhook I suggest you upvote posts on BMC's feedback site relevant to the webhooks.

I posted some suggestions a while ago myself of additional events that would be useful.