Client examples
Small, dependency-free clients for PHP, PowerShell and the Linux shell. Each one sends the token, signs requests when the client has a signing secret, turns error answers into errors and waits after a 429. Copy them as a starting point for scripts and integrations.
All examples read their settings from environment variables, never from the script itself:
| Variable | Example | Notes |
|---|---|---|
ANVIL_URL |
https://app.example.com |
address of the application, without /api/v1 |
ANVIL_TOKEN |
anv_x7Kq2mP9aB3c_… |
token of the client |
ANVIL_SIGNING_SECRET |
only for clients that require signed requests |
Keep the token and the secret in the secret store of the system that runs the script (e.g. a CI secret, the Windows credential manager, a file readable only by the service user) and do not log them.
The examples use the projects resource of the other chapters; replace
it with the slug of your resource.
Needs PHP 8.1 or newer with ext-curl; no Composer package.
<?php
declare(strict_types=1);
final class AnvilClient{ public function __construct( private readonly string $baseUrl, #[SensitiveParameter] private readonly string $token, #[SensitiveParameter] private readonly ?string $signingSecret = null, private readonly int $maxAttempts = 3, ) { }
public static function fromEnvironment(): self { return new self( (string) getenv('ANVIL_URL'), (string) getenv('ANVIL_TOKEN'), getenv('ANVIL_SIGNING_SECRET') ?: null, ); }
/** * Sends a request and returns the decoded answer ([] for 204). * * @param array<string, mixed> $query e.g. ['filter' => ['status' => 'running'], 'limit' => 50] * @param array<string, mixed>|null $body * * @return array<string, mixed> */ public function request(string $method, string $path, array $query = [], ?array $body = null, ?string $idempotencyKey = null): array { $method = strtoupper($method); $path = '/api/v1/' . ltrim($path, '/'); $payload = $body === null ? '' : json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $target = $path . ($query === [] ? '' : '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986)); $url = rtrim($this->baseUrl, '/') . $target;
for ($attempt = 1; ; $attempt++) { $headers = ['Authorization: Bearer ' . $this->token, 'Accept: application/json'];
if ($payload !== '') { $headers[] = 'Content-Type: application/json'; }
if ($idempotencyKey !== null) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; }
if ($this->signingSecret !== null) { // Signed again for every attempt: a signature is accepted once. $time = time(); $headers[] = 'X-Anvil-Signature: t=' . $time . ', v1=' . hash_hmac('sha256', $time . '.' . $method . '.' . $target . '.' . $payload, $this->signingSecret); }
$answerHeaders = []; $curl = curl_init($url); curl_setopt_array($curl, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $payload === '' ? null : $payload, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 60, CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$answerHeaders): int { if (str_contains($line, ':')) { [$name, $value] = explode(':', $line, 2); $answerHeaders[strtolower(trim($name))] = trim($value); }
return strlen($line); }, ]); $answer = curl_exec($curl); $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $error = curl_error($curl);
if ($answer === false) { throw new RuntimeException('Anvil API not reachable: ' . $error); }
if ($status === 429 && $attempt < $this->maxAttempts) { sleep(max(1, (int) ($answerHeaders['retry-after'] ?? 1)));
continue; }
$data = $answer === '' ? [] : json_decode($answer, true, 512, JSON_THROW_ON_ERROR);
if ($status >= 400) { throw new AnvilApiException($status, $data, $answerHeaders['x-request-id'] ?? null); }
return $data; } }
/** * All records of a list, page by page. * * @param array<string, mixed> $query * * @return Generator<int, array<string, mixed>> */ public function all(string $path, array $query = []): Generator { do { $page = $this->request('GET', $path, $query);
yield from $page['data'];
$query['cursor'] = $page['next']; } while ($page['next'] !== null); }}
final class AnvilApiException extends RuntimeException{ /** * @param array<string, mixed> $problem RFC 9457 problem document */ public function __construct(public readonly int $status, public readonly array $problem, public readonly ?string $requestId) { parent::__construct(sprintf('%d %s (request %s)', $status, $problem['title'] ?? 'Error', $requestId ?? '-')); }
/** * @return array<string, string> field messages of a 422 */ public function errors(): array { return $this->problem['errors'] ?? []; }}Using it:
$anvil = AnvilClient::fromEnvironment();
// all running projects, 100 per requestforeach ($anvil->all('projects', ['filter' => ['status' => 'running'], 'limit' => 100]) as $project) { echo $project['uuid'], ' ', $project['name'], PHP_EOL;}
// create: the same key makes a repeated request (e.g. after a timeout) harmlesstry { $created = $anvil->request('POST', 'projects', body: ['name' => 'Alpha', 'budget' => '1250.50'], idempotencyKey: bin2hex(random_bytes(16))); $uuid = $created['data']['uuid'];} catch (AnvilApiException $e) { if ($e->status === 422) { print_r($e->errors()); }
throw $e;}
// change, run an action, delete$anvil->request('PATCH', "projects/{$uuid}", body: ['status' => 'paused']);$anvil->request('POST', "projects/{$uuid}/actions/mark-done");$anvil->request('DELETE', "projects/{$uuid}");PowerShell
Section titled “PowerShell”Needs PowerShell 7.1 or newer (Windows, Linux, macOS); Windows PowerShell
5.1 lacks -SkipHttpErrorCheck.
function Invoke-Anvil { [CmdletBinding()] param( [Parameter(Mandatory)][ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')][string] $Method, [Parameter(Mandatory)][string] $Path, [hashtable] $Query = @{}, $Body = $null, [string] $IdempotencyKey, [int] $MaxAttempts = 3 )
$apiPath = '/api/v1/' + $Path.TrimStart('/') $payload = if ($null -eq $Body) { '' } else { $Body | ConvertTo-Json -Depth 10 -Compress } $pairs = foreach ($entry in $Query.GetEnumerator()) { [uri]::EscapeDataString($entry.Key) + '=' + [uri]::EscapeDataString([string] $entry.Value) } $target = $apiPath + $(if ($pairs) { '?' + ($pairs -join '&') } else { '' }) $uri = $env:ANVIL_URL.TrimEnd('/') + $target
for ($attempt = 1; ; $attempt++) { $headers = @{ Authorization = "Bearer $env:ANVIL_TOKEN"; Accept = 'application/json' }
if ($IdempotencyKey) { $headers['Idempotency-Key'] = $IdempotencyKey }
if ($env:ANVIL_SIGNING_SECRET) { # Signed again for every attempt: a signature is accepted once. $time = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $hmac = [System.Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($env:ANVIL_SIGNING_SECRET)) $hash = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes("$time.$Method.$target.$payload")) $headers['X-Anvil-Signature'] = "t=$time, v1=" + [Convert]::ToHexString($hash).ToLowerInvariant() }
$request = @{ Method = $Method; Uri = $uri; Headers = $headers; SkipHttpErrorCheck = $true; TimeoutSec = 60 }
if ($payload) { $request.Body = [Text.Encoding]::UTF8.GetBytes($payload) $request.ContentType = 'application/json' }
$response = Invoke-WebRequest @request $status = [int] $response.StatusCode
if ($status -eq 429 -and $attempt -lt $MaxAttempts) { Start-Sleep -Seconds ([math]::Max(1, [int] ($response.Headers['Retry-After'] | Select-Object -First 1))) continue }
$text = if ($response.Content -is [byte[]]) { [Text.Encoding]::UTF8.GetString($response.Content) } else { [string] $response.Content } $data = if ($text) { $text | ConvertFrom-Json } else { $null }
if ($status -ge 400) { $requestId = $response.Headers['X-Request-Id'] | Select-Object -First 1 $message = "$status $($data.title) (request $requestId)" if ($data.errors) { $message += "`n" + ($data.errors | ConvertTo-Json -Compress) } throw $message }
return $data }}Using it:
# all running projects, 100 per request$query = @{ 'filter[status]' = 'running'; limit = 100 }do { $page = Invoke-Anvil -Method GET -Path 'projects' -Query $query $page.data | ForEach-Object { "$($_.uuid) $($_.name)" } $query.cursor = $page.next} while ($page.next)
# create, change, delete$created = Invoke-Anvil -Method POST -Path 'projects' -Body @{ name = 'Alpha'; budget = '1250.50' } -IdempotencyKey ([guid]::NewGuid())$uuid = $created.data.uuidInvoke-Anvil -Method PATCH -Path "projects/$uuid" -Body @{ status = 'paused' } | Out-NullInvoke-Anvil -Method DELETE -Path "projects/$uuid" | Out-NullIn a scheduled task, read the token from the credential manager or a
secret vault (Microsoft.PowerShell.SecretManagement) into
$env:ANVIL_TOKEN at the start of the script.
Linux shell
Section titled “Linux shell”Needs bash, curl, jq and, for signed requests, openssl. The
function prints the answer as JSON and returns a non-zero exit code on
errors, so it works with set -e and jq. Pass the query string encoded
(e.g. with jq -rn --arg v "$value" '$v | @uri'); it is sent and signed
exactly as given.
#!/usr/bin/env bashset -euo pipefail
# anvil METHOD PATH [QUERY] [BODY] [IDEMPOTENCY_KEY]anvil() { local method=$1 path="/api/v1/${2#/}" query=${3:-} body=${4:-} key=${5:-} local target="${path}${query:+?$query}" local url="${ANVIL_URL%/}${target}" local answer headers status attempt time signature answer=$(mktemp) headers=$(mktemp) trap 'rm -f "$answer" "$headers"' RETURN
for attempt in 1 2 3; do local args=(--silent --show-error --globoff --request "$method" --output "$answer" --dump-header "$headers" --write-out '%{http_code}' --connect-timeout 10 --max-time 60 # The token is read from a file descriptor, so it is not visible in the process list. --header @<(printf 'Authorization: Bearer %s\n' "$ANVIL_TOKEN") --header 'Accept: application/json')
[[ -n $body ]] && args+=(--header 'Content-Type: application/json' --data-binary "$body") [[ -n $key ]] && args+=(--header "Idempotency-Key: $key")
if [[ -n ${ANVIL_SIGNING_SECRET:-} ]]; then # Signed again for every attempt: a signature is accepted once. time=$(date +%s) signature=$(printf '%s' "$time.$method.$target.$body" \ | openssl dgst -sha256 -mac HMAC -macopt "key:$ANVIL_SIGNING_SECRET" -r | cut -d' ' -f1) args+=(--header "X-Anvil-Signature: t=$time, v1=$signature") fi
status=$(curl "${args[@]}" "$url")
if [[ $status == 429 && $attempt -lt 3 ]]; then local wait wait=$(tr -d '\r' <"$headers" | awk 'tolower($1) == "retry-after:" { print $2 + 0; exit }') sleep "${wait:-1}" continue fi
if (( status >= 400 )); then printf '%s %s\n' "$status" "$(jq -c '{title, requestId, errors}' "$answer" 2>/dev/null || cat "$answer")" >&2 return 1 fi
cat "$answer" return 0 done}Using it:
# all running projects, 100 per requestcursor=''while :; do page=$(anvil GET projects "filter[status]=running&limit=100${cursor:+&cursor=$(jq -rn --arg c "$cursor" '$c | @uri')}") jq -r '.data[] | "\(.uuid) \(.name)"' <<<"$page" cursor=$(jq -r '.next // empty' <<<"$page") [[ -n $cursor ]] || breakdone
# create: build the body with jq, so values are always valid JSONbody=$(jq -cn --arg name 'Alpha' --arg budget '1250.50' '{name: $name, budget: $budget}')uuid=$(anvil POST projects '' "$body" "$(openssl rand -hex 16)" | jq -r '.data.uuid')
anvil PATCH "projects/$uuid" '' '{"status":"paused"}' >/dev/nullanvil DELETE "projects/$uuid" >/dev/nullGood practice
Section titled “Good practice”- One client per system with the narrowest permissions, and a token with an end date. Rotate tokens with a second token before the first one expires, see Tokens.
- Never turn off certificate checks (
CURLOPT_SSL_VERIFYPEER,curl -k,-SkipCertificateCheck). Install the CA of an internal certificate on the calling system instead. - Use an
Idempotency-Keyfor every create and action of a script that may be repeated, e.g. after a timeout or by a scheduler. - Log the
X-Request-Idof failed requests: it finds the call in the access log. - Answers are JSON with
data(andnextfor lists); errors are problem documents, see Calling the API.