diff --git a/docs/source/examples.rst b/docs/source/examples.rst index d9bd3da..12ac007 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -7,4 +7,7 @@ for sample code. Eg. - `examples/authorization_flow.php `_ -- `examples/create_invoice.php `_ \ No newline at end of file +- `examples/create_invoice.php `_ +- `examples/webhook_flow.php `_ + +See the examples `README.md `_ for how to run. \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 4a9f042..4678095 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -9,6 +9,7 @@ configuration authorization api-calls/index + webhooks examples diff --git a/docs/source/webhooks.rst b/docs/source/webhooks.rst new file mode 100644 index 0000000..ecf5270 --- /dev/null +++ b/docs/source/webhooks.rst @@ -0,0 +1,86 @@ +Webhook Callbacks +================= + +The client supports registration and verification of FreshBooks' API Webhook Callbacks. +See `FreshBooks' documentation `_ 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; diff --git a/examples/README.md b/examples/README.md index 5b23bce..2a0b5fe 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 ``, ``, and ``. + +## 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 diff --git a/examples/webhook_flow.php b/examples/webhook_flow.php new file mode 100644 index 0000000..0b8f594 --- /dev/null +++ b/examples/webhook_flow.php @@ -0,0 +1,104 @@ +'; +$accountId = ''; +$accessToken = ''; +$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"; +} diff --git a/examples/webhook_server.php b/examples/webhook_server.php new file mode 100644 index 0000000..c5e097c --- /dev/null +++ b/examples/webhook_server.php @@ -0,0 +1,20 @@ +