A modern, fully typed PHP SDK for the three Hetzner APIs — Cloud, Robot (dedicated servers) and DNS.
Servers · Volumes · Networks · Load Balancers · Firewalls · Dedicated servers · Rescue · vSwitch · DNS zones
$action = $cloud->servers()->create('web01', 'cx22', 'ubuntu-24.04');
$cloud->actions()->waitFor($action);
$dns->records()->create($zoneId, 'A', 'web01', $serverIp);
$robot->boot()->activateRescue(321);Framework-agnostic core — usable from any PHP project, script, or worker — with an optional bundle for first-class Symfony integration. Token/basic auth per API, typed exceptions, action waiters, automatic pagination, opt-in retries, typed models generated from the official OpenAPI spec, and a comment-free, strictly typed codebase (PHP 8.2+, declare(strict_types=1) everywhere).
- Features
- Requirements
- Installation
- Quick Start (plain PHP)
- Symfony Integration (optional)
- Architecture
- API Reference
- Cloud — Servers
- Cloud — Volumes, Images & Placement Groups
- Cloud — Networks & Firewalls
- Cloud — Floating & Primary IPs
- Cloud — Load Balancers & Certificates
- Cloud — SSH keys, Actions & Catalog
- Robot — Dedicated servers
- Robot — Reset, WOL & Boot
- Robot — IPs, rDNS & vSwitch
- Robot — Firewalls & SSH keys
- DNS — Zones & Records
- Responses
- Error Handling
- Testing
- Security Notes
- WHMCS module
- License
| 🧩 Three APIs, one SDK | Cloud (api.hetzner.cloud/v1, full coverage incl. every server action), Robot webservice (dedicated servers) and DNS (dns.hetzner.com) behind three matching facades |
| ⏳ Action-aware | Every asynchronous Cloud operation returns an action — actions()->waitFor() blocks until completion and fails loudly with the real error when the action fails |
| 📦 Smart responses | items() auto-detects envelope keys, totalCount() reads meta.pagination, paginate() follows next_page through a generator, and typed models hydrate via as()/asList() |
| 🚨 Production-grade errors | Hetzner error codes (not_found, locked, rate_limit_exceeded, resource_limit_exceeded…) mapped to dedicated exceptions, opt-in automatic retries with backoff |
| 🏗 Generated models | 21 Cloud models generated from the official OpenAPI spec (tools/generate-models.py) + hand-written Robot and DNS models — field names match the API exactly |
| 🔓 Nothing sealed off | Each client's get/post/put/delete accept any path, so an endpoint not wrapped by a module is one call away |
| 🛠 Framework-agnostic | Only hard dependency is symfony/http-client; the optional Symfony bundle adds semantic config and autowiring |
| ✅ Fully unit-tested | 27 tests against MockHttpClient — no network required |
| Dependency | Version |
|---|---|
| PHP | >= 8.2 |
| Hetzner Cloud | an API token (Console » Security » API tokens) |
| Hetzner Robot | webservice credentials (Robot » Settings » Web service settings) — optional |
| Hetzner DNS | an API token (DNS Console » API tokens) — optional |
| Symfony | 6.4 LTS or 7.x — optional, only for the bundle integration |
The package is published on Packagist:
composer require chuckbartowski/hetzner-sdkNo framework required — build the clients you need and go:
use ChuckBartowski\HetznerSdk\Client\CloudClient;
use ChuckBartowski\HetznerSdk\Client\DnsClient;
use ChuckBartowski\HetznerSdk\Client\RobotClient;
use ChuckBartowski\HetznerSdk\HetznerCloud;
use ChuckBartowski\HetznerSdk\HetznerDns;
use ChuckBartowski\HetznerSdk\HetznerRobot;
$cloud = new HetznerCloud(new CloudClient(getenv('HCLOUD_TOKEN')));
$creation = $cloud->servers()->create('web01', 'cx22', 'ubuntu-24.04', [
'ssh_keys' => ['my-key'],
'location' => 'fsn1',
'labels' => ['env' => 'prod'],
]);
$cloud->actions()->waitFor($creation);
$cloud->servers()->waitForStatus($creation->data('server')['id'], 'running');
$dns = new HetznerDns(new DnsClient(getenv('HETZNER_DNS_TOKEN')));
$dns->records()->create($zoneId, 'A', 'web01', $creation->data('server')['public_net']['ipv4']['ip']);
$robot = new HetznerRobot(new RobotClient(getenv('ROBOT_USER'), getenv('ROBOT_PASSWORD')));
$robot->reset()->execute(321, 'hw');Client constructor signatures:
new CloudClient(string $token, float $timeout = 30.0, bool $retryFailed = false, int $maxRetries = 3, ?HttpClientInterface $httpClient = null);
new RobotClient(string $username, string $password, float $timeout = 30.0, bool $retryFailed = false, int $maxRetries = 3, ?HttpClientInterface $httpClient = null);
new DnsClient(string $token, float $timeout = 30.0, bool $retryFailed = false, int $maxRetries = 3, ?HttpClientInterface $httpClient = null);Register the bundle:
// config/bundles.php
return [
ChuckBartowski\HetznerSdk\HetznerSdkBundle::class => ['all' => true],
];Then create config/packages/hetzner_sdk.yaml — configure only the APIs you use:
hetzner_sdk:
cloud:
token: '%env(HCLOUD_TOKEN)%'
robot:
username: '%env(ROBOT_USER)%'
password: '%env(ROBOT_PASSWORD)%'
dns:
token: '%env(HETZNER_DNS_TOKEN)%'
retry_failed: true| Key | Type | Default | Description |
|---|---|---|---|
cloud.token |
string | '' |
Cloud API token, sent as Authorization: Bearer |
robot.username / robot.password |
string | '' |
Robot webservice credentials (HTTP basic auth) |
dns.token |
string | '' |
DNS API token, sent as Auth-API-Token |
timeout |
float | 30.0 |
Per-request timeout in seconds |
retry_failed |
bool | false |
Retry transient failures (429, 5xx) with exponential backoff |
max_retries |
int | 3 |
Maximum retry attempts when retry_failed is enabled |
The HetznerCloud, HetznerRobot and HetznerDns facades are then autowirable. A facade whose credentials are missing throws an AuthenticationException on first use, before any network request.
src/
├── HetznerSdkBundle.php Symfony bundle: config tree + service wiring (optional)
├── HetznerCloud.php HetznerRobot.php HetznerDns.php
├── Client/
│ ├── CloudClient.php Bearer auth, pagination via meta.next_page, retries
│ ├── RobotClient.php Basic auth, form-encoded bodies
│ └── DnsClient.php Auth-API-Token, zone-file import/export support
├── Response/
│ └── ApiResponse.php Normalized response (+ action()/nextPage() helpers)
├── Exception/
│ ├── HetznerSdkExceptionInterface.php ApiException.php
│ ├── ResourceNotFoundException.php ConflictException.php
│ ├── RateLimitException.php ResourceLimitException.php
│ ├── AuthenticationException.php TransportException.php
├── Model/
│ ├── Cloud/ 21 models generated from the official OpenAPI spec
│ ├── Robot/ DedicatedServer
│ └── Dns/ Zone, Record
└── Api/
├── Cloud/ Server, Volume, Image, Network, Firewall, FloatingIp,
│ PrimaryIp, LoadBalancer, Certificate, PlacementGroup,
│ SshKey, Action, Catalog
├── Robot/ DedicatedServer, Reset, Boot, Rdns, Ip, Vswitch,
│ RobotFirewall, RobotKey
└── Dns/ Zone, Record
Every method returns an ApiResponse and throws on failure (see Error Handling).
$cloud->servers() — full lifecycle plus every documented server action:
| Group | Methods |
|---|---|
| CRUD | list(query), find(id), create(name, serverType, image, options), update(id, fields), remove(id) |
| Power | powerOn, powerOff, reboot, reset, shutdown |
| Provisioning | rebuild(id, image), changeType(id, type, upgradeDisk), resetPassword, enableRescue(id, type, sshKeys) / disableRescue, createImage(id, options) |
| Backups & ISO | enableBackup / disableBackup, attachIso(id, iso) / detachIso |
| Network | attachToNetwork(id, networkId), detachFromNetwork, changeAliasIps, changeDnsPtr(id, ip, ptr) |
| Misc | changeProtection(id, delete, rebuild), requestConsole, addToPlacementGroup / removeFromPlacementGroup, metrics(id, type, start, end), actions(id) |
| Waiter | waitForStatus(id, 'running') — polls until the server reaches the given status |
All mutating calls return an action — chain with $cloud->actions()->waitFor($response).
$cloud->volumes() — list, find, create(name, sizeGb) (attach at creation with ['server' => $id]), update, remove, attach(id, serverId, automount), detach, resize(id, sizeGb) (grow only), changeProtection.
$cloud->images() — list(query) (filter type=snapshot|backup|system), find, update (convert backup → snapshot), remove, changeProtection.
$cloud->placementGroups() — list, find, create(name, type) (spread), update, remove.
$cloud->networks() — list, find, create(name, ipRange), update, remove, addSubnet(id, type, networkZone, ipRange), deleteSubnet, addRoute / deleteRoute, changeIpRange, changeProtection.
$cloud->firewalls() — list, find, create(name, rules), update, remove, setRules(id, rules) (replaces all rules), applyToResources(id, resources), removeFromResources.
$cloud->floatingIps() — list, find, create(type, options) (ipv4/ipv6), update, remove, assign(id, serverId), unassign, changeDnsPtr, changeProtection.
$cloud->primaryIps() — same surface with assign(id, assigneeId, assigneeType); primary IPs survive server deletion when auto_delete is false.
$cloud->loadBalancers() — list, find, create(name, type, options), update, remove, metrics, services (addService, updateService, deleteService), targets (addTarget, removeTarget — server, label selector or IP), changeAlgorithm, changeType, attachToNetwork / detachFromNetwork, enablePublicInterface / disablePublicInterface, changeDnsPtr, changeProtection.
$cloud->certificates() — list, find, createManaged(name, domainNames) (Let's Encrypt), upload(name, certificate, privateKey), update, remove, retry(id) (failed managed issuance).
$cloud->sshKeys() — list, find, create(name, publicKey, labels), update, remove.
$cloud->actions() — list(query), find(id), and the central waiter: waitFor(actionId | actionArray | ApiResponse, timeout, interval).
$cloud->catalog() — serverTypes(), serverType(id), loadBalancerTypes(), locations(), datacenters(), isos(), pricing().
$robot->servers() — list(), find(serverNumber), rename(serverNumber, name), cancellationStatus, cancel(serverNumber, date, reason), revokeCancellation.
$robot->reset() — list(), options(serverNumber), execute(serverNumber, type) (sw, hw, man), wake(serverNumber) (Wake-on-LAN).
$robot->boot() — status(serverNumber), rescue system (rescueStatus, activateRescue(serverNumber, os, sshKeyFingerprints), deactivateRescue) and Linux installation (linuxStatus, activateLinux(serverNumber, dist, lang, sshKeyFingerprints), deactivateLinux). Activation returns the one-time root password — reboot to apply.
$robot->ips() — list(?serverNumber), find(ip), updateTrafficWarnings, virtual MACs (macAddress, generateMac, deleteMac), subnets(?serverNumber), subnet(netIp).
$robot->rdns() — list(), find(ip), set(ip, ptr), remove(ip).
$robot->vswitches() — list(), find(id), create(name, vlan) (VLAN 4000–4091), update, remove(id, cancellationDate), addServers(id, serverNumbers), removeServers.
$robot->firewalls() — per-server stateful firewall: find(serverNumber), apply(serverNumber, config), remove, plus templates (templates(), template(id), createTemplate(config)).
$robot->keys() — list(), find(fingerprint), create(name, data), rename, remove.
$dns->zones() — list(query), find(id), create(name, ttl), update, remove, zone files (importZoneFile(id, contents), exportZoneFile(id), validateZoneFile(contents)).
$dns->records() — list(zoneId), find(id), create(zoneId, type, name, value, ?ttl), update(...), remove(id), bulkCreate(records), bulkUpdate(records).
$zone = $dns->zones()->create('example.com');
$dns->records()->bulkCreate([
['zone_id' => $zone->data('zone')['id'], 'type' => 'A', 'name' => '@', 'value' => '203.0.113.10'],
['zone_id' => $zone->data('zone')['id'], 'type' => 'A', 'name' => 'www', 'value' => '203.0.113.10'],
]);All calls return an immutable ApiResponse. Here is what actually comes back from GET /v1/servers:
{
"servers": [
{
"id": 42,
"name": "web01",
"status": "running",
"server_type": { "name": "cx22" },
"public_net": { "ipv4": { "ip": "203.0.113.10" } }
}
],
"meta": { "pagination": { "page": 1, "total_entries": 1, "next_page": null } }
}$response = $cloud->servers()->list();
$response->success; // bool
$response->statusCode; // int
$response->data; // the decoded body above
$response->data('servers'); // keyed access with optional default
$response->items(); // the wrapped list, envelope auto-detected (meta ignored)
$response->first(); // first item or null
$response->totalCount(); // meta.pagination.total_entries
$response->action(); // the action array when the call started one, null otherwise
$response->errors; // list<string>, e.g. 'invalid_input: name is invalid'21 Cloud models are generated from Hetzner's official OpenAPI spec (tools/generate-models.py), plus hand-written Robot and DNS models:
use ChuckBartowski\HetznerSdk\Model\Cloud\Server;
use ChuckBartowski\HetznerSdk\Model\Dns\Record;
$server = $cloud->servers()->find(42)->as(Server::class);
$server->name; // ?string
$server->status; // ?string 'running'
$server->publicNet; // ?array ['ipv4' => ['ip' => …]]
$server->raw; // complete original payload, nothing lost
foreach ($dns->records()->list($zoneId)->asList(Record::class) as $record) {
echo "{$record->name} {$record->type} {$record->value}", PHP_EOL;
}Hydration is lossless and lenient: unknown fields stay in ->raw, malformed values degrade to null/[].
foreach ($cloud->client()->paginate('/servers', itemsKey: 'servers') as $server) {
echo $server['name'], PHP_EOL;
}The Cloud paginator follows meta.pagination.next_page; the DNS paginator pages until a short page. Robot endpoints return complete lists.
All SDK exceptions implement HetznerSdkExceptionInterface. HTTP status and Hetzner's error codes drive the mapping:
| Exception | Thrown when | Extras |
|---|---|---|
ApiException |
The API reported a failure (module methods validate automatically) | getErrors(), getStatusCode(), getRaw() |
↳ ResourceNotFoundException |
404 or code not_found |
|
↳ ConflictException |
409 or codes conflict, uniqueness_error, locked |
|
↳ RateLimitException |
429 or code rate_limit_exceeded |
getRetryAfter(): ?int |
↳ ResourceLimitException |
code resource_limit_exceeded (project limits) |
|
AuthenticationException |
Missing credentials or HTTP 401 | thrown before any request when credentials are empty |
TransportException |
Network error, TLS failure, timeout, or invalid JSON | wraps the symfony/http-client exception |
try {
$cloud->actions()->waitFor($cloud->servers()->create('web01', 'cx22', 'ubuntu-24.04'));
} catch (ResourceLimitException) {
$this->askForLimitIncrease();
} catch (RateLimitException $e) {
$this->requeue(delaySeconds: $e->getRetryAfter() ?? 30);
}The suite runs entirely offline against MockHttpClient:
composer install
vendor/bin/phpunit- Secrets are passed with
#[\SensitiveParameter], so they never appear in stack traces. - Cloud tokens are per-project — one read-write token per application, revocable individually. Robot webservice credentials are account-wide: store them in your vault and restrict which service uses them.
remove()on servers, volumes and load balancers is irreversible — enable delete protection (changeProtection) on critical resources and gate destructive calls behind confirmation flows.- Robot
cancel()schedules a real contract cancellation — double-gate it.
A ready-to-use WHMCS provisioning module ships in whmcs/modules/servers/hetznersdk/. It provisions a Hetzner Cloud server through this SDK — create + wait for the action, suspend (power off), unsuspend, terminate, reboot.
Install
composer require chuckbartowski/hetzner-sdkin your WHMCS root.- Copy the
hetznersdkfolder into<whmcs>/modules/servers/. - Add a server with Type: Hetzner Cloud (SDK) and your Cloud API token in the Access Hash field.
- Set the config options: Server type (
cx22…), Image (ubuntu-24.04…), Location (fsn1…).
The server is named whmcs-<serviceid>, so the module finds it again on suspend/terminate. Creation blocks on the returned action via actions()->waitFor().
MIT