Getting Started

This guide will go over the basics for integrating your web store with Shopfront, we've tried to make it language agnostic and presume the minimal amount about the platform you're using, however some parts may require a bit of modification to get to work with your platform.

There are a great many additional features that aren't covered in this guide but are possible with the API, this should be used as a starting point and then additional functionality should be fairly simple to add.

We'll presume the following:

  • Your eCommerce store has no products before starting (otherwise you'll have to match the products)
  • You can track Shopfront's product ID in your system (otherwise you'll have to store your product ID in Shopfront, but that is outside of this guide)
  • Shopfront will be the source of truth for your data

It's possible to set up a two-way sync or setup your eCommerce store as the source of truth, but those options are outside the scope of this guide.

We're also going to presume that the Vendor you are integrating with is has multiple Outlets. This allows you to develop with the most versatility and makes integrating single Outlets simple.

If you're going to send sales back to Shopfront we would suggest setting up an additional register (note, an additional cost may apply) to handle web sales - this prevents sales from the web store from affecting the physical register's end-of-day takings. More information and options are available in the Sending Sales Back to Shopfront section.

On the right-hand side we've included a class we've used in several of our own integrations (in JavaScript, we've converted it to other languages) which assists in handling things like token refreshes, and the Shopfront rate limiter. We've converted it to use fetch as we use our own request system but haven't tested it with fetch, so you may need to tweak it. You'll also need to change it to get it working with your platform, and you should add additional error handling, however it provides a good starting point.

Shopfront Utility Class
class Shopfront {
    #bearer  = false;
    #refresh = false;
    #options = {
        clientId       : "",
        clientSecret   : "",
        subdomain      : "",
        shopfrontURL   : "onshopfront.com",
        integrationName: "eCommerce-Example"
    };

    // This storage class is an example to contain tokens,
    // you might use a database or another method to store it with
    #storage = {
        get: async (from) => {
            // Get data from your storage system
        },
        put: async (where, data) => {
            // Put data into your storage system
        },
    };

    constructor(options, storage) {
        this.#options = {
            ...this.#options,
            ...options,
        };

        this.#storage = storage;
    }

    #buildURL() {
        return new URL(`https://${this.#options.subdomain}.${this.#options.shopfrontURL}`);
    }

    async #getBearerToken() {
        if(this.#bearer) {
            return this.#bearer;
        }

        try {
            const tokens = await this.#storage.get("authenticationTokens");

            this.#bearer  = tokens.access_token;
            this.#refresh = tokens.refresh_token;
        } catch(e) {
            throw new Error("Bearer token missing for Shopfront");
        }

        return this.#bearer;
    }

    async #refreshToken() {
        if(!this.#refresh) {
            await this.#getBearerToken();
        }

        const url = `${this.#buildURL()}/oauth/token`;

        return fetch(url, {
            method : "POST",
            headers: {
                "Content-Type": "application/json",
                "Accept"      : "application/json",
            },
            body: JSON.stringify({
                client_id    : this.#options.clientId,
                client_secret: this.#options.clientSecret,
                grant_type   : "refresh_token",
                refresh_token: this.#refresh,
            }),
        })
            .then(response => response.json())
            .then(body => {
                this.#bearer  = body.access_token;
                this.#refresh = body.refresh_token;

                return this.#storage.put("authenticationTokens", body);
            });
    }

    #createCatch = (retryMethod, parameters) => {
        return (err) => {
            if(typeof err.statusCode === "number") {
                if(err.statusCode === 401) {
                    let error = "";
                    if(typeof err.data === "string") {
                        error = err.data;
                    } else if(typeof err.data === "object") {
                        error = err.data.error;
                    }

                    // Invalid bearer token
                    if(error === "invalid_token" || error === "Unauthenticated.") {
                        return this.#refreshToken()
                            .then(() => retryMethod(...parameters));
                    }
                } else if(err.statusCode === 429) {
                    // Throttled by the rate limiter
                    return new Promise(res => {
                        setTimeout(() => {
                            res(retryMethod(...parameters));
                        }, 30 * 1000);
                    });
                }
            }

            throw err;
        };
    };

    request = async (method, endpoint, headers = {}, body = null) => {
        if(endpoint.startsWith("/")) {
            endpoint = endpoint.slice(1);
        }

        const url = new URL(`${this.#buildURL()}/${endpoint}`);

        headers = {
            "Authorization": `Bearer ${await this.#getBearerToken()}`,
            "Content-Type" : "application/json",
            "Accept"       : "application/json",
            "User-Agent"   : this.#options.integrationName,
            ...headers,
        };

        return fetch(url, {
            method,
            headers,

            body: body === null ? undefined : JSON.stringify(body),
        })
            .then(response => response.json())
            .catch(this.#createCatch(this.request, arguments));
    };

    graphQL = async (query, variables = {}) => {
        return this.request("POST", "/api/v2/graphql", {}, {
            query,
            variables,
        })
            .then(response => {
                if(typeof response.data !== "object") {
                    throw response;
                }

                if(typeof response.errors !== "undefined") {
                    throw response.errors;
                }

                return response.data;
            });
    }
}
<?php

interface StorageInterface {
    public function get(string $from);
    public function put(string $where, $data);
}

class Shopfront {
    private $bearer;
    private $refresh;
    private $storage;
    private $options = [
        'clientId'        => '',
        'clientSecret'    => '',
        'subdomain'       => '',
        'shopfrontURL'    => 'onshopfront.com',
        'integrationName' => 'eCommerce-Example',
    ];

    public function __construct(array $options, StorageInterface $storage) {
        $this->options = array_merge($this->options, $options);
        $this->storage = $storage;
    }

    private function buildURL() {
        return "https://{$this->options['subdomain']}.{$this->options['shopfront_url']}";
    }

    private function getBearerToken() {
        if($this->bearer) {
            return $this->bearer;
        }

        try {
            $tokens = $this->storage->get("authenticationTokens");

            $this->bearer  = $tokens->access_token;
            $this->refresh = $tokens->refresh_token;
        } catch(Exception $e) {
            throw new Exception("Bearer token missing for Shopfront", 0, $e);
        }

        return $this->bearer;
    }

    private function refreshToken() {
        if(!$this->refresh) {
            $this->getBearerToken();
        }

        $url = "{$this->buildURL()}/oauth/token";
        $ch  = curl_init($url);

        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            "Content-Type" => "application/json",
            "Accept"       => "application/json",
        ]);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
            "client_id"     => env("SHOPFRONT_CLIENT_ID"),
            "client_secret" => env("SHOPFRONT_CLIENT_SECRET"),
            "grant_type"    => "refresh_token",
            "refresh_token" => $this->refresh,
        ]));

        $response    = curl_exec($ch);
        $status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

        curl_close($ch);

        if($status_code !== 200) {
            // Invalid refresh token, you'll likely have to reintegrate
            throw new Exception("Unable to refresh the access token");
        }

        $body = json_decode($response);

        if($body === false) {
            throw new Exception("Unable to refresh access token, invalid response returned from Shopfront");
        }

        $this->bearer  = $body->access_token;
        $this->refresh = $body->refresh_token;

        $this->storage->put("authenticationTokens", $body);
    }

    private function validateResponse($ch, $retry_args) {
        $response    = curl_exec($ch);
        $status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

        curl_close($ch);

        $body = json_decode($response);

        if($body === false) {
            throw new Exception('The request returned an invalid body that could not be parsed as JSON');
        }

        if($status_code === 200) {
            return $body;
        }

        if($status_code === 401) {
            if(isset($body->error) && $body->error === 'invalid_token') {
                // Invalid bearer token, attempt to refresh
                $this->refreshToken();

                // Once refreshed, retry the request
                return $this->request($retry_args['method'], $retry_args['endpoint'], $retry_args['headers'], $retry_args['body']);
            }
        } else if($status_code === 429) {
            // Throttled by the rate limiter
            sleep(30);

            return $this->request($retry_args['method'], $retry_args['endpoint'], $retry_args['headers'], $retry_args['body']);
        }

        // You'll probably want to handle this differently
        throw new Exception("Invalid Shopfront Response");
    }

    public function request($method, $endpoint, $headers = [], $body = null) {
        $endpoint = ltrim($endpoint, '/');
        $url      = "{$this->buildURL()}/{$endpoint}";

        $bearer_token = $this->getBearerToken();

        $headers['Authorization'] = "Bearer {$bearer_token}";
        $headers['Content-Type']  = 'application/json';
        $headers['Accept']        = 'application/json';
        $headers['User-Agent']    = $this->options['integrationName'];

        $ch  = curl_init($url);

        if($method === "POST") {
            curl_setopt($ch, CURLOPT_POST, true);
        }

        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

        if($body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
        }

        return $this->validateResponse($ch, [
            'method'   => $method,
            'endpoint' => $endpoint,
            'headers'  => $headers,
            'body'     => $body,
        ]);
    }

    public function graphql($query, $variables) {
        $response = $this->request('POST', '/api/v2/graphql', [], [
            'query'     => $query,
            'variables' => $variables,
        ]);

        // You'll probably also want to handle these differently
        if(!is_object($response->data)) {
            throw new Exception("Invalid response, no data object included");
        }

        if(isset($response->errors)) {
            throw new Exception("Invalid query, errors returned");
        }

        return $response->data;
    }
}

Full Synchronisation of Products

After integrating Shopfront with your web store (not sure how to do this? Check out our authentication guide) you'll want to obtain all the products out of Shopfront. This will likely require a one-time full synchronisation (whilst it's likely only going to be a one-time operation, you should leave it available for future use in-case of downtime where you've missed product updates) which involves querying the Shopfront GraphQL API for all the product information.

This is likely to be a long-running operation that depending upon the amount of products, and the data you're obtaining from Shopfront, may take several hours to complete. So we would suggest either being able to run it from some sort of queue, through the command line or within a once-off schedule that doesn't have a timeout.

Using the Shopfront class that was provided withing the Getting Started section, you'll want to send a query to the Shopfront GraphQL API for the connection of products and loop through them until you get to the end, while looping through products you'll want to either insert them as you come across them, or batch-insert them into your store - if possible, we suggest using asynchronous programming that allows you to retrieve products from Shopfront at the same time as inserting them to improve the speed of the operation (our JavaScript shown on the right demonstrates an example of this, whereas the PHP example shows it happening synchronously with a bulk-insert).

Once you've completed the full synchronisation of products your store should be essentially ready to trade, you'll just now need to worry about keeping the products up-to-date and making sure Shopfront updates its inventory when a sale occurs on the web store.

Full Product Synchronisation
const shopfront = new Shopfront({
    clientId       : "<YOUR-CLIENT-ID>",
    clientSecret   : "<YOUR-CLIENT-SECRET>",
    subdomain      : "<CONNECTED-SUBDOMAIN>",
    integrationName: "<YOUR-APPLICATION-NAME>",
}, yourStorage);

const insertProduct = async (product) => {
    // Insert your logic here to insert the product into your store.
    // We would also suggest checking if the product already exists
    // in case this is run in the future.
    // Here's what an example product from the query below might
    // look like:
    /*
     *  {
     *      id: "11e6...",
     *      name: "Test Product",
     *      prices: [{
     *          quantity: 1,
     *          price: 5,
     *      }, {
     *          quantity: 6,
     *          price: 25.95
     *      }],
     *      category: {
     *          id: "11e7...",
     *          name: "Test Category",
     *      },
     *      inventory: [{
     *          outlet: {
     *              id: "11e8...",
     *          },
     *          quantity: 27
     *      }, {
     *          outlet: {
     *              id: "11e9...",
     *          },
     *          quantity: 4,
     *      }],
     *  }
     */
};

// We're implementing loadPage recursively, but you could use a loop
const loadPage = async (after = null, productPromises = []) => {
    // You'll probably want to customise this query to get the data
    // that you want to display on your store
    const products = await shopfront.graphQL(`
        query GetProducts($after: Cursor) {
            products(after: $after) {
                edges {
                    node {
                        id,
                        name,
                        prices {
                            quantity,
                            price,
                        },
                        category {
                            id,
                            name,
                        },
                        inventory {
                            outlet {
                                id,
                            },
                            quantity,
                        },
                    },
                },
                pageInfo {
                    hasNextPage,
                    endCursor,
                },
            }
        }
    `, {
        after,
    });

    for(let i = 0, l = products.edges.length; i < l; i++) {
        // At the moment we're just adding them all straight into the
        // database, this may be fine, but it may also be too much
        // for the database so you may want to rate limit it somehow
        productPromises.push(insertProduct(products.edges[i].node));
    }

    if(products.pageInfo.hasNextPage) {
        return loadPage(products.pageInfo.endCursor, productPromises);
    } else {
        return await Promise.all(productPromises);
    }
};

// Start the sync
(async () => {
    console.log("Starting sync");
    await loadPage();
    console.log("Sync finished");
})();
<?php

$shopfront = new Shopfront([
    'clientId'        => '<YOUR-CLIENT-ID>',
    'clientSecret'    => '<YOUR-CLIENT-SECRET>',
    'subdomain'       => '<CONNECTED-SUBDOMAIN>',
    'integrationName' => '<YOUR-APPLICATION-NAME>',
], $yourStorage);

function formatProduct(stdClass $product) {
    // Insert your logic to format your product into the required database query
    // If you want an example of how the product would appear, check the JavaScript
    // example.
}

function insertProducts(array $products) {
    // Insert the products into the database
}

// We're implementing loadPage recursively, but you could use a loop
function loadPage(string|null $after = null) {
    // You'll probably want to customise this query to get the data
    // that you want to display on your store
    global $shopfront;
    $products = $shopfront->graphql('
        query GetProducts($after: Cursor) {
            products(after: $after) {
                edges {
                    node {
                        id,
                        name,
                        prices {
                            quantity,
                            price,
                        },
                        category {
                            id,
                            name,
                        },
                        inventory {
                            outlet {
                                id,
                            },
                            quantity,
                        },
                    },
                },
                pageInfo {
                    hasNextPage,
                    endCursor,
                },
            }
        }
    ', [
        'after' => $after
    ]);

    $data = [];
    for($i = 0, $c = count($products->edges); $i < $c; $i++) {
        $data[] = formatProduct($products->edges[$i]->node);
    }

    insertProducts($data);

    if($products->pageInfo->hasNextPage) {
        return loadPage($products->pageInfo->endCursor);
    }
}

// Start the sync
loadPage();

Keeping Products Up-to-date

Now you've received the products from Shopfront, you'll need to keep them up-to-date, you've got two options, to either perform a routine sync with Shopfront which will result in lagging updates or to opt-in to Shopfront's webhooks (HIGHLY recommended) which provide realtime updates to your web store as they happen in Shopfront. We're going to presume that you've elected to go for the webhook option as it is by-far the better option.

To start with, you'll need to register your webhook, we'd recommend starting with the following webhook events:

  • PRODUCT_CREATED (for new products),
  • PRODUCT_UPDATED (for updates such as name and category changes),
  • PRODUCT_DELETED (for deleted products),
  • INVENTORY_UPDATED (for when a product's inventory changes)

Now, you can either register them all as one webhook or each as individual webhooks, the choice is yours and likely depends upon the structure of your application, in our example to the right we've registered just one webhook which contains all the events - we then check the webhook to determine what to do with it.

Example
Registering Webhooks
const shopfront = new Shopfront(yourOptions, yourStorage);

const registerWebhooks = () => {
    return shopfront.graphQL(`
        mutation RegisterWebhook(
            $name: String!,
            $url: String!,
            $events: [WebhookEventEnum!]
        ) {
            registerWebhook(
                name: $name,
                url: $url,
                events: $events
            ) {
                id
            }
        }
    `, {
        // Whilst the name is not currently visible in the UI,
        // you should name it as if it was.
        name  : "Web Store Products",
        url   : "https://your-domain.store/shopfront/webhook",
        events: [
            "PRODUCT_CREATED",
            "PRODUCT_UPDATED",
            "PRODUCT_DELETED",
            "INVENTORY_UPDATED",
        ],
    });
    // You might want to store the IDs of the webhooks, but that's up to you
};

registerWebhooks();
Register Webhooks
<?php

function registerWebhooks() {
    $shopfront = new Shopfront($options, $storage);

    $shopfront->graphql('
        mutation RegisterWebhook(
            $name: String!,
            $url: String!,
            $events: [WebhookEventEnum!]
        ) {
            registerWebhook(
                name: $name,
                url: $url,
                events: $events
            ) {
                id
            }
        }
    ', [
        // Whilst the name is not currently visible in the UI,
        // you should name it as if it was.
        'name'   => 'Web Store Products',
        'url'    => 'https://your-domain.store/shopfront/webhook',
        'events' => [
            'PRODUCT_CREATED',
            'PRODUCT_UPDATED',
            'PRODUCT_DELETED',
            'INVENTORY_UPDATED',
        ],
    ]);

    // You might want to store the ID of the webhook, but that's up to you.
}

registerWebhooks();

We would suggest reading the documentation for more information on how webhooks work and the intricacies with Shopfront's webhook system. One thing in particular to note is that a webhook must respond to Shopfront within 30 seconds, or it will be marked as failed. To avoid this, we would suggest either returning a response to Shopfront before handling the webhook, queuing the webhook to be processed separately or storing the data and processing them on a schedule. We're not doing this in the example code as it is highly dependent upon your platform as to what is the best way to handle this.

Once the webhook has been registered you'll need to handle receiving requests on it, we've provided some basic code in the example for each of the webhook events mentioned previously but you'll need to tweak this to how your system handles updates.

We would also suggest storing the webhook's ID to prevent it from being processed multiple times in the event of Shopfront not receiving a valid response from your web store, but the webhook was handled correctly. Additionally, Shopfront signs each webhook so your application can ensure the request is coming from Shopfront's server. Verification isn't required, but is highly recommended.

Handling Webhooks
const handleWebhook = request => {
    // First we'll validate the webhook to ensure it looks like something
    // from Shopfront
    if(!request.body.id || !request.body.event || !request.body.timestamp || !request.body.payload) {
        return;
    }

    // This is a function to ensure that this Vendor is currently active
    // in your store
    if(!validateVendor(request.body.vendor)) {
        return;
    }

    // Handle the event, you'll have to customise these functions.
    switch(request.body.event) {
        case "PRODUCT_CREATED":
            return createProduct(request.body.payload);
        case "PRODUCT_UPDATED":
            // Sometimes the update and create may be combined into one function
            return updateProduct(request.body.payload);
        case "PRODUCT_DELETED":
            return deleteProduct(request.body.payload);
        case "INVENTORY_UPDATED":
            return updateInventory(request.body.payload);
    }
};

// You'll have to plug this in to your routing system
routes.post("/shopfront/webhook", request => {
    // Whilst our handleWebhook function should finish in less than one second
    // we're intentionally ignoring the promise so we can return a response
    // to Shopfront quicker, depending on your website, that may not be
    // possible and you'll have to use another method to respond quickly
    // to Shopfront.
    handleWebhook(request);

    // We're sending back the status code 204,
    // check out the webhook documentation for more information.
    return response.noContent(204);
});
<?php

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

function handleWebhook(stdClass $request) {
    // First we'll validate the webhook to ensure it looks like something
    // from Shopfront
    if(!$request->body->id || !$request->body->event || $request->body->timestamp || !$request->body->payload) {
        return;
    }

    // This is a function to ensure that this Vendor is currently active
    // in your store
    if(!validateVendor($request->body->vendor)) {
        return;
    }

    // Handle the event, you'll have to customise these functions.
    switch($request->body->event) {
        case "PRODUCT_CREATED":
            return createProduct($request->body->payload);
        case "PRODUCT_UPDATED":
            // Sometimes the update and create may be combined into one function
            return updateProduct($request->body->payload);
        case "PRODUCT_DELETED":
            return deleteProduct($request->body->payload);
        case "INVENTORY_UPDATED":
            return updateInventory($request->body->payload);
    }
}

// You'll have to plug this in to your routing system
Route::post("/shopfront/webhook", function(ServerRequestInterface $request): ResponseInterface {
    // By default PHP isn't asynchronous, so you'll need to ensure your webhook
    // handling is quick. Sometimes it might be better if you add the webhook handling
    // into a queue or similar system to process.
    handleWebhook(json_decode($request->getBody()));

    // We're sending back the status code 204,
    // check out the webhook documentation for more information.
    return Response::noContent(204);
});

Sending Sales Back to Shopfront

If you want the user to control fulfilment and want to make it easy to process orders without having to leave Shopfront, we'd suggest having a look at our how to document on fulfilment

You've got quite a lot of choices when it comes to sending sales back into Shopfront, and a lot of the decision will come down to how your customers are receiving the stock (is it being delivered or are they coming to pick it up), how the Vendor wishes to handle inventory and how the Vendor wishes to report on the data from the web store.

Whilst there are more options, we've typically found most stores want one of the following options:

  • Have a separate "online" outlet which contains an "online" register that the sales get attributed to. The store would then transfer stock between the online store and the physical store. This allows for a great separation of reporting, however it is more time-consuming as the "online" outlet acts like a warehouse location without the actual warehouse having any live stock.
  • Have an outlet (physical location) that contains an "online" register that the sales get attributed to. Online purchases then pull stock directly out of the physical store. This allows for a similar level of reporting but typically mixes the online sales with the POS sales in Shopfront's sales reports.
  • Run the eCommerce store separately to the POS and just send inventory updates (without sales). This prevents reports from being run through Shopfront and losses visibility into the sales from Shopfront.

We're going to presume that you're using either the first or second option as they are the same from a development point-of-view and are instead just different setups.

The next part to work out is when you're sending the sale to Shopfront and how the sale should be received in Shopfront.

Most web store platforms have multiple statuses when it comes to performing a sale, typically some statues like these:

  • Order placed,
  • Order paid,
  • Order shipped,
  • Order received

There might be other statuses in your platform or there could be less. When sending to Shopfront, you have the following three statuses to use for sales:

  • COMPLETED - the sale has been paid and inventory has been taken
  • INCOMPLETE - the sale is unpaid but inventory has been taken (typically used for debtor customers),
  • PARKED - the sale isn't finished, but it may have been paid, inventory has not been taken

The status you use and what status it corresponds to in your platform is up to you and the store as to how they want to manage stock.

In the example we're creating the sale in Shopfront when the order has been paid, we're also marking it as COMPLETED to deduct the stock instantly.

Sending Sales
// You'll have to hook a function into your web store
webStore.addEventListener("ORDER_PAID", async order => {
    const shopfront = new Shopfront(options, storage);

    // Shopfront records who performed the sale. Querying `viewer` returns the
    // user your application is authenticated as, which you're welcome to use -
    // alternatively, select a different user's id if you'd prefer sales to be
    // attributed to someone else. You'll likely want to cache this rather than
    // querying it for every sale.
    const { viewer } = await shopfront.graphQL(`
        {
            viewer {
                id
            }
        }
    `);

    // This is essentially the base sale, there are a number
    // of other fields you could consider populating, such
    // as the customer.
    const sale = await shopfront.graphQL(`
        mutation CreateSale($sale: SaleInput) {
            createSale(sale: $sale) {
                id
            }
        }
    `, {
        sale: {
            id      : order.id,
            register: "<REGISTER-TO-SEND-TO>",
            user    : viewer.id,
            status  : "COMPLETED",
            items   : order.products.map(product => ({
                total  : product.price,
                product: {
                    id      : product.shopfront.id,
                    quantity: product.quantity,
                },
            })),
            payments: order.payments.map(payment => ({
                method: payment.shopfront.id,
                amount: payment.amount,
            })),
        },
    });

    // You'll probably want to store a reference to the sale somewhere.
});
<?php

// You'll have to hook a function into your web store
WebStore::addEventListener("ORDER_PAID", function($order) {
    $shopfront = new Shopfront($options, $storage);

    // Shopfront records who performed the sale. Querying `viewer` returns the
    // user your application is authenticated as, which you're welcome to use -
    // alternatively, select a different user's id if you'd prefer sales to be
    // attributed to someone else. You'll likely want to cache this rather than
    // querying it for every sale.
    $viewer = $shopfront->graphQL('
        {
            viewer {
                id
            }
        }
    ', [])->viewer;

    // This is essentially the base sale, there are a number
    // of other fields you could consider populating, such
    // as the customer.
    $sale = $shopfront->graphQL('
        mutation CreateSale($sale: SaleInput) {
            createSale(sale: $sale) {
                id
            }
        }
   ', [
        'sale' => [
            'id'       => $order->id,
            'register' => "<REGISTER-TO-SEND-TO>",
            'user'     => $viewer->id,
            'status'   => "COMPLETED",
            'items'    => array_map(function($product) {
                return [
                    'total'   => $product->price,
                    'product' => [
                        'id'       => $product->shopfront->id,
                        'quantity' => $product->quantity,
                    ],
                ];
            }, $order->products),
            'payments' => array_map(function($payment) {
                return [
                    'method' => $payment->shopfront->id,
                    'amount' => $payment->amount,
                ];
            }, $order->payments),
        ],
    ]);

    // You'll probably want to store a reference to the sale somewhere.
});

Final Notes

From here you should have a fully-functioning web store!

There are a great many additional parts that you can add to your store to further integrate it with Shopfront, including (but not limited to):