Webhooks

Webhooks can be used to instantly notify your server of changes to your Goodtill data. When enabled, an HTTP POST request will be sent to your webserver containing the type and ID of the data that has changed, you will then need to use the API to retrieve the relevant data. The webhook URL for your store can be configured in the backoffice by your store owner.

POS data will incur a short delay
The Goodtill POS application syncs data (such as new sales and customers) with our backend every 30 seconds, therefore there will be up to a 30 second delay between this data being recorded on the POS and webhooks being sent because the data has not reached our backend yet. Similarly, most of the POS functionality is available offline, so you may receive a batch of webhook messages when the POS regains an internet connection after being offline for some time.

Bulk updates may trigger a large batch of webhook messages
Some bulk actions in the Backoffice, such as importing customers, trigger a webhook message per single action. This can result in your integration receiving a large number of webhook messages in a short period of time.

New events
New webhook events are added periodically. To take advantage of new events, you’ll need to enable them in the webhook settings page.

If you have any questions, please contact dev@thegoodtill.com

Setup

In order to configure webhooks for your store:

  • Login to the Goodtill backoffice with the store owner account.
  • Go to https://pos.thegoodtill.com/app/integrations/webhook
  • Enter the URL where you would like messages to be sent.
  • Choose which events you want to be notified about.
  • Click “Send test webhook” and verify that the message was received on your server.
  • Make a note of your “Verification Token” – see security section below.

Request Format

Data is sent as JSON in the POST request body.

Example request:

{
"message_id": "36bde03d-ce07-4008-901c-e7dce9c6bead",
"datetime": "2018-11-01 10:20:00",
"event": "sale.completed",
"attempt": 1,
"client": {
"id": "3d07bb96-3f01-4265-ad1c-fba1f5ad1382",
"name": "Goodtill Cafe",
"subdomain": "goodtillcafe"
},
"outlet": {
"id": "10709c98-6351-4259-8c78-06f01e2d42a6",
"name": "Main Outlet"
},
"data": {
"sale": {
"id": "ed418d47-913c-4e5c-b440-7a314e0bf1b6",
"url": "https://api.thegoodtill.com/api/sales/ed418d47-913c-4e5c-b440-7a314e0bf1b6"
}
}
}

 

Request fields:

Field Data
message_id Unique ID for the message. If message delivery fails, the same ID will be used for subsequent delivery attempts.
datetime UTC datetime when the event occured.
event Type of event that has occurred (see list below).
attempt Attempt number for sending this message.
client Goodtill store associated with the data.
outlet Store outlet associated with the data. This may be null for data which is store-wide, such as in Customer data.
data Contains the type and ID of the data that has changed, including the URL where the data can be retrieved.

Security

In order to ensure that webhook requests are legitimately sent from Goodtill, you should check that the  Goodtill-Verification-Token header in the request is equal to the Verification Token shown in the backoffice when configuring the webhook URL and reject any requests where the token does not match.

The IP address of the server which makes the requests is liable to change and should not be used to determine the authenticity of the request.

Retries

If a 200 response code is not received, the delivery of the message will be recorded as failed and the message will be sent again roughly an hour later.

A timeout will also be counted as a failure. The cutoff for receiving a response is 10 seconds.  If you are planning to perform a long-running task in response to a webhook message we strongly recommend returning a 200 response then performing the rest of the work in the background. This will prevent us sending the same message again even though it has already been accepted but no response was sent in time.

Events

Sales

Data format:

{
...
"data": {
"sale": {
"id": "ed418d47-913c-4e5c-b440-7a314e0bf1b6",
"url": "http://api.thegoodtill.com/api/sales/ed418d47-913c-4e5c-b440-7a314e0bf1b6"
}
}
}

The outlet field will be populated for these events.

Events:

ID Description Notes
sale.completed Sale moved to the completed state. Generated by POS or API.
sale.transferred Sale moved to the transferred state. Generated by POS.
sale.voided Sale moved to the voided state. Generated by POS, Backoffice or API.
sale.web_order_status_updated Sale web order status changed. Generated by POS, Backoffice or API.

 

Unlike other sale events this fires immediately.

Products

Data format:

{
...
"data": {
"product": {
"id": "767379b9-af6e-4c2b-b449-ca2d3e6706fc",
"url": "https://api.thegoodtill.com/api/products/767379b9-af6e-4c2b-b449-ca2d3e6706fc"
}
}
}

The outlet field will be populated for these events. It will be set to either the product owner or where the change occurred (eg the outlet where the stock was updated) depending on the event.

Events:

ID Description Notes
product.stock_updated Product inventory count or tracking settings has changed. Changing any of the following product fields will trigger this event: track_inventory, inventory, take_stock_from_parent.

The specified product ID can be either a parent product or a product variant.

If the inventory count or tracking settings are changed on a product where any variants inherit the stock from the parent product (take_stock_from_parent enabled), a webhook will also be sent for those variants.

The outlet field will be set to the outlet where the stock has changed.

Customers

Data format:

{
...
"data": {
"customer": {
"id": "80bea0e2-b8f0-41ac-a861-afd8ad9fd416",
"url": "https://api.thegoodtill.com/api/customers/details/80bea0e2-b8f0-41ac-a861-afd8ad9fd416"
}
}
}

The outlet field will not be populated for these events, as customers are store-wide.

Events:

ID Description Notes
customer.created New customer record created. Generated by POS, Backoffice or API.
customer.updated Customer record updated. Generated by POS, Backoffice or API.

Staff Clock Records

Data format:

{
...
"data": {
"staff_clock_record": {
"id": "5828f6a3-9dc3-4412-ada9-c1dc0af9601a",
"url": "https://api.thegoodtill.com/api/staff_clock_records/5828f6a3-9dc3-4412-ada9-c1dc0af9601a"
}
}
}

The outlet field will be populated for these events.

In cases where a user clocks out immediately after clocking in, you may find that the clock record clock_out value is already populated when handling a staff_clock_record.clock_in_pos event. You will still receive a staff_clock_record.clock_out_pos event message in this case.

Events:

ID Description Notes
staff_clock_record.clock_in_pos Staff member clocked in on POS application.  
staff_clock_record.clock_out_pos Staff member clocked out on POS application.  
staff_clock_record.created_external Staff clock record created externally (Backoffice or API).  
staff_clock_record.updated_external Staff clocked record updated externally (Backoffice, API).  

Loyalty Rewards

Data format:

{
...
"data": {
"loyalty_reward": {
"id": 15,
"url": "https://api.thegoodtill.com/api/loyalty/reward/15"
}
}
}

The outlet field will be populated for these events, set to the outlet where the sale was made.

Events:

ID Description Notes
loyalty_reward.earned Loyalty points earned from a sale.  
loyalty_reward.redeemed Loyalty points redeemed during a sale. Multiple events may occur for a single sale.

Example Code

The following is a minimal example of how you can process Goodtill webhook messages.

The code does the following:

  • Check that the Goodtill-Verification-Token header is correct.
  • Obtain a JWT for accessing the Goodtill API.
  • Fetches the customer or sale data, depending on the event, using the API.

You will need to enter the credentials for an admin or store owner and account and your verification token in the define() calls.

In order to keep this example simple, we will call the POST api/login endpoint each time this script is called. Ideally you would save the token in your database allowing you to reuse it until it needs to be refreshed.

<?php

// This script requires PHP >=7.1

// Set Goodtill credentials
define('GOODTILL_SUBDOMAIN', '');
define('GOODTILL_USERNAME', '');
define('GOODTILL_PASSWORD', '');
define('GOODTILL_VERIFICATION_TOKEN', '');

class GoodtillException extends \Exception { }

class GoodtillClient {

public $token = null;
public $outletId = null;
public $vendorId = null;

public function request(string $url, string $method='GET', array $data=[]): array
{
$method = strtoupper($method);
$request_url = 'https://api.thegoodtill.com/api/'.$url;

if ($method == 'GET')
$request_url .= '?'.http_build_query($data);

$headers = [
'Content-type: application/json',
'User-Agent: GoodtillPhpClient (+https://support.thegoodtill.com/support/api/)',
];
if ($this->token)
$headers[] = 'Authorization: Bearer '.$this->token;
if ($this->outletId)
$headers[] = 'Outlet-Id: '.$this->outletId;
if ($this->vendorId)
$headers[] = 'Vendor-Id: '.$this->vendorId;

// Make request
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $request_url);
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
if ($method != 'GET'){
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
}
$responseBody = curl_exec($curl);
$responseCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
$error = curl_error($curl);
curl_close($curl);

$parsedBody = json_decode($responseBody, true);

// Check for errors
if (empty($error) == false)
throw new GoodtillException($error);

if ($responseCode > 400){
$errorMessage = $parsedBody['message'] ?? $parsedBody['error'] ?? $responseBody;
throw new GoodtillException($errorMessage, $responseCode);
}

// Return data
return $parsedBody;
}

public function login(string $subdomain, string $username, string $password): array
{
$data = $this->request(
'login',
'POST',
[
'subdomain' => $subdomain,
'username' => $username,
'password' => $password,
]
);

$this->token = $data['token'];

return $data;
}
}

try {
// Check that the request uses the post method
if ($_SERVER['REQUEST_METHOD'] !== 'POST')
throw new \Exception('Request must use the POST HTTP method.');

// Check the Verification-Token
$request_verification_token = $_SERVER[$HTTP_GOODTILL_VERIFICATION_TOKEN] ?? null;
if ($request_verification_token != GOODTILL_VERIFICATION_TOKEN)
throw new \Exception('Invalid Verification-Token - please check that you have copied the correct token from the backoffice.');

// Get the request content
$request_body = file_get_contents('php://input');
$data = json_decode($request_body, true);

// Login
$goodtill = new GoodtillClient();
$goodtill->login(GOODTILL_SUBDOMAIN, GOODTILL_USERNAME, GOODTILL_PASSWORD);

// Get event
$event = $data['event'];

// New sale event
if ($event === 'sale.completed'){

// Fetch sale data
$sale_id = $data['data']['sale']['id'];
$response = $goodtill->request('sales/'.$sale_id);
$sale = $response['data'];

// Use the sale data
printf('New Goodtill sale - %s at %s', $sale['id'], $sale['sales_date_time']);
}
// New customer event
else if ($event === 'customer.created'){

// Fetch customer data
$customer_id = $data['data']['customer']['id'];
$response = $goodtill->request('customers/details/'.$customer_id);
$customer = $response['data']['model']['data'];

// Use the customer data
printf('New Goodtill customer - %s - %s', $customer['id'], $customer['name']);
}
// Another event
else {
throw new \Exception('Unhandled event '.$event);
}

} catch (GoodtillException $e) {
echo 'Goodtill API exception: '.$e->getMessage();
} catch (\Exception $e) {
echo 'Generic exception: '.$e->getMessage();
}