Google Click Fraud


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


In order to configure webhooks for your store:

  • Login to the Goodtill backoffice with the store owner account.
  • Go to
  • 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": ""

Request fields:

message_id Unique ID for the message. If message delivery fails, the same ID will be used for subsequent delivery attempts.
datetimeUTC datetime when the event occured.
eventType of event that has occurred (see list below).
attemptAttempt number for sending this message.
clientGoodtill store associated with the data.
outletStore outlet associated with the data. This may be null for data which is store-wide, such as in Customer data.
dataContains the type and ID of the data that has changed, including the URL where the data can be retrieved.


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.


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.



Data format:

    "data": {
        "sale": {
            "id": "ed418d47-913c-4e5c-b440-7a314e0bf1b6",
            "url": ""

The outlet field will be populated for these events.


sale.completedSale moved to the completed state.Generated by POS or API.
sale.transferredSale moved to the transferred state.Generated by POS.
sale.voidedSale moved to the voided state.Generated by POS, Backoffice or API.


Data format:

    "data": {
        "product": {
             "id": "767379b9-af6e-4c2b-b449-ca2d3e6706fc",
             "url": ""

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.


product.stock_updatedProduct 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.


Data format:

    "data": {
        "customer": {
            "id": "80bea0e2-b8f0-41ac-a861-afd8ad9fd416",
            "url": ""

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


customer.createdNew customer record created.Generated by POS, Backoffice or API.
customer.updatedCustomer record updated.Generated by POS, Backoffice or API.

Staff Clock Records

Data format:

    "data": {
        "staff_clock_record": {
            "id": "5828f6a3-9dc3-4412-ada9-c1dc0af9601a",
            "url": ""

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.


staff_clock_record.clock_in_posStaff member clocked in on POS application.
staff_clock_record.clock_out_posStaff member clocked out on POS application.
staff_clock_record.created_externalStaff clock record created externally (Backoffice or API).
staff_clock_record.updated_externalStaff clocked record updated externally (Backoffice, API).

Loyalty Rewards

Data format:

    "data": {
        "loyalty_reward": {
            "id": 15,
            "url": ""

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


loyalty_reward.earnedLoyalty points earned from a sale.
loyalty_reward.redeemedLoyalty 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 $goodtill_jwt in your database allowing you to reuse it until it needs to be refreshed.

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

// Store login JWT for future requests
$goodtill_jwt = null;

 * Make API request to the Goodtill API, authenticated with JWT and
 * return the response body.
 * @param string $url 
 * @param string $method
 * @param array $data
 * @param null|string $outlet_id
 * @return null|array
function makeRequest($url, $method='GET', $data=[], $outlet_id=null)
    global $goodtill_jwt;

    $url = ''.$url;

    $body = null;
    $headers = ['Content-type: application/json'];

    if ($goodtill_jwt != null)
        $headers[] = 'Authorization: Bearer '.$goodtill_jwt;

    // Pass an outlet ID if we want to get data for a specific outlet,
    // rather than the outlet assigned to the user.
    if ($outlet_id != null)
        $headers[] = 'Outlet-Id: '.$outlet_id;

    // Add parameters to URL
    if ($method == 'GET'){
        if (empty($data) == false)
            $url .= '?'.http_build_query($data);
    // Add data to request body
    else if ($method == 'POST'){
        $body = json_encode($data);

    $opts = ['http' =>
            'method'  => $method,
            'header'  => implode("\r\n", $headers),
            'content' => $body,

    $context = stream_context_create($opts);
    $response = file_get_contents($url, false, $context);
    return json_decode($response, true);

// Check that the request uses the post method
    die('Request must use the POST HTTP method.');

// Check the Verification-Token
$request_verification_header = 'HTTP_GOODTILL_VERIFICATION_TOKEN';
$request_verification_token = isset($_SERVER[$request_verification_header]) ? 
    $_SERVER[$request_verification_header] : null;

if ($request_verification_token != GOODTILL_VERIFICATION_TOKEN)
    die('Invalid Verification-Token - please check that you have copied the correct token from the backoffice.');

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

// Get a JWT for authorising future requests
$response = makeRequest(
        'subdomain' => GOODTILL_SUBDOMAIN,
        'username' => GOODTILL_USERNAME,
        'password' => GOODTILL_PASSWORD,

// Check for response error (eg invalid credentials)
if ($response == null)
    die('Login error - please check the credentials.');

// Check that an admin or store own account has been used
if ($response['user_level'] == 'operator')
    die('Operator logins cannot be used to make API request - use an admin or store owner account instead.');

// Set JWT for future requests. We'll need this to fetch the data (eg sale or customer)
// that we're being notified about.
$goodtill_jwt = $response['token'];

// Handle the event
$event = $data['event'];

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

    // Fetch sale data
    $response = makeRequest('sales/'.$data['data']['sale']['id'], 'GET');
    $sale = $response['data'];

    if ($sale == null)
        die('Failed to fetch sale.');

    // 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
    $response = makeRequest('customers/details/'.$data['data']['customer']['id'], 'GET');
    $customer = $response['data']['model']['data'];

    if ($customer == null)
        die('Failed to fetch customer.');

    // Use the customer data
    printf('New Goodtill customer - %s - %s', $customer['id'], $customer['name']);
// Another event
else {
    die('Unhandled event '.$event);
Need Further Helps?

We are always happy to help with any issues you may be having. If you can't find what you're looking for within our support portal please send us a message by clicking the button below or call our support team on 0203 322 4095.