Skip to content

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 request
foreach ($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) harmless
try {
$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}");

Needs PowerShell 7.1 or newer (Windows, Linux, macOS); Windows PowerShell 5.1 lacks -SkipHttpErrorCheck.

Terminal window
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:

Terminal window
# 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.uuid
Invoke-Anvil -Method PATCH -Path "projects/$uuid" -Body @{ status = 'paused' } | Out-Null
Invoke-Anvil -Method DELETE -Path "projects/$uuid" | Out-Null

In 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.

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 bash
set -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:

Terminal window
# all running projects, 100 per request
cursor=''
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 ]] || break
done
# create: build the body with jq, so values are always valid JSON
body=$(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/null
anvil DELETE "projects/$uuid" >/dev/null
  • 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-Key for every create and action of a script that may be repeated, e.g. after a timeout or by a scheduler.
  • Log the X-Request-Id of failed requests: it finds the call in the access log.
  • Answers are JSON with data (and next for lists); errors are problem documents, see Calling the API.