Skip to content

Commit

Permalink
Merge pull request #64 from amcintosh/issue-63-webhooks-documentation
Browse files Browse the repository at this point in the history
📝 Add webhooks documentation and example
  • Loading branch information
amcintosh authored Mar 30, 2024
2 parents d720b1d + b110f53 commit 2698f00
Show file tree
Hide file tree
Showing 6 changed files with 249 additions and 1 deletion.
5 changes: 4 additions & 1 deletion docs/source/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ for sample code.
Eg.

- `examples/authorization_flow.php <https://github.com/amcintosh/freshbooks-php-sdk/blob/main/examples/authorization_flow.php>`_
- `examples/create_invoice.php <https://github.com/amcintosh/freshbooks-php-sdk/blob/main/examples/create_invoice.php>`_
- `examples/create_invoice.php <https://github.com/amcintosh/freshbooks-php-sdk/blob/main/examples/create_invoice.php>`_
- `examples/webhook_flow.php <https://github.com/amcintosh/freshbooks-php-sdk/blob/main/examples/webhook_flow.php>`_

See the examples `README.md <https://github.com/amcintosh/freshbooks-php-sdk/tree/main/examples/README.md>`_ for how to run.
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
configuration
authorization
api-calls/index
webhooks
examples


Expand Down
86 changes: 86 additions & 0 deletions docs/source/webhooks.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
Webhook Callbacks
=================

The client supports registration and verification of FreshBooks' API Webhook Callbacks.
See `FreshBooks' documentation <https://www.freshbooks.com/api/webhooks>`_ for more information.

FreshBooks will send webhooks as a POST request to the registered URI with form data:

.. code-block:: http
name=invoice.create&object_id=1234567&account_id=6BApk&business_id=6543&identity_id=1234user_id=1
Registration
------------

.. code-block:: php
$clientData = array(
'event' => 'invoice.create',
'uri' => 'http://your_server.com/webhooks/ready'
);
$webhook = $freshBooksClient->callbacks()->create($accountId, data: $clientData);
echo $webhook->callbackId; // 2001
echo $webhook->verified; // false
Registration Verification
-------------------------

Registration of a webhook will cause FreshBooks to send a webhook to the specified URI with a
verification code. The webhook will not be active until you send that code back to FreshBooks.

.. code-block:: php
$freshBooksClient->callbacks()->verify($accountId, $callbackId, $verificationCode);
If needed, you can ask FreshBooks to resend the verification code.

.. code-block:: php
$freshBooksClient->callbacks()->resendVerification($accountId, $callbackId);
Hold on to the verification code for later use (see below).


Verifing Webhook Signature
--------------------------

Each Webhook sent by FreshBooks includes a header, ``X-FreshBooks-Hmac-SHA256``, with a base64-encoded
signature generated from a JSON string of the form data sent in the request and hashed with the token
originally sent in the webhook verification process as a secret.

From FreshBooks' documentation, this signature is gnerated in Python using:

.. code-block:: python
import base64
import hashlib
import hmac
import json
msg = dict((k, str(v)) for k, v in message.items())
dig = hmac.new(
verifier.encode("utf-8"),
msg=json.dumps(msg).encode("utf-8"),
digestmod=hashlib.sha256
).digest()
return base64.b64encode(dig).decode()
So to verify the signature in PHP:

.. code-block:: php
$signature = $_SERVER['HTTP_X_FRESHBOOKS_HMAC_SHA256'];
$data = json_encode($_POST);
// Signature from FreshBooks calculated from Python json.dumps, which
// produces {"key": "val", "key2": "val"}, but PHP json_encode
// produces {"key":"value","key2","val"}
$data = str_replace(":", ": ", $data);
$data = str_replace(",", ", ", $data);
$hash = hash_hmac(
'sha256',
iconv(mb_detect_encoding($data), "UTF-8", $data),
iconv(mb_detect_encoding($verifier), "UTF-8", $verifier),
true
);
$calculated_signature = base64_encode($hash);
$isAuthentic = $calculated_signature === $signature;
34 changes: 34 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,37 @@ php ./examples/create_invoice.php

Be sure to update the example files with your own credentials in place of things like `<your client_id>`,
`<your account id>`, and `<your access token>`.

## Webhooks Example

In order to demonstrate the use of webhooks, an active server is required to receive the webhook
callbacks from FreshBooks. This server must also be accessible to the open internet.

To facilitate this, the example provides some simple server code in `webhook_server.php` that will
receive the webhook and store the params and verifier signature into a csv.

To make this accessible to FreshBooks, we suggest a tool like [ngrok](https://ngrok.com/).

The example code to register a webhook for client creation events, verify the webhook, and then
create a client and receive the webhook callback is in `webhook_flow.php`.

Thus, to setup this flow:

1. Install ngrok
2. Update the `webhook_flow.php` example with your own credentials for `$fbClientId`,
`$accountId`, and `$accessToken`.
3. Start ngrok:

```shell
ngrok http 8000
```

4. Copy the ngrok "Forwarding" url (eg. `https://6e33-23-233.ngrok-free.app`) and set it as the `$uri`
variable in `webhook_flow.php`.
5. Start the webserver:

```shell
php -S 127.0.0.1:8000 webhook_server.php
```

6. Run the sample code: php ./webhook_flow.php
104 changes: 104 additions & 0 deletions examples/webhook_flow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

/*
This is an example to show how to register for webhooks, verify them,
and respond to a webhook. This requires an active server, so please
look over the instructions in the examples `README.md` for setup.
*/

require __DIR__ . '/../vendor/autoload.php';

use amcintosh\FreshBooks\Model\Callback;
use amcintosh\FreshBooks\Model\Client;
use amcintosh\FreshBooks\FreshBooksClient;
use amcintosh\FreshBooks\FreshBooksClientConfig;
use amcintosh\FreshBooks\Builder\PaginateBuilder;
use Spryker\DecimalObject\Decimal;

$fbClientId = '<your client_id>';
$accountId = '<your account_id>';
$accessToken = '<your access token>';
$uri = '<your ngrok uri>';

$conf = new FreshBooksClientConfig(accessToken: $accessToken);
$freshBooksClient = new FreshBooksClient($fbClientId, $conf);

function getWebhookResponse()
{
$fp = fopen('./webhooks.csv', 'r');
$data = fgetcsv($fp, 1000, ",");
fclose($fp);
return $data;
}

function verifyWebhookData($verifier, $signature, $data)
{
$data = json_encode($data);
// Signature from FreshBooks calculated from Python json.dumps, which
// produces {"key": "val", "key2": "val"}, but PHP json_encode
// produces {"key":"value","key2","val"}
$data = str_replace(":", ": ", $data);
$data = str_replace(",", ", ", $data);
$hash = hash_hmac(
'sha256',
iconv(mb_detect_encoding($data), "UTF-8", $data),
iconv(mb_detect_encoding($verifier), "UTF-8", $verifier),
true
);
$calculated_signature = base64_encode($hash);

return $calculated_signature === $signature;
}

// Create a webhook callback
$createData = new Callback();
$createData->event = 'client.create';
$createData->uri = $uri;

echo "Creating webhook...\n";
try {
$callback = $freshBooksClient->callbacks()->create($accountId, model: $createData);
} catch (\amcintosh\FreshBooks\Exception\FreshBooksException $e) {
echo 'Error: ' . $e->getMessage();
exit(1);
}

sleep(5);

$webhookData = getWebhookResponse();
$webhookSignature = $webhookData[0];
$webhookParams = json_decode($webhookData[1], true);
$verifier = $webhookParams['verifier'];
$webhookId = $webhookParams['object_id'];

echo "Recieved verification webhook for webhook_id {$webhookId} with verifier {$verifier}\n";

echo "Sending webhook verification...\n\n";
try {
$freshBooksClient->callbacks()->verify($accountId, $webhookId, $verifier);
} catch (\amcintosh\FreshBooks\Exception\FreshBooksException $e) {
echo 'Error: ' . $e->getMessage();
exit(1);
}

echo "Creating client to test webhook...\n";
try {
$client = $freshBooksClient->clients()->create($accountId, data: array('organization' => 'PHP Test Client'));
} catch (\amcintosh\FreshBooks\Exception\FreshBooksException $e) {
echo 'Error: ' . $e->getMessage();
exit(1);
}
echo 'Created client "' . $client->id . "\"\n";

sleep(5);

$webhookData = getWebhookResponse();
$webhookSignature = $webhookData[0];
$webhookParams = json_decode($webhookData[1], true);

echo "Recieved webhook {$webhookParams['name']} with id {$webhookParams['object_id']} and signature {$webhookSignature}\n";
if (verifyWebhookData($verifier, $webhookSignature, $webhookParams)) {
echo "\nData validated by signature!\n";
} else {
echo "\nSignature validation failed\n";
}
20 changes: 20 additions & 0 deletions examples/webhook_server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/*
This is a simple server endpoint used to demonstrate the webhook
flow seen in `./webhook_flow.php`.
Please look over the instructions in the examples `README.md` for setup.
*/

require __DIR__ . '/../vendor/autoload.php';

if (array_key_exists('HTTP_X_FRESHBOOKS_HMAC_SHA256', $_SERVER)) {
$data = array(
$_SERVER['HTTP_X_FRESHBOOKS_HMAC_SHA256'],
json_encode($_POST)
);

$fp = fopen('./webhooks.csv', 'wb');
fputcsv($fp, $data);
fclose($fp);
}

0 comments on commit 2698f00

Please sign in to comment.