I am using the GoCardless Pro package to handle incoming webhooks from GoCardless. The controller method looks like so:
/**
* Handle an incoming webhook from GoCardless.
*/
public function handleWebhook(Request $request)
{
$payload = $request->getContent();
$signature_header = $request->header('Webhook-Signature');
$webhook_endpoint_secret = config('gocardless.webhook_secret');
try {
$events = Webhook::parse(
$payload,
$signature_header,
$webhook_endpoint_secret
);
event(new WebhookReceived(json_decode($payload, true), 'GoCardless'));
foreach ($events as $event) {
switch ($event->resource_type) {
case 'billing_requests':
$this->process_billing_requests($event);
break;
case 'payments':
$this->process_payments($event);
break;
default: Log::info('Ignoring Resource Type - ' . $event->resource_type);
}
}
} catch (InvalidSignatureException $e) {
throw new AccessDeniedException($e->getMessage());
}
}
This works as expected but when I run my test:
<?php
namespace Tests\Feature\Http\Controllers;
use GoCardlessPro\Webhook;
use Tests\TestCase;
class GocardlessWebhookControllerTest extends TestCase
{
/**
* The JSON payload we expect from the GoCardless webhook.
*/
private $requestBody = '{
"events": [
{
"id": "EV00PRB3XPQ6SM",
"created_at": "2022-01-19T16:31:27.334Z",
"resource_type": "billing_requests",
"action": "collect_customer_details",
"links": {
"customer": "CU000KCYTG87AG",
"billing_request_flow": "BRF00006MD132JNYB34090X5BY8MB7WH",
"billing_request": "BRQ00006VYYE38D"
},
"details": {
"origin": "api",
"cause": "billing_request_collect_customer_details",
"description": "Customer details have been collected for this billing request."
},
"metadata": {}
},
{
"id": "EV00PRB4MN1FEW",
"created_at": "2022-01-19T16:31:33.556Z",
"resource_type": "billing_requests",
"action": "collect_bank_account",
"links": {
"customer": "CU000KCYTG87AG",
"customer_bank_account": "BA000JTPBSYCHH",
"billing_request_flow": "BRF00006MD132JNYB34090X5BY8MB7WH",
"billing_request": "BRQ00006VYYE38D"
},
"details": {
"origin": "api",
"cause": "billing_request_collect_bank_account",
"description": "Bank account details have been collected for this billing request."
},
"metadata": {}
},
{
"id": "EV00PRB5RN2WFM",
"created_at": "2022-01-19T16:31:44.368Z",
"resource_type": "billing_requests",
"action": "payer_details_confirmed",
"links": {
"customer": "CU000KCYTG87AG",
"customer_bank_account": "BA000JTPBSYCHH",
"billing_request_flow": "BRF00006MD132JNYB34090X5BY8MB7WH",
"billing_request": "BRQ00006VYYE38D"
},
"details": {
"origin": "api",
"cause": "billing_request_payer_details_confirmed",
"description": "Payer has confirmed all their details for this billing request."
},
"metadata": {}
},
{
"id": "EV00PRB5W4RKJD",
"created_at": "2022-01-19T16:31:45.303Z",
"resource_type": "billing_requests",
"action": "bank_authorisation_authorised",
"links": {
"customer": "CU000KCYTG87AG",
"customer_bank_account": "BA000JTPBSYCHH",
"bank_authorisation": "BAU00003RY4GSJG",
"institution_id": "payment_failed_sandbox_bank",
"billing_request": "BRQ00006VYYE38D"
},
"details": {
"origin": "payer",
"cause": "billing_request_bank_authorisation_authorised",
"bank_account_id": "BA000JTPBSYCHH",
"description": "A bank authorisation for this billing request has been authorised by the payer."
},
"metadata": {}
},
{
"id": "EV00PRB5WXS86W",
"created_at": "2022-01-19T16:31:45.511Z",
"resource_type": "mandates",
"action": "active",
"links": {
"mandate": "MD000JTGT82N9J"
},
"details": {
"origin": "gocardless",
"cause": "mandate_activated",
"description": "The time window after submission for the banks to refuse a mandate has ended without any errors being received, so this mandate is now active."
},
"metadata": {}
},
{
"id": "EV00PRB5X2476R",
"created_at": "2022-01-19T16:31:45.534Z",
"resource_type": "mandates",
"action": "created",
"links": {
"mandate": "MD000JTGT82N9J"
},
"details": {
"origin": "api",
"cause": "mandate_created",
"description": "Mandate created via the API."
},
"metadata": {}
},
{
"id": "EV00PRB5XH1YYY",
"created_at": "2022-01-19T16:31:45.645Z",
"resource_type": "payments",
"action": "created",
"links": {
"payment": "PM004GQ682E7XH"
},
"details": {
"origin": "payer",
"cause": "payment_created",
"description": "Payment created by payer (via a payment request)."
},
"metadata": {}
},
{
"id": "EV00PRB5XWK9RP",
"created_at": "2022-01-19T16:31:45.714Z",
"resource_type": "mandates",
"action": "consumed",
"links": {
"mandate": "MD000JTGT82N9J"
},
"details": {
"origin": "gocardless",
"cause": "mandate_consumed",
"description": "The mandate has been used to create a payment, and has now been consumed. It cannot be used again."
},
"metadata": {}
},
{
"id": "EV00PRB5YD6YFZ",
"created_at": "2022-01-19T16:31:45.822Z",
"resource_type": "mandates",
"action": "created",
"links": {
"mandate": "MD000JTGT9SES7"
},
"details": {
"origin": "api",
"cause": "mandate_created",
"description": "Mandate created via the API."
},
"metadata": {}
},
{
"id": "EV00PRB5YTTYKR",
"created_at": "2022-01-19T16:31:45.926Z",
"resource_type": "billing_requests",
"action": "fulfilled",
"links": {
"customer": "CU000KCYTG87AG",
"customer_bank_account": "BA000JTPBSYCHH",
"payment_request_payment": "PM004GQ682E7XH",
"mandate_request_mandate": "MD000JTGT9SES7",
"billing_request": "BRQ00006VYYE38D"
},
"details": {
"origin": "gocardless",
"cause": "billing_request_fulfilled",
"description": "This billing request has been fulfilled, and the resources have been created."
},
"metadata": {}
},
{
"id": "EV00PRB6BSRW67",
"created_at": "2022-01-19T16:31:49.153Z",
"resource_type": "payments",
"action": "submitted",
"links": {
"payment": "PM004GQ682E7XH"
},
"details": {
"origin": "gocardless",
"cause": "payment_submitted",
"bank_account_id": "BA000JTPBSYCHH",
"description": "Payment submitted to the banks. As a result, it can no longer be cancelled."
},
"metadata": {}
},
{
"id": "EV00PRB6S1A8BQ",
"created_at": "2022-01-19T16:31:52.670Z",
"resource_type": "payments",
"action": "failed",
"links": {
"payment": "PM004GQ682E7XH"
},
"details": {
"origin": "bank",
"cause": "other",
"scheme": "faster_payments",
"reason_code": "FAILED",
"will_attempt_retry": false,
"not_retried_reason": "other",
"bank_account_id": "BA000JTPBSYCHH",
"description": "The payment failed but the reason for the failure was not provided, usually for regulatory reasons."
},
"metadata": {}
}
],
"meta": {
"webhook_id": "WB0014APC38MQ4"
}
}';
/**
* A secret we would set for an endpoint within GoCardless
*/
private $webhookEndpointSecret = 'secret-key-that-is-used';
/**
* Determine whether our webhook controller can handle a webhook.
* For reference we expect our signature header to be: "8f8c9d66392b2e0a0cdd2a591e9ede806f1c12b873b350215af772e50e091ffa"
*
* @test
*/
public function it_can_retrieve_a_webhook()
{
$expectedCompitedSignatureHeader = hash_hmac('sha256', $this->requestBody, $this->webhookEndpointSecret);
// $this->assertTrue(Webhook::isSignatureValid($this->requestBody, $expectedCompitedSignatureHeader, $this->webhookEndpointSecret));
$payload = json_decode($this->requestBody, true);
$this->assertSame($this->webhookEndpointSecret, config('gocardless.webhook_secret'));
$this->postJson(route('webhook-gocardless'), $payload, [
'webhook-signature' => $expectedCompitedSignatureHeader
])
->assertSessionHasNoErrors()
->assertStatus(200);
}
}
It always fails as postJson converts some curly braces to square braces.
Here is the Webhook::parse method
<?php
namespace GoCardlessPro;
/**
* Class containing a collection of functions for validating and parsing GoCardless webhooks
*/
class Webhook
{
const INVALID_SIGNATURE_MESSAGE = "This webhook doesn't appear to be a genuine " .
'webhook from GoCardless, because the signature ' .
"header doesn't match the signature computed with" .
' your webhook endpoint secret.';
/**
* Validates that a webhook was genuinely sent by GoCardless using `isValidSignature`,
* and then parses it into an array of `GoCardlessPro::Resources::Event`
* objects representing each event included in the webhook.
*
* @param string $request_body the request body
* @param string $signature_header the signature included in the request, found in the
* `Webhook-Signature` header
* @param string $webhook_endpoint_secret the webhook endpoint secret for your webhook
* endpoint, as configured in your GoCardless Dashboard
* @return GoCardlessPro\Resources\Event[] the events included in the
* webhook
* @raises GoCardlessPro\Core\Exception\InvalidSignatureException if the
* signature header specified does not match the signature computed using the
* request body and webhook endpoint secret
*/
public static function parse($request_body, $signature_header, $webhook_endpoint_secret)
{
if (self::isSignatureValid($request_body, $signature_header, $webhook_endpoint_secret)) {
$events = json_decode($request_body)->events;
return array_map('self::buildEvent', $events);
} else {
throw new Core\Exception\InvalidSignatureException(self::INVALID_SIGNATURE_MESSAGE);
}
}
/**
* Validates that a webhook was genuinely sent by GoCardless by computing its
* signature using the body and your webhook endpoint secret, and comparing that with
* the signature included in the `Webhook-Signature` header.
*
* @param string $request_body the request body
* @param string $signature_header the signature included in the request, found in the
* `Webhook-Signature` header
* @param string $webhook_endpoint_secret the webhook endpoint secret for your webhook
* endpoint, as configured in your GoCardless Dashboard
* @return boolean whether the webhook's signature is valid
*/
public static function isSignatureValid($request_body, $signature_header, $webhook_endpoint_secret)
{
$computed_signature = hash_hmac('sha256', $request_body, $webhook_endpoint_secret);
return hash_equals($computed_signature, $signature_header);
}
/**
* Internal function for converting a parsed stdObject into an event resource
*/
private static function buildEvent($event)
{
return new Resources\Event($event);
}
}
Is there a way to pass raw JSON rather than having to convert back and forth?