Recently when working to migrate an e-commerce website to the aspiring Shopify Cloud Platform, generating coupon codes through the API was dismissed as being a obvious, simple, and apparently one of the most requested features back in March, 2011.

Unfortunately, I was wrong. So I contacted their support team to see what’s up!

Shopify Support Snapshot

Thanks for the help, Brian. /s

Being unaccustomed to “no”, and particularly impatient I decided to develop my own solution utilizing the very same API’s Shopify created for themselves in their admin panel.

So it all began when I decided to poke at how they’re being loaded into Shopify’s back-end

GET /admin/discounts.json?limit=50&order=id+DESC&direction=next HTTP/1.1
{
    "discounts": [{
        "applies_once": false,
        "applies_to_id": null,
        "code": "ypa73p",
        "ends_at": null,
        "id": 12353868,
        "minimum_order_amount": "0.00",
        "starts_at": "2013-03-06T00:00:00-08:00",
        "status": "enabled",
        "usage_limit": 1,
        "value": "89.99",
        "discount_type": "fixed_amount",
        "applies_to_resource": null,
        "times_used": 1
    }, ...]
}

Wait, so, that looks pretty friendly right? They’ve already done the work, so why we use it? … We can! So, here’s how.

First, let’s take at the full HTTP request. (Snipped to the interesting parts)

GET /admin/discounts.json?limit=50&order=id+DESC&direction=next HTTP/1.1
Host: myshop.myshopify.com
X-CSRF-Token: +QjKt70XBMis/iZXz8VsvbfHkOcH+h45N38os4O1lJo=
X-Requested-With: XMLHttpRequest
X-Shopify-Api-Features: pagination-headers
Cookie: _secure_session_id=150d716ebc55cf62xxx; storefront_digest=056eb6c39dd92c5171360c97d0xxxx;

Nothing particularly special, there’s a token we need to watch out for and, of course, our session cookies. So first thing’s first, let’s tackle the login form. I’ve trimmed this down to the bare necessities for your viewing pleasure

<form accept-charset="UTF-8" action="/admin/auth/login" method="post">
  <input name="utf8" type="hidden" value="&#x2713;" />
  <input name="authenticity_token" type="hidden" value="+QjKt70XBMis/iZXz8VsvbfHkOcH+h45N38os4O1lJo=" />
  <input type="hidden" name="redirect" value="" id="redirect" />
  <input type="email" name="login" size="30" id="login-input" class="email" />
  <input type="password" name="password" size="16" id="password" />

  <div id="open-id" style="display:none">
    <div class="ppb clearfix">
      <label id="open_id" for="openid-input" class="open-id">OpenID</label>  
      <input type="text" name="openid_url" value="" class="url" id="openid-input" />
    </div>
  </div>
</form>
Spoiler: Looks like Shopify are at least playing with OpenID integration

In the interest of minimizing maintainance let’s parse all of those <input>'s dynamically. I chose to use regex over a DOM parser because it seemed more appropriate in such a hacky project, and will save us having to worry about broken markup. We’ll do this in two parts, first we’ll grab the login form, and then the key/value pairs embedded within it.

private function getFields($data = false) {
    $data = $data ?: $this->initGetData($this->store);

    if (preg_match('/(<form.*?.*?<\/form>)/is', $data, $matches))
        $this->inputs = $this->getInputs($matches[1]);
        
    return is_array($this->inputs) ? $this->inputs : false;
}

Then the fields

private function getInputs($form, $inputs = []) {
    if (!($els = preg_match_all('/(<input[^>]+>)/is', $form, $matches)))
        return false;
        
    for ($i = 0; $i < $els; $i++) {
        $el = preg_replace('/\s{2,}/', ' ', $matches[1][$i]);
        
        if (preg_match('/name=(?:["\'])?([^"\'\s]*)/i', $el, $name) 
         && preg_match('/value=(?:["\'])?([^"\'\s]*)/i', $el, $value))
            $inputs[$name[1]] = $value[1];
    
    }
    
    return $inputs;
}
               

Once we have the data necessary, posting it to Shopify is a piece of cake. Cake’s good, right?

public function login() {
    $fields = $this->inputs ?: $this->getFields();

    $fields['login']  = $this->username;
    $fields['password'] = $this->password;

    $url = $this->store . self::_LOGIN_URL;

    $this->ch = curl_init($url);

    $this->setOpts([
        CURLOPT_POST       => true,
        CURLOPT_POSTFIELDS => http_build_query($fields),
        CURLOPT_HTTPHEADER => ['Shopify-Auth-Mechanisms:password']
    ]);    

    $data = curl_exec($this->ch);
    $http_code = curl_getinfo($this->ch, CURLINFO_HTTP_CODE);

    return $http_code == 200 && $this->setToken($data);
}

The astute among you may notice the setToken call at the end. We’ll get to this shortly. Also, setOptions is a function I crafted to keep the code clean, it will take care of setting the cookie jar and user-agent upon each request. Yes, that’s right – Cake, and cookies.

private function setOpts($extra = []) {    
    $default = [
        CURLOPT_USERAGENT => self::_USER_AGENT,
        CURLOPT_COOKIEJAR => self::_COOKIE_STORE,
        CURLOPT_COOKIEFILE => self::_COOKIE_STORE,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true
    ];
    
    $options = $default + array_filter($extra, function($v) {
        return !is_null($v);
    });
    
    curl_setopt_array($this->ch, $options);
 }

So, back to it.. Now we’re logged in - that’s great! Let’s see if we can request from the discounts.json file we saw used earlier.

$params = [
    'limit' => 50, 
    'order' => 'id+DESC', 
    'direction' => 'next'
];
    
$url = $this->store . $function . urldecode(http_build_query($parameters));
$ch = curl_init($url);    

$response = curl_exec($ch);
$data = json_decode($response);

Response

(
    stdClass Object
    (
        [applies_once] => 
        [applies_to_id] => 
        [code] => wyrrw4
        [ends_at] => 
        [id] => 14256508
        [minimum_order_amount] => 0.00
        [starts_at] => 2013-03-01T00:00:00-08:00
        [status] => enabled
        [usage_limit] => 1
        [value] => 5.0
        [discount_type] => percentage
        [applies_to_resource] => 
        [times_used] => 0
    )

    ... 
)

Awesome! It worked. POSTing turns out to be a little trickier, but let’s get to it..

A Cross-site Request Forgery (CSRF) token is used for all POST requests internally as shown below

X-CSRF-Token: +QjKt70XBMis/iZXz8VsvbfHkOcH+h45N38os4O1lJo=

A little poking around reveals this token in the document body

Once again we’ll resort to regex. By co-incidence, or not, the arrangement of these parameters has switched once before, so that’s worth keeping an eye out for!

if (preg_match('//i', $data, $token)) {
    $this->_token = $token[1];
    return true;
}

As noted after the login call we grab the token to avoid excess HTTP requests. Now let’s wrap it into something usable

               
public function doRequest($method, $function, $parameters) {
    $this->ch = curl_init();        
    $url = (!filter_var($function, FILTER_VALIDATE_URL) ? $this->store : '') . $function;
    
    switch ($method) {
        case 'POST':
            $this->setOpts([
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => json_encode($parameters),
                CURLOPT_URL => $url,
                CURLOPT_HTTPHEADER => [
                    'X-Shopify-Api-Features: pagination-headers',
                    'X-CSRF-Token: ' . $this->_token,
                    'X-Requested-With: XMLHttpRequest',
                    'Content-Type: application/json',
                    'Accept: application/json'
                ]
            ]);

            break;
        case 'GET':
        default:
            $this->setOpts([
                CURLOPT_HTTPGET => true,
                CURLOPT_URL => $url . (count($parameters) ? '?' . urldecode(http_build_query($parameters)) : '')
            ]);
    }

Sure enough, that worked too! So what other cool stuff can we do?

Shopify Dashboard

Spoiler: I like graphs

You may have noticed the flashy new dashboard in Shopify 2. Fortunately, with little effort, we can access this data too!

Shopify Reports Query

There’s a couple of things we need to take note of here, the callback (this is JSONP, we’ll get to that in a moment), and the token. The token is used as authentication, and set inline in the document body.

Shopify.set('controllers.dashboard.token',"WyIxOTg5NDg0IiwiMODowMCJd--ebf3dbfffec25186c14a163b8e13bafxxx")

As soon as I saw this it was pretty obvious it was a base64 string and an md5 hash, whilst this probably isn’t terribly useful for us it’s nice to know! Let’s decode it. (Note: I snipped these to keep this store private)

["1989xxx", "2013-03-07T22:34:12-08:00"]

So the base64 is an array containing the store ID and a timestamp. Perhaps the hash is used for performance metrics, or more likely a checksum of the array to avoid people grabbing analytics of other stores. Doesn’t matter much to us, as we aren’t trying to do anything malicious here.

Due to the same origin policy XHR requests to external locations (scheme, hostname and ports must be consistent). The exceptions being JSONP, and CORS. CORS is considered a better solution however in this instance Shopify is using JSONP, that’s what the callback parameter is for. We’ll need to strip out that callback when we parse the response.

To do so, I’ve defined the callback as a static fake_function and strip it out with regular string functions:

if ($reportCenter) {
    if (strpos($response, 'fake_function') !== FALSE) {
        $response = substr($response, strpos($response, '{'));
        $response = substr($response, 0, -2);
    }
}

This allows us to access the report center data such as

stdClass Object
(
    [start_date] => 2013-02-22
    [end_date] => 2013-03-01
    [search_terms] => Array
        (
            [0] => stdClass Object
                (
                    [terms] => shopify.com
                    [count] => 1
                    [percentage] => 100
                )

        )

    [top_referrals] => Array
        (
            [0] => stdClass Object
                (
                    [referrer] => www.example.com
                    [count] => 530
                    [percentage] => 56.025369978858
                )

            ....
        )
)

Remember hackers, the full code & demo is available to fork: