Import files from Backblaze in PHP with the Native API
To import one private Backblaze B2 object in PHP, authorize with the Native API and stream its download into a temporary file. PHP’s open-source cURL extension handles the HTTP requests without an SDK dependency. The command below replaces your chosen destination only after the transfer and disk writes succeed.
Requirements
- A Linux command line with PHP CLI 8.5.10 or a later 8.5 patch and
ext-curl. This example was tested with PHP 8.5.10. Use the latest patch in that maintained branch. - An existing private B2 bucket and the exact name of an object you can read without SSE-C headers.
- An application key with
readFilesaccess to that bucket and object prefix. You need its key ID and application key, which are separate values. - An existing, writable destination directory on a local filesystem. Run one importer at a time for a given destination.
Check the installed CLI before saving the program:
php -r 'exit(PHP_MAJOR_VERSION === 8 && PHP_MINOR_VERSION === 5 && version_compare(PHP_VERSION, "8.5.10", ">=") && extension_loaded("curl") ? 0 : 1);'
A zero exit means this CLI meets the prerequisites. If it fails, install or enable cURL for your PHP CLI, or select the supported PHP version, then repeat the check. No Composer install is needed.
Use the B2 Native API
The v4 authorization response puts
downloadUrl inside apiInfo.storageApi; the authorization token remains at the top level.
The key ID and application key authenticate that first request using HTTP Basic Auth. The download
then uses the returned token in its Authorization header.
Save the following complete program as import-b2.php. It accepts HTTPS endpoints and loopback
HTTP endpoints for local testing, and refuses redirects on both requests.
Import one object
<?php
declare(strict_types=1);
final class CurlTransport
{
private const MAX_AUTH_BYTES = 1_048_576;
public function __construct(private readonly int $timeoutSeconds = 60) {}
/** @return array<string, mixed> */
public function authorize(string $url, string $keyId, string $applicationKey): array
{
$this->assertSafeUrl($url);
$body = '';
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Could not initialize cURL');
}
try {
if (!curl_setopt_array($curl, [
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_USERPWD => $keyId . ':' . $applicationKey,
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$body): int {
if (strlen($body) + strlen($chunk) > self::MAX_AUTH_BYTES) {
return 0;
}
$body .= $chunk;
return strlen($chunk);
},
])) {
throw new RuntimeException('Could not configure cURL');
}
$ok = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($ok !== true || $status !== 200) {
throw new RuntimeException("B2 authorization failed with HTTP {$status}");
}
} finally {
unset($curl);
}
$decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new RuntimeException('B2 returned an invalid authorization response');
}
return $decoded;
}
/** @param resource $output */
public function download(string $url, string $token, $output, int $maxBytes): void
{
$this->assertSafeUrl($url);
$written = 0;
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Could not initialize cURL');
}
try {
if (!curl_setopt_array($curl, [
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPHEADER => ['Authorization: ' . $token],
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (
$output,
$maxBytes,
&$written,
): int {
$length = strlen($chunk);
if ($written + $length > $maxBytes) {
return 0;
}
$result = fwrite($output, $chunk);
if ($result !== $length) {
return 0;
}
$written += $result;
return $result;
},
])) {
throw new RuntimeException('Could not configure cURL');
}
$ok = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($ok !== true || $status !== 200) {
throw new RuntimeException("B2 download failed with HTTP {$status}");
}
} finally {
unset($curl);
}
}
private function assertSafeUrl(string $url): void
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = parse_url($url, PHP_URL_HOST);
$loopback = $scheme === 'http' && in_array($host, ['127.0.0.1', '::1'], true);
if ($scheme !== 'https' && !$loopback) {
throw new RuntimeException('B2 endpoints must use HTTPS');
}
}
}
/** @param array<string, mixed> $authorization */
function requiredString(array $authorization, string $key): string
{
$value = $authorization[$key] ?? null;
if (!is_string($value) || $value === '') {
throw new RuntimeException("B2 authorization response is missing {$key}");
}
return $value;
}
function encodeObjectName(string $objectName): string
{
$segments = explode('/', $objectName);
if (in_array('.', $segments, true) || in_array('..', $segments, true)) {
throw new InvalidArgumentException('Object names cannot contain dot path segments');
}
return implode('/', array_map('rawurlencode', $segments));
}
function importB2Object(
CurlTransport $transport,
string $authorizeUrl,
string $keyId,
string $applicationKey,
string $bucket,
string $objectName,
string $destination,
int $maxBytes,
): void {
if ($maxBytes < 1 || $bucket === '' || $objectName === '') {
throw new InvalidArgumentException('Bucket, object name, and a positive limit are required');
}
$authorization = $transport->authorize($authorizeUrl, $keyId, $applicationKey);
$apiInfo = $authorization['apiInfo'] ?? null;
$storageApi = is_array($apiInfo) ? ($apiInfo['storageApi'] ?? null) : null;
if (!is_array($storageApi)) {
throw new RuntimeException('B2 authorization response is missing storageApi');
}
$downloadUrl = requiredString($storageApi, 'downloadUrl');
$token = requiredString($authorization, 'authorizationToken');
$url = rtrim($downloadUrl, '/') . '/file/' . rawurlencode($bucket) . '/'
. encodeObjectName($objectName);
$directory = dirname($destination);
$realDirectory = realpath($directory);
if ($realDirectory === false || !is_dir($realDirectory) || !is_writable($realDirectory)) {
throw new RuntimeException('Destination directory must exist and be writable');
}
$temporary = tempnam($realDirectory, '.b2-');
if ($temporary === false) {
throw new RuntimeException('Could not create a temporary destination');
}
if (dirname($temporary) !== $realDirectory) {
@unlink($temporary);
throw new RuntimeException('Temporary file was not created beside the destination');
}
$output = fopen($temporary, 'wb');
if ($output === false) {
@unlink($temporary);
throw new RuntimeException('Could not open the temporary destination');
}
try {
$transport->download($url, $token, $output, $maxBytes);
$flushed = fflush($output);
$closed = fclose($output);
$output = null;
if (!$flushed || !$closed) {
throw new RuntimeException('Could not finish writing the downloaded object');
}
if (!rename($temporary, $destination)) {
throw new RuntimeException('Could not publish the downloaded object');
}
} finally {
if (is_resource($output)) {
fclose($output);
}
if (file_exists($temporary)) {
unlink($temporary);
}
}
}
function main(array $arguments): void
{
if (count($arguments) !== 2) {
throw new InvalidArgumentException('Usage: php import-b2.php DESTINATION');
}
foreach (['B2_KEY_ID', 'B2_APPLICATION_KEY', 'B2_BUCKET', 'B2_OBJECT'] as $name) {
if (getenv($name) === false || getenv($name) === '') {
throw new RuntimeException("Missing environment variable: {$name}");
}
}
$configuredMaxBytes = getenv('B2_MAX_BYTES');
$maxBytes = filter_var(
$configuredMaxBytes === false ? 100 * 1024 * 1024 : $configuredMaxBytes,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]],
);
if ($maxBytes === false) {
throw new RuntimeException('B2_MAX_BYTES must be a positive integer');
}
$configuredTimeout = getenv('B2_TIMEOUT_SECONDS');
$timeoutSeconds = filter_var(
$configuredTimeout === false ? 60 : $configuredTimeout,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1, 'max_range' => 3600]],
);
if ($timeoutSeconds === false) {
throw new RuntimeException('B2_TIMEOUT_SECONDS must be an integer from 1 to 3600');
}
importB2Object(
new CurlTransport($timeoutSeconds),
getenv('B2_AUTHORIZE_URL') ?: 'https://api.backblazeb2.com/b2api/v4/b2_authorize_account',
(string) getenv('B2_KEY_ID'),
(string) getenv('B2_APPLICATION_KEY'),
(string) getenv('B2_BUCKET'),
(string) getenv('B2_OBJECT'),
$arguments[1],
$maxBytes,
);
}
try {
main($argv);
} catch (Throwable) {
error_log('B2 import failed');
exit(1);
}
From that directory, substitute your credentials, bucket, and object name in this command. In an
application, supply credentials through your secret manager rather than saving them in source or
shell history. Use ./ before a relative destination, including names that begin with -.
B2_KEY_ID='your-key-id' \
B2_APPLICATION_KEY='your-application-key' \
B2_BUCKET='my-bucket' \
B2_OBJECT='exports/report.bin' \
php import-b2.php ./report.bin
Success exits zero without printing anything; ./report.bin contains the downloaded bytes. A
successful rerun replaces that file. The code stages bytes in a unique .b2- file beside the
destination, closes it, then uses rename()
to publish it. On a local Linux filesystem, replacement is atomic,
so readers see the old file or the completed replacement. This does not promise crash durability
or coordinate concurrent importers.
The default limit is 100 MiB, inclusive. Set B2_MAX_BYTES to another positive integer in bytes
when needed. Authorization responses have a separate 1 MiB bound. Each request has a
60-second whole-transfer deadline, including
connection setup; B2_TIMEOUT_SECONDS accepts integers from 1 to 3,600. Authorization and download
use separate deadlines, so a small file can still fail on a slow connection. Neither request retries.
A failure exits one and logs a generic error without response bodies or credentials. Authentication
failure, a denied or missing object, an oversized or interrupted HTTP transfer, and a failed disk
write leave the previous destination intact and remove this invocation’s temporary file. With no
previous destination, failure leaves none. A forced process kill or machine crash can leave a
.b2- file; remove it only after confirming that its importer has stopped.
Adapt the importer
Set B2_OBJECT to the literal UTF-8 object name, without URL-encoding it first. Following
B2’s encoding rules,
the code encodes each path segment, preserving / separators and literal spaces, +, %, #, and ? characters.
It rejects . and .. path segments. Downloading by name selects the
current visible version;
this command does not list a bucket or select a historical file ID.
Secure and efficient file handling
Keep the key scoped to the bucket and prefix you need. Leave TLS verification enabled and use the
default authorization endpoint for B2; B2_AUTHORIZE_URL exists to exercise a controlled local
test service, not to accept an endpoint from an untrusted caller.
The importer checks HTTP completion and the byte limit. It does not compare a publisher-supplied checksum or decrypt files uploaded with customer-managed encryption keys. If your workflow needs content authentication or SSE-C, add that verification or key handling before publishing the file.
For server-side processing after an import, see the Backblaze import Robot.
