diff --git a/ImageOptim.png b/ImageOptim.png
new file mode 100644
index 0000000..3b4555b
Binary files /dev/null and b/ImageOptim.png differ
diff --git a/README.md b/README.md
index 7b8e491..d28f5b8 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ require "vendor/autoload.php"; // created by Composer
 
 $api = new ImageOptim\API("🔶your api username goes here🔶");
 
-$imageData = $api->fromURL('http://example.com/photo.jpg') // read this image
+$imageData = $api->imageFromURL('http://example.com/photo.jpg') // read this image
     ->resize(160, 100, 'crop') // optional: resize to a thumbnail
     ->dpr(2) // optional: double number of pixels for high-resolution "Retina" displays
     ->getBytes(); // perform these operations and return the image data as binary string
@@ -32,6 +32,8 @@ $imageData = $api->fromURL('http://example.com/photo.jpg') // read this image
 file_put_contents("images/photo_optimized.jpg", $imageData);
 ```
 
+There's a longer example at the end of the readme.
+
 ### Methods
 
 #### `API($username)` constructor
@@ -40,9 +42,15 @@ file_put_contents("images/photo_optimized.jpg", $imageData);
 
 Creates new instance of the API. You need to give it [your username](https://im2.io/api/username).
 
-#### `fromURL($url)` — source image
+#### `imageFromPath($filePath)` — local source image
+
+Creates a new request that will [upload](https://im2.io/api/upload) the image to the API, and then resize and optimize it. The upload method is necessary for optimizing files that are not on the web (e.g. `localhost`, files in `/tmp`).
+
+For images that have a public URLs (e.g. published on a website) it's faster to use the URL method instead:
 
-Creates new request that will read image from the given URL, and then resize and optimize it.
+#### `imageFromURL($url)` — remote source image
+
+Creates a new request that will read the image from the given public URL, and then resize and optimize it.
 
 Please pass full absolute URL to images on your website.
 
@@ -88,8 +96,58 @@ All methods throw on error. You can expect the following exception subclasses:
 
 * `ImageOptim\InvalidArgumentException` means arguments to functions are incorrect and you need to fix your code.
 * `ImageOptim\NetworkException` is thrown when there is problem comunicating with the API. You can retry the request.
-* `ImageOptim\NotFoundException` is thrown when URL given to `fromURL()` returns 404. Make sure paths and urlencoding are correct. [More](https://im2.io/api/post#response).
+* `ImageOptim\NotFoundException` is thrown when URL given to `imageFromURL()` returned 404. Make sure paths and urlencoding are correct. [More](https://im2.io/api/post#response).
+* `ImageOptim\OriginServerException` is thrown when URL given to `imageFromURL()` returned 4xx or 5xx error. Make sure your server allows access to the file.
+
+If you're writing a script that processes a large number of images in one go, don't launch it from a web browser, as it will likely time out. It's best to launch such scripts via CLI (e.g. via SSH).
 
 ### Help and info
 
 See [imageoptim.com/api](https://imageoptim.com/api) for documentation and contact info. I'm happy to help!
+
+### Example
+
+This is a script that optimizes an image. Such script usually would be ran when a new image is uploaded to the server. You don't need to run any PHP code to *serve* optimized images.
+
+The API operates on a single image at a time. When you want to generate multiple image sizes/thumbnails, repeat the whole procedure for each image at each size.
+
+```php
+<?php
+
+// This line is required once to set up Composer
+// If this file can't be found, try changing the path
+// and or run `composer update` in your project's directory
+require "vendor/autoload.php";
+
+$api = new ImageOptim\API("🔶your api username goes here🔶");
+
+// imageFromURL/imageFromPath creates a temporary object used to store
+// settings of the optimization.
+$imageParams = $api->imageFromURL('http://example.com/photo.jpg');
+
+// You set various settings on this object (or none to get the defaults).
+$imageParams->quality('low');
+$imageParams->resize(1024);
+
+// Next, to start the optimizations and get the optimized image, call:
+$imageData = $imageParams->getBytes();
+
+/*
+ the getBytes() call may take a while to run, so it's intended to be
+ called only once per image (e.g. only when a new image is uploaded
+ to your server). If you'd like to "lazily" optimize arbitrary images
+ on-the-fly when  they're requested, there is a better API for that:
+ https://im2.io/api/get
+*/
+
+// Save the image data somewhere on the server, e.g.
+file_put_contents("images/photo_optimized.jpg", $imageData);
+
+// Note that this script only prepares a static image file
+// (in this example in images/photo_optimized.jpg),
+// and does not serve it to the browser. Once the optimized
+// image is saved to disk you should serve it normally
+// as you'd do with any regular image file.
+
+```
+
diff --git a/composer.json b/composer.json
index 30e43c3..00ca812 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,7 @@
 {
     "name": "imageoptim/imageoptim",
     "description": "ImageOptim API for PHP",
+    "minimum-stability": "stable",
     "license": "BSD-2-Clause",
     "authors": [
         {
diff --git a/examples/optimize_directory.php b/examples/optimize_directory.php
new file mode 100755
index 0000000..354c1eb
--- /dev/null
+++ b/examples/optimize_directory.php
@@ -0,0 +1,164 @@
+#!/usr/bin/env php
+<?php
+
+// This is a shell script, not a webpage.
+// That's because the script will take a while to run, and it could time out in a browser.
+if (php_sapi_name() != "cli") {
+    @header("Content-Type: text/plain;charset=utf8");
+    echo "This file is meant to be run from command line, and not via a web server. Please run it from a terminal (e.g. connect via SSH):\n";
+    echo "cd ", __DIR__, "\n";
+    die("php ./".basename(__FILE__)."\n");
+}
+
+// If this line fails, run "composer update".
+// If you don't have the composer command, see https://getcomposer.org
+set_include_path(get_include_path() . PATH_SEPARATOR . dirname(__DIR__));
+require_once "vendor/autoload.php";
+
+function usage() {
+    echo "This command takes 4 arguments:\n";
+    echo " - your ImageOptim API username (https://imageoptim.com/api/register),\n";
+    echo " - maximum width of the images (optional),\n";
+    echo " - source directory to read unoptimized images from,\n";
+    echo " - and a destination directory to save converted images to.\n\n";
+    echo "If you omit the size, images will keep their original dimensions.\n";
+    echo "If you specify a size, the size will be added to images' filenames,\n";
+    echo "so you can generate multiple thumbnail sizes in the same directory.\ne.g.:\n";
+    // Note that paths in the example are fictional and you'll need to adjust them for your server
+    echo "php ", $_SERVER['argv'][0], " exampleapikey 640 /www/example.com/originals /www/example.com/optimized\n";
+    echo "The above example will read all images from /www/example.com/originals,\n";
+    echo "resize them to max 640 pixels wide, and save with a '-640.jpg' suffix.\n";
+    echo "/www/example.com/originals/hello.png -> /www/example.com/optimized/hello-640.png\n\n";
+    echo "If you have questions, ask support@imageoptim.com\n";
+}
+
+if (count($_SERVER['argv']) < 4) { // the arg 0 is the command name
+    usage();
+    exit(1);
+}
+
+$argn = 1;
+$apiUsername = $_SERVER['argv'][$argn++];
+if (!$apiUsername || ctype_digit($apiUsername) || file_exists($apiUsername)) {
+    echo "The first argument (". escapeshellarg($apiUsername) . ") must be an ImageOptim API username.\n";
+    echo "Get your username from https://imageoptim.com/api/register\n";
+    exit(1);
+}
+
+$width = null;
+if (count($_SERVER['argv']) > 4 && ctype_digit($_SERVER['argv'][$argn])) {
+    $width = $_SERVER['argv'][$argn++];
+}
+
+$sourceDir = $_SERVER['argv'][$argn++];
+if (!is_dir($sourceDir)) {
+    echo "ERROR: ", $sourceDir, " does not exist or is not a directory.\n\n";
+    usage();
+    exit(1);
+}
+
+$destDir = $_SERVER['argv'][$argn++];
+if (!is_dir($destDir))  {
+    if (is_dir(dirname($destDir))) {
+        if (!mkdir($destDir)) {
+            echo "ERROR: can't create ", $destDir, ". Please create this directory first.\n";
+            exit(1);
+        }
+    } else {
+        echo "ERROR: ", $destDir, " does not exist or is not a directory.\n\n";
+        usage();
+        exit(1);
+    }
+}
+
+// Clears symlinks from paths, makes them absolute and comparable
+$sourceDir = realpath($sourceDir);
+$destDir = realpath($destDir);
+
+try {
+    $api = new ImageOptim\API($apiUsername);
+
+    // This is a fancy way of getting a list of all files in a directory
+    $items = new RecursiveIteratorIterator(
+        new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS),
+        RecursiveIteratorIterator::SELF_FIRST,
+        RecursiveIteratorIterator::CATCH_GET_CHILD);
+
+    $nonImage = [];
+    $skipped = 0;
+    $done = 0;
+
+    foreach ($items as $item) {
+        if ($item->isDir()) continue;
+        $filename = $item->getFilename();
+        if (!preg_match('/\.(png|jpe?g|gif|svgz?|bmp|tiff?)/i', $filename)) {
+            $nonImage[] = $filename;
+            continue;
+        }
+
+        $sourcePath = $item->getPathname();
+        $destRelPath = substr($sourcePath, strlen($sourceDir));
+        $destPath = $destDir . $destRelPath;
+
+        // Append .min extension if source and destination are the same
+        if ($destPath === $sourcePath && false === strpos($destRelPath, '.min.')) {
+            $destRelPath = preg_replace('/\.[^.]+$/', '.min$0', $destRelPath);
+            $destPath = $destDir . $destRelPath;
+        }
+
+        echo substr($destRelPath,1),"... ";
+
+        if (file_exists($destPath) && filemtime($destPath) > filemtime($sourcePath)) {
+            echo " already exists (skipped)\n";
+            $skipped++;
+            continue;
+        }
+
+        // The process preserves directory structure, so it needs to create dirs
+        $destSubdir = dirname($destPath);
+        if (!is_dir($destSubdir)) {
+            if (!mkdir($destSubdir, 0777, true)) {
+                echo "error: unable to create", $destSubdir,"\n";
+                continue;
+            }
+        }
+
+        $apiRequest = $api->imageFromPath($sourcePath);
+        if ($width) {
+            // You could add more options here
+            $apiRequest->resize($width);
+        }
+        $data = $apiRequest->getBytes();
+        if (!file_put_contents($destPath, $data)) {
+            echo "ERROR: unable to save file $destPath\n";
+            break;
+        }
+
+        $inSize = filesize($sourcePath);
+        $outSize = strlen($data);
+        echo "ok (", ($inSize > $outSize ? "$inSize -> $outSize bytes" : "already optimized"), ")\n";
+        $done++;
+    }
+
+    if (count($nonImage)) {
+        echo "Skipped ", count($nonImage), " non-image file(s) ", implode(', ', array_slice($nonImage, 0, 50)), "\n";
+        $nonImage = [];
+    }
+
+    if ($skipped) {
+        echo "\nSkipped $skipped alredy-existing file(s) in $destDir";
+    }
+    echo "\nImageOptim API processed $done file(s)\n";
+
+} catch(\ImageOptim\AccessDeniedException $e) {
+    echo "ERROR\n\n";
+    echo "Please got to https://imageoptim.com/api/register\n";
+    echo "get your API username, and replace '$apiUsername' with\n";
+    echo "your new registered API username.\n\n";
+    echo $e;
+    exit(1);
+} catch(\Exception $e) {
+    echo "ERROR\n\n";
+    echo $e;
+    exit(1);
+}
diff --git a/src/API.php b/src/API.php
index eb4e7a6..46e51ac 100644
--- a/src/API.php
+++ b/src/API.php
@@ -6,13 +6,17 @@ class API {
     private $username;
 
     function __construct($username) {
-        if (empty($username) || !is_string($username)) {
+        if (empty($username) || !is_string($username) || false !== strpos($username, ' ')) {
             throw new InvalidArgumentException("First argument to ImageOptim\\API must be the username\nGet your username from https://im2.io/register\n");
         }
         $this->username = $username;
     }
 
     function imageFromURL($url) {
-        return new Request($this->username, $url);
+        return new URLRequest($this->username, $url);
+    }
+
+    function imageFromPath($file) {
+        return new FileRequest($this->username, $file);
     }
 }
diff --git a/src/FileRequest.php b/src/FileRequest.php
new file mode 100644
index 0000000..e410084
--- /dev/null
+++ b/src/FileRequest.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace ImageOptim;
+
+class FileRequest extends Request {
+    private $path;
+
+    function __construct($username, $path) {
+        parent::__construct($username);
+        if (!$path) {
+            throw new InvalidArgumentException("Image path is required");
+        }
+        if (!file_exists($path)) {
+            $cwd = getcwd();
+            throw new InvalidArgumentException("The file '$path' could not be found (searched relative to '$cwd')");
+        }
+        $this->path = $path;
+    }
+
+    function apiURL() {
+        return parent::apiURL();
+    }
+
+    function getBytes() {
+        $fileData = @file_get_contents($this->path);
+        if (!$fileData) {
+            throw new APIException("Unable to read {$this->path}");
+        }
+
+        $contentHash = md5($this->path);
+        $boundary = "XXX$contentHash";
+        $nameEscaped = addslashes(basename($this->path));
+
+        $url = $this->apiURL();
+        $content = "--$boundary\r\n" .
+                   "Content-Disposition: form-data; name=\"file\"; filename=\"{$nameEscaped}\"\r\n" .
+                   "Content-Type: application/octet-stream\r\n" .
+                   "Content-Transfer-Encoding: binary\r\n" .
+                   "\r\n$fileData\r\n--$boundary--";
+
+        return $this->getBytesWithOptions([
+            'header' => "Content-Length: " . strlen($content) . "\r\n" .
+                        "Content-MD5: $contentHash\r\n" .
+                        "Content-Type: multipart/form-data, boundary=$boundary\r\n",
+            'content' => $content,
+        ], $this->path);
+    }
+}
diff --git a/src/NotFoundException.php b/src/NotFoundException.php
index 11266c5..0924774 100644
--- a/src/NotFoundException.php
+++ b/src/NotFoundException.php
@@ -2,6 +2,6 @@
 
 namespace ImageOptim;
 
-class NotFoundException extends \RuntimeException {
+class NotFoundException extends OriginServerException {
 
 }
diff --git a/src/OriginServerException.php b/src/OriginServerException.php
new file mode 100644
index 0000000..29bae99
--- /dev/null
+++ b/src/OriginServerException.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace ImageOptim;
+
+class OriginServerException extends \RuntimeException {
+
+}
diff --git a/src/Request.php b/src/Request.php
index d3856ab..3cbb2a9 100644
--- a/src/Request.php
+++ b/src/Request.php
@@ -2,22 +2,15 @@
 
 namespace ImageOptim;
 
-class Request {
+abstract class Request {
     const BASE_URL = 'https://im2.io';
 
-    private $username, $url;
-    private $width, $height, $dpr, $fit, $quality, $timeout;
+    private $username;
+    private $width, $height, $dpr, $fit, $bgcolor, $quality, $timeout;
 
-    function __construct($username, $url) {
+    function __construct($username) {
         if (!$username) throw new InvalidArgumentException();
-        if (!$url) {
-            throw new InvalidArgumentException("Image URL is required");
-        }
-        if (!preg_match('/^https?:\/\//', $url)) {
-            throw new InvalidArgumentException("The API requires absolute image URL (starting with http:// or https://). Got: $url");
-        }
         $this->username = $username;
-        $this->url = $url;
     }
 
     public function resize($width, $height_or_fit = null, $fit = null) {
@@ -44,15 +37,20 @@ public function resize($width, $height_or_fit = null, $fit = null) {
             throw new InvalidArgumentException("Height is out of allowed range: $height");
         }
 
-        $allowedFitOptions = ['fit', 'crop', 'scale-down'];
+        $allowedFitOptions = ['fit', 'crop', 'scale-down', 'pad'];
         if (null !== $fit && !in_array($fit, $allowedFitOptions)) {
             throw new InvalidArgumentException("Fit is not one of ".implode(', ',$allowedFitOptions).". Got: $fit");
         }
 
+        if (!$height && ('pad' === $fit || 'crop' === $fit)) {
+            throw new InvalidArgumentException("Height is required for '$fit' scaling mode\nPlease specify height or use 'fit' scaling mode to allow flexible height");
+        }
+
         $this->width = $width;
         $this->height = $height;
         $this->fit = $fit;
 
+
         return $this;
     }
 
@@ -65,6 +63,17 @@ public function timeout($timeout) {
         return $this;
     }
 
+    public function bgcolor($background_color) {
+        if ('transparent' === $background_color || false === $background_color || null === $background_color) {
+            $this->bgcolor = null;
+        } else if (is_string($background_color) && preg_match('/^#?([0-9a-f]+)$/i', $background_color, $m)) {
+            $this->bgcolor = $m[1];
+        } else {
+            throw new InvalidArgumentException("Background color must be a hex string (e.g. AABBCC). Got: $background_color");
+        }
+        return $this;
+    }
+
     public function dpr($dpr) {
         if (!preg_match('/^\d[.\d]*(x)?$/', $dpr, $m)) {
             throw new InvalidArgumentException("DPR should be 1x, 2x or 3x. Got: $dpr");
@@ -89,7 +98,7 @@ function optimize() {
         return $this;
     }
 
-    function apiURL() {
+    protected function apiURL() {
         $options = [];
         if ($this->width) {
             $size = $this->width;
@@ -104,25 +113,20 @@ function apiURL() {
         if ($this->dpr) $options[] = $this->dpr;
         if ($this->quality) $options[] = 'quality=' . $this->quality;
         if ($this->timeout) $options[] = 'timeout=' . $this->timeout;
+        if ($this->bgcolor) $options[] = 'bgcolor=' . $this->bgcolor;
 
-        $imageURL = $this->url;
-        if (preg_match('/[\s%+]/', $imageURL)) {
-            $imageURL = rawurlencode($imageURL);
-        }
-
-        return self::BASE_URL . '/' . rawurlencode($this->username) . '/' . implode(',', $options) . '/' . $imageURL;
+        return self::BASE_URL . '/' . rawurlencode($this->username) . '/' . implode(',', $options);
     }
 
-    function getBytes() {
+    protected function getBytesWithOptions(array $options, $sourceURL) {
         $url = $this->apiURL();
-        $stream = @fopen($url, 'r', false, stream_context_create([
-            'http' => [
-                'ignore_errors' => true,
-                'method' => 'POST',
-                'header' => "User-Agent: ImageOptim-php/1.0 PHP/" . phpversion(),
-                'timeout' => max(30, $this->timeout),
-            ],
-        ]));
+        $options['timeout'] = max(30, $this->timeout);
+        $options['ignore_errors'] = true;
+        $options['method'] = 'POST';
+        $options['header'] .= "Accept: image/*,application/im2+json\r\n" .
+                              "User-Agent: ImageOptim-php/1.1 PHP/" . phpversion();
+
+        $stream = @fopen($url, 'r', false, stream_context_create(['http'=>$options]));
 
         if (!$stream) {
             $err = error_get_last();
@@ -155,18 +159,35 @@ function getBytes() {
             throw new NetworkException("Unexpected response: ". $meta['wrapper_data'][0]);
         }
 
-        $code = intval($status[1]);
-        if ($code >= 500) {
-            throw new APIException($status[2], $code);
+        $status = intval($status[1]);
+        $errorMessage = $status[2];
+
+        if ($res && preg_grep('/content-type:\s*application\/im2\+json/i', $meta['wrapper_data'])) {
+            $json = @json_decode($res);
+            if ($json) {
+                if (isset($json->status)) {
+                    $status = $json->status;
+                }
+                if (isset($json->error)) {
+                    $errorMessage = $json->error;
+                }
+                if (isset($json->code) && $json->code === 'IM2ACCOUNT') {
+                    throw new AccessDeniedException($errorMessage, $status);
+                }
+            }
+        }
+
+        if ($status >= 500) {
+            throw new APIException($errorMessage, $status);
         }
-        if ($code == 404) {
-            throw new NotFoundException("Could not find the image: {$this->url}", $code);
+        if ($status == 404) {
+            throw new NotFoundException("Could not find the image: {$sourceURL}", $status);
         }
-        if ($code == 403) {
-            throw new AccessDeniedException("API username was not accepted: {$this->username}", $code);
+        if ($status == 403) {
+            throw new OriginServerException("Origin server denied access to {$sourceURL}", $status);
         }
-        if ($code >= 400) {
-            throw new InvalidArgumentException($status[2], $code);
+        if ($status >= 400) {
+            throw new InvalidArgumentException($errorMessage, $status);
         }
 
         return $res;
diff --git a/src/URLRequest.php b/src/URLRequest.php
new file mode 100644
index 0000000..2b825ea
--- /dev/null
+++ b/src/URLRequest.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace ImageOptim;
+
+class URLRequest extends Request {
+    private $url;
+
+    function __construct($username, $url) {
+        parent::__construct($username);
+        if (!$url) {
+            throw new InvalidArgumentException("Image URL is required");
+        }
+        if (!preg_match('/^https?:\/\//', $url)) {
+            throw new InvalidArgumentException("The API requires absolute image URL (starting with http:// or https://). Got: $url");
+        }
+        $this->url = $url;
+    }
+
+    function apiURL() {
+        return parent::apiURL() . '/' . rawurlencode($this->url);
+    }
+
+    function getBytes() {
+        return $this->getBytesWithOptions(['header' => ""], $this->url);
+    }
+}
diff --git a/test/BasicTest.php b/test/BasicTest.php
index 2ae2431..34b0960 100644
--- a/test/BasicTest.php
+++ b/test/BasicTest.php
@@ -29,6 +29,14 @@ public function testNeedsURL() {
         $this->api->imageFromURL('local/path.png');
     }
 
+    /**
+     * @expectedException InvalidArgumentException
+     * @expectedExceptionMessage could not be found
+     */
+    public function testNeedsPath() {
+        $this->api->imageFromPath('http://nope/path.png');
+    }
+
     /**
      * @expectedException InvalidArgumentException
      * @expectedExceptionMessage Width
@@ -54,7 +62,7 @@ public function testResizeNegativeHeight() {
     }
 
     public function testResizeWithoutHeight() {
-        $this->api->imageFromURL('http://example.com')->resize(320, "crop");
+        $this->api->imageFromURL('http://example.com')->resize(320, "fit");
     }
 
     public function testResizeWithHeight() {
@@ -69,11 +77,36 @@ public function testResizeInvalidKeyword() {
         $this->api->imageFromURL('http://example.com')->resize(320, 100, "loose");
     }
 
+    /**
+     * @expectedException InvalidArgumentException
+     * @expectedExceptionMessage Height
+     */
+    public function testCropNeedsHeight() {
+        $this->api->imageFromURL('http://example.com')->resize(320, null, "crop");
+    }
+
+    /**
+     * @expectedException InvalidArgumentException
+     * @expectedExceptionMessage Height
+     */
+    public function testPadNeedsHeight() {
+        $this->api->imageFromURL('http://example.com')->resize(320, null, "pad");
+    }
+
     public function testEncodesURLIfNeeded() {
         $example = 'http://example.com/%2F';
         $this->assertContains(rawurlencode($example), $this->api->imageFromURL($example)->apiURL());
     }
 
+    public function testPad() {
+        $apiurl = $this->api->imageFromURL('http://example.com')->resize(10,15,'pad')->bgcolor('#FFffFF')->apiURL();
+
+        $this->assertInternalType('string', $apiurl);
+        $this->assertContains('10x15', $apiurl);
+        $this->assertContains('pad', $apiurl);
+        $this->assertContains('bgcolor=FFffFF', $apiurl);
+    }
+
     public function testChains() {
         $c1 = $this->api->imageFromURL('http://example.com')->resize(1280)->optimize()->timeout(34)
             ->quality('low')->resize(1280)->dpr('2x')->resize(1280, 300);
@@ -87,6 +120,6 @@ public function testChains() {
         $this->assertContains('2x', $c2->apiURL());
         $this->assertContains('1280x300', $c1->apiURL());
         $this->assertContains('timeout=34', $c1->apiURL());
-        $this->assertContains('/http://example.com', $c1->apiURL());
+        $this->assertContains('/http%3A%2F%2Fexample.com', $c1->apiURL());
     }
 }
diff --git a/test/OnlineTest.php b/test/OnlineTest.php
index c70fb3b..a215185 100644
--- a/test/OnlineTest.php
+++ b/test/OnlineTest.php
@@ -13,6 +13,14 @@ public function testFullMonty() {
         $this->assertEquals(100*2, imagesy($gdimg));
     }
 
+    public function testUpload() {
+        $imageData = $this->api->imageFromPath(__dir__ . '/../ImageOptim.png')->resize(32)->getBytes();
+
+        $gdimg = imagecreatefromstring($imageData);
+        $this->assertEquals(32, imagesx($gdimg));
+        $this->assertEquals(32, imagesy($gdimg));
+    }
+
     /**
      * @expectedException ImageOptim\AccessDeniedException
      * @expectedExceptionCode 403
@@ -22,6 +30,14 @@ public function testBadKey() {
         $api->imageFromURL('http://example.com/image.png')->dpr('2x')->getBytes();
     }
 
+    /**
+     * @expectedException ImageOptim\OriginServerException
+     * @expectedExceptionCode 403
+     */
+    public function testGoodKeyUpstream403() {
+        $this->api->imageFromURL('https://im2.io/.htdeny')->dpr('2x')->getBytes();
+    }
+
     /**
      * @expectedException ImageOptim\NotFoundException
      * @expectedExceptionCode 404
