<?xml version="1.0" encoding="UTF-8"?>
<article version="5.0" xmlns="http://docbook.org/ns/docbook"
         xmlns:xlink="http://www.w3.org/1999/xlink">
  <info>
    <title>Cloudflare::API: Using Cloudflare from Perl</title>
    <author>
      <personname><firstname>Andrew</firstname><surname>Speer</surname></personname>
    </author>
    <copyright><year>2026</year><holder>Andrew Speer</holder></copyright>
  </info>

  <section xml:id="introduction">
    <title>Introduction</title>
    <para>Want to list your R2 buckets, inspect a Worker, or create a D1 database from a Perl script? <classname>Cloudflare::API</classname> gives you a small client for those everyday Cloudflare management tasks. It uses <classname>HTTP::API::Core</classname> for the HTTPS transport, authentication header, and HTTP response handling; the resource modules add Cloudflare paths and request shapes on top. Thanks to the authors of that module for providing the foundation.</para>
    <para>You can call the module from Perl or use the accompanying <command>cloudflare-api</command> command when a shell is more convenient. Both talk to Cloudflare's management API. Neither builds a Worker project or runs npm. The command can use a Wrangler login when you request it with <option>--auth=wrangler</option>. Prepare Worker code with your usual tools, then supply the resulting files if you want to upload them.</para>
    <para>If you only need a quick look, the next example shows the whole idea. The later sections explain credentials, resource methods, uploads, responses, and command-line options. A module-by-module reference is near the end.</para>
    <programlisting language="perl">use strict;
use warnings;
use Cloudflare::API;

my $api_or=Cloudflare::API-&gt;new();
my $buckets_hr=$api_or-&gt;r2()-&gt;list_buckets();
my $scripts_ar=$api_or-&gt;workers()-&gt;list_scripts();</programlisting>
    <para>With <envar>CLOUDFLARE_API_TOKEN</envar> and <envar>CLOUDFLARE_ACCOUNT_ID</envar> set, the client supplies the account path and bearer token. Each JSON method normally gives you Cloudflare's decoded <literal>result</literal>, so the shape of <varname>$buckets_hr</varname> or <varname>$scripts_ar</varname> is the shape returned by that Cloudflare endpoint.</para>
    <note><para>The distribution requires Perl 5.10 or later, <classname>HTTP::API::Core</classname> 1.01 or later, and Perl HTTPS support through <classname>IO::Socket::SSL</classname>. Install it with a CPAN client in the usual way.</para></note>
  </section>

  <section xml:id="credentials">
    <title>Credentials and account context</title>
    <para>Create an API token in the Cloudflare dashboard with only the permissions and account or zone access your script needs. Cloudflare offers user tokens and account tokens; the endpoint and your account permissions determine which is appropriate. Copy the token when it is created and keep it in your normal secret store. The <link xlink:href="https://developers.cloudflare.com/fundamentals/api/get-started/create-token/">Cloudflare token guide</link> explains the dashboard steps and permission scoping.</para>
    <programlisting language="sh"># Load CLOUDFLARE_API_TOKEN from your secret manager first.
export CLOUDFLARE_ACCOUNT_ID='your-account-id'
cloudflare-api --resource r2 --action list_buckets</programlisting>
    <para>The account ID above is a placeholder. Load the token through your secret manager so it does not enter a repository or a pasted command. The module also accepts explicit <literal>token</literal> and <literal>account_id</literal> constructor options:</para>
    <programlisting language="perl">my $api_or=Cloudflare::API-&gt;new(
    token      =&gt; $ENV{'CLOUDFLARE_API_TOKEN'},
    account_id =&gt; $ENV{'CLOUDFLARE_ACCOUNT_ID'},
    timeout    =&gt; 30
);</programlisting>
    <para>You can omit <literal>account_id</literal> for account and zone lookup, but account-scoped methods need it. The command has <option>--account-id</option> to choose another account while continuing to read the token from the environment.</para>
    <warning><para>An API token is a credential, even when it is short lived. Do not commit it, paste it into command-line arguments, print it in logs, or include it in error reports. Use a narrowly scoped token; a token that can edit a resource can change or delete it through the relevant methods.</para></warning>
    <para>If you have logged into Wrangler locally, the command can ask Wrangler for a token for each invocation. Wrangler refreshes an expired OAuth token before returning it:</para>
    <programlisting language="sh">cloudflare-api --auth=wrangler --resource workers --action list_scripts</programlisting>
    <para>For an account-scoped named method, <option>--auth=wrangler</option> also uses <command>wrangler whoami --json</command> to obtain the account ID when the login has exactly one available account. If it has several, select one with <option>--account-id</option> or <envar>CLOUDFLARE_ACCOUNT_ID</envar>; either value takes precedence and skips account discovery. Run <command>wrangler login</command> separately if you have not logged in. Wrangler returns an existing <envar>CLOUDFLARE_API_TOKEN</envar> in preference to its OAuth login; it does not mint a newly scoped API token. API key and email credentials are not supported by <option>--auth=wrangler</option>. When you rely on a Wrangler login, the returned OAuth token has the permissions of that login. The module does not refresh a token passed to its constructor; the command asks Wrangler again on each invocation. See the <link xlink:href="https://developers.cloudflare.com/workers/wrangler/commands/general/#auth-token">Wrangler auth token reference</link> for the current command behaviour.</para>
    <tip><para>For unattended scripts, use a dedicated, limited API token supplied by a secret manager. The Wrangler shortcut is best suited to an interactive session you control.</para></tip>
  </section>

  <section xml:id="perl-synopsis">
    <title>A first Perl script</title>
    <para>Let's list two resources without changing anything. Resource accessors are named for the Cloudflare service, and their methods follow the action you want to perform:</para>
    <programlisting language="perl">use strict;
use warnings;
use Cloudflare::API;

my $api_or=Cloudflare::API-&gt;new();
my $zones_ar=$api_or-&gt;zones()-&gt;list(status =&gt; 'active');
my $namespaces_ar=$api_or-&gt;kv()-&gt;list_namespaces(per_page =&gt; 20);

foreach my $zone_hr (@$zones_ar) {
    print $zone_hr-&gt;{'name'}, "\n";
}</programlisting>
    <para><methodname>zones()-&gt;list()</methodname> is not account-scoped; <methodname>kv()-&gt;list_namespaces()</methodname> is. List methods accept Cloudflare query fields as named arguments. The exact result shape differs by endpoint, so consult the Cloudflare endpoint documentation when reading fields.</para>
    <para>To create something, pass Cloudflare's JSON request body as a hash reference. Here are two independent examples:</para>
    <programlisting language="perl">my $bucket_hr=$api_or-&gt;r2()-&gt;create_bucket({
    name =&gt; 'my-app-assets'
});

my $database_hr=$api_or-&gt;d1()-&gt;create_database({
    name =&gt; 'my-app-data'
});</programlisting>
    <caution><para>These calls create real Cloudflare resources. The module does not check whether a name already exists, make writes idempotent, or roll back later calls if a script fails.</para></caution>
  </section>

  <section xml:id="resource-api">
    <title>Exploring the resource API</title>
    <para>The client has accessors for <methodname>accounts()</methodname>, <methodname>zones()</methodname>, <methodname>workers()</methodname>, <methodname>r2()</methodname>, <methodname>kv()</methodname>, <methodname>d1()</methodname>, <methodname>queues()</methodname>, <methodname>hyperdrive()</methodname>, and <methodname>secrets_store()</methodname>. Each returns a small resource object tied to the same client and token. The examples below show representative operations rather than every method.</para>

    <section xml:id="read-and-write">
      <title>Lists, detail, and changes</title>
      <para>Most resources offer a familiar list, get, create, update, and delete pattern. For example, you can list R2 buckets, get one by name, then update or delete it using that name. Queues, Hyperdrive configurations, and Secrets Store resources follow the same general pattern, with bodies specific to the Cloudflare endpoint.</para>
      <programlisting language="perl">my $bucket_hr=$api_or-&gt;r2()-&gt;get_bucket('my-app-assets');
my $queue_hr=$api_or-&gt;queues()-&gt;create_queue({ name =&gt; 'jobs' });
my $config_ar=$api_or-&gt;hyperdrive()-&gt;list_configs();</programlisting>
      <para>Accounts and zones are useful starting points when you do not yet know an ID:</para>
      <programlisting language="perl">my $accounts_ar=$api_or-&gt;accounts()-&gt;list();
my $zones_ar=$api_or-&gt;zones()-&gt;list(name =&gt; 'example.com');</programlisting>
      <para>The methods that take a body pass the supplied hash reference to Cloudflare. Refer to the relevant Cloudflare API endpoint for its required fields and accepted values. The module deliberately leaves most endpoint-specific payload choices with the caller.</para>
    </section>

    <section xml:id="kv-and-d1">
      <title>KV values and D1 queries</title>
      <para>KV has a small convenience API for keys and raw values. The value methods are separate from the JSON management methods because the value body need not be JSON:</para>
      <programlisting language="perl">my $kv_or=$api_or-&gt;kv();
$kv_or-&gt;put_value('namespace-id', 'greeting', 'Hello', expiration_ttl =&gt; 3600);
my $value=$kv_or-&gt;get_value('namespace-id', 'greeting');
my $keys_ar=$kv_or-&gt;list_keys('namespace-id', prefix =&gt; 'greet');</programlisting>
      <para><methodname>get_value()</methodname> returns raw bytes. <methodname>put_value()</methodname> accepts either <literal>expiration</literal> or <literal>expiration_ttl</literal>, and writing a value replaces its previous expiration and metadata. It does not support metadata-bearing writes through this convenience method.</para>
      <para>For D1, <methodname>query_sql()</methodname> keeps SQL parameters separate from the statement:</para>
      <programlisting language="perl">my $rows_ar=$api_or-&gt;d1()-&gt;query_sql(
    'database-id',
    'SELECT id, name FROM people WHERE id = ?',
    [42]
);</programlisting>
      <para>The D1 REST result remains Cloudflare's array of query results. This is a management API call, not a DBI connection or a migration tool. Hyperdrive methods likewise manage connection configuration; SQL for a Hyperdrive-backed application goes through a Worker binding and database driver.</para>
    </section>

    <section xml:id="worker-management">
      <title>Worker scripts, versions, and assets</title>
      <para>To upload a Worker, prepare its module files first. The metadata must identify a <literal>main_module</literal> whose name matches one of the supplied file entries. Each entry may contain a local <literal>path</literal> or in-memory <literal>content</literal>. This example uploads an already built module:</para>
      <programlisting language="perl">my $worker_hr=$api_or-&gt;workers()-&gt;upload_script('my-app',
    metadata =&gt; {
        main_module        =&gt; 'worker.mjs',
        compatibility_date =&gt; '2026-09-22',
        bindings           =&gt; [
            { type =&gt; 'r2_bucket', name =&gt; 'ASSETS',
              bucket_name =&gt; 'my-app-assets' }
        ]
    },
    files =&gt; [
        { name =&gt; 'worker.mjs', path =&gt; 'dist/worker.mjs' }
    ]
);</programlisting>
      <warning><para><methodname>upload_script()</methodname> deploys immediately. If you want to inspect a version before activating it, use <methodname>upload_version()</methodname> with the same <literal>metadata</literal> and <literal>files</literal> arguments, then call <methodname>create_deployment()</methodname> when you are ready.</para></warning>
      <para>Static assets can be uploaded from a directory, a list of files, or a URL-path map. The returned value includes a manifest and a short-lived completion JWT for the version metadata:</para>
      <programlisting language="perl">my $assets_hr=$api_or-&gt;workers()-&gt;upload_assets(
    'my-app', 'dist/site', prefix =&gt; '/docs'
);
my $version_hr=$api_or-&gt;workers()-&gt;upload_version('my-app',
    metadata =&gt; {
        main_module        =&gt; 'worker.mjs',
        compatibility_date =&gt; '2026-09-22',
        assets             =&gt; { jwt =&gt; $assets_hr-&gt;{'jwt'} },
        bindings           =&gt; [{ type =&gt; 'assets', name =&gt; 'ASSETS' }]
    },
    files =&gt; [{ name =&gt; 'worker.mjs', path =&gt; 'dist/worker.mjs' }]
);</programlisting>
      <para>Neither <methodname>upload_assets()</methodname> nor <methodname>upload_version()</methodname> activates a new deployment. Asset uploads are assembled in memory, so consider the size of the files in a large upload. Treat the completion JWT as a credential and keep it out of logs. Worker routes use a zone ID; script upload does not create a route automatically.</para>
      <para>Other Worker methods inspect scripts and versions, manage deployments and secret bindings, and control routes or the workers.dev subdomain. <methodname>download_script()</methodname> returns a raw response because its body is source content. Secret values are write-only; do not expect a later list or get call to return them.</para>
    </section>
  </section>

  <section xml:id="responses-and-errors">
    <title>Responses, pagination, and errors</title>
    <para>Cloudflare commonly wraps data in an envelope with <literal>success</literal>, <literal>result</literal>, and sometimes <literal>result_info</literal>. Named JSON methods return only <literal>result</literal> by default. Ask for the full envelope when you need page information or messages:</para>
    <programlisting language="perl">my $page_hr=$api_or-&gt;r2()-&gt;list_buckets(
    per_page =&gt; 20,
    full_response =&gt; 1
);
my $buckets_ar=$page_hr-&gt;{'result'}{'buckets'};</programlisting>
    <para>The envelope shape and pagination fields depend on the endpoint. The command-line client's <option>--paginate</option> option can follow numbered or cursor pages for list actions, while Perl callers can use the returned <literal>result_info</literal> to implement the traversal they need.</para>
    <para>An HTTP or transport failure is thrown as an <classname>HTTP::API::Core::Error</classname>. A successful HTTP response whose Cloudflare envelope says <literal>success: false</literal> is thrown as <classname>Cloudflare::API::Error</classname>; it retains <methodname>errors()</methodname>, <methodname>messages()</methodname>, and the underlying <methodname>response()</methodname>. For example:</para>
    <programlisting language="perl">my $result_hr=eval { $api_or-&gt;r2()-&gt;get_bucket('my-app-assets') };
if (my $error=$@) {
    if (ref($error) &amp;&amp; $error-&gt;isa('Cloudflare::API::Error')) {
        warn "Cloudflare rejected the request: $error\n";
    }
    else {
        die $error;
    }
}</programlisting>
    <note><para>A missing account ID or invalid local argument may also raise an ordinary Perl exception before any request is made. Keep tokens and secret request bodies out of exception logs.</para></note>
  </section>

  <section xml:id="lower-level-api">
    <title>When there is no named method</title>
    <para>The named methods cover a useful part of Cloudflare's API, not every endpoint. Use <methodname>request()</methodname> for a JSON endpoint that does not yet have a wrapper. It accepts an HTTP method, a path relative to the Cloudflare API root, and <classname>HTTP::API::Core</classname> request options:</para>
    <programlisting language="perl">my $accounts_ar=$api_or-&gt;request('GET', '/accounts',
    query =&gt; { per_page =&gt; 20 }
);
my $envelope_hr=$api_or-&gt;request_full('GET', '/accounts');
my $response_or=$api_or-&gt;raw_request('GET', '/accounts');</programlisting>
    <para><methodname>request()</methodname> unwraps <literal>result</literal>, <methodname>request_full()</methodname> keeps the decoded envelope, and <methodname>raw_request()</methodname> returns the <classname>HTTP::API::Core::Response</classname> object. Use the raw form for non-JSON content or when you need response details that the JSON helpers do not expose.</para>
    <important><para>Paths must start with one slash and cannot be absolute URLs, so a caller-supplied path cannot send the bearer token to another host. If you put a dynamic value in a low-level path, percent-encode that segment yourself. Named resource methods encode their own path segments.</para></important>
  </section>

  <section xml:id="command-line">
    <title>Using the command-line script</title>
    <para><command>cloudflare-api</command> is useful for a quick lookup or a shell script that already has credentials in its environment. Choose a resource and action for a named method. Output is pretty JSON unless you ask for <literal>dumper</literal>:</para>
    <programlisting language="sh">cloudflare-api --resource zones --action list --param status=active
cloudflare-api --resource r2 --action list_buckets --full-response
cloudflare-api --resource zones --action list --output dumper</programlisting>
    <para>A <option>--param</option> passes a named string argument, usually a list filter. <option>--arg</option> passes a positional string argument, in the order given. For a method expecting a JSON body, use a typed argument rather than a string:</para>
    <programlisting language="sh">cloudflare-api --resource kv --action create_namespace \
    --arg-json '{"title":"my-app-cache"}'
cloudflare-api --resource workers --action upload_assets \
    --arg my-app --arg dist/site --param prefix=/docs</programlisting>
    <para>For selected files, give the Worker name with <option>--arg</option>, then combine repeatable <option>--asset FILE</option>, <option>--asset-list-json FILE</option>, and <option>--asset-list-text FILE</option>. Use <option>--asset-list-stdin</option> once to read one filename per line from standard input. Text lists ignore blank lines and preserve spaces in filenames. JSON lists contain arrays of filenames or objects with <literal>path</literal>, optional URL <literal>name</literal>, and optional <literal>content_type</literal>. Bare filenames use their basenames as URL paths; use a directory source or explicit JSON names to preserve nested paths. Do not mix these list options with a second source argument.</para>
    <programlisting language="sh">cloudflare-api --resource workers --action upload_assets \
    --arg my-app --asset dist/index.html --asset-list-text images.txt \
    --param prefix=/docs</programlisting>
    <para>Typed positional forms include <option>--arg-bool</option>, <option>--arg-array</option>, <option>--arg-hash</option>, <option>--arg-json</option>, and <option>--arg-json-file</option>. Named forms include <option>--param-bool</option>, <option>--param-json</option>, and <option>--param-json-file</option>. The JSON file forms are handy for longer bodies; for example, a Worker upload can take its name through <option>--arg</option> and prepared <literal>metadata</literal> and <literal>files</literal> through <option>--param-json-file</option>.</para>
    <para>To read multiple pages, use <option>--paginate</option> on a list action. The command returns an array of page results, preserving each page boundary. Without <option>--max-pages</option>, it follows every page Cloudflare reports:</para>
    <programlisting language="sh">cloudflare-api --resource kv --action list_namespaces \
    --paginate --per-page 20 --max-pages 2 --full-response</programlisting>
    <para>The lower-level form uses <option>--method</option> and <option>--path</option> instead of a resource and action:</para>
    <programlisting language="sh">cloudflare-api --method GET --path /accounts --full-response</programlisting>
    <para>Use <option>--help</option> for a short reminder, <option>--man</option> for the complete option reference, or <option>--version</option> for the installed version. The command also has <option>--account-id</option> and <option>--output json|dumper</option>.</para>
    <warning><para>The <option>--arg-dumper-file</option> and <option>--param-dumper-file</option> options evaluate the file as Perl code. Use them only with files you trust; prefer JSON for data from elsewhere. <option>--dump-opt</option> prints parsed arguments and can expose values. For secret-bearing bodies, avoid shell arguments and output logs; the script's manual describes how to read a JSON body from standard input.</para></warning>
  </section>

  <section xml:id="module-reference">
    <title>Module reference</title>
    <para>The examples above are a starting point. For the actual method names, arguments, and return conventions, follow the Markdown reference kept beside each module's Perl source. Those sidecars are the maintained method reference; this article stays focused on how the pieces fit together. Cloudflare defines the fields inside many request bodies and returned <literal>result</literal> values, so consult the relevant Cloudflare endpoint when you need its complete schema.</para>
    <variablelist>
      <varlistentry>
        <term><link xlink:href="lib/Cloudflare/API.pm.md"><classname>Cloudflare::API</classname></link></term>
        <listitem><para>Constructor options, environment defaults, resource accessors, low-level requests, and response handling.</para></listitem>
      </varlistentry>
      <varlistentry>
        <term><link xlink:href="lib/Cloudflare/API/Accounts.pm.md"><classname>Cloudflare::API::Accounts</classname></link></term>
        <listitem><para>List accounts or retrieve one by ID.</para></listitem>
      </varlistentry>
      <varlistentry>
        <term><link xlink:href="lib/Cloudflare/API/Zones.pm.md"><classname>Cloudflare::API::Zones</classname></link></term>
        <listitem><para>List zones or retrieve one by ID.</para></listitem>
      </varlistentry>
      <varlistentry>
        <term><link xlink:href="lib/Cloudflare/API/Workers.pm.md"><classname>Cloudflare::API::Workers</classname></link></term>
        <listitem><para>Scripts, staged versions, deployments, assets, secrets, subdomains, and routes. Start here for upload arguments and return values.</para></listitem>
      </varlistentry>
      <varlistentry>
        <term><link xlink:href="lib/Cloudflare/API/R2.pm.md"><classname>Cloudflare::API::R2</classname></link></term>
        <listitem><para>Bucket management through the REST API.</para></listitem>
      </varlistentry>
      <varlistentry>
        <term><link xlink:href="lib/Cloudflare/API/KV.pm.md"><classname>Cloudflare::API::KV</classname></link></term>
        <listitem><para>Namespace management, key lists, and raw value reads and writes.</para></listitem>
      </varlistentry>
      <varlistentry>
        <term><link xlink:href="lib/Cloudflare/API/D1.pm.md"><classname>Cloudflare::API::D1</classname></link></term>
        <listitem><para>Database management and REST queries with separate SQL parameters.</para></listitem>
      </varlistentry>
      <varlistentry>
        <term><link xlink:href="lib/Cloudflare/API/Queues.pm.md"><classname>Cloudflare::API::Queues</classname></link></term>
        <listitem><para>Queue and consumer management.</para></listitem>
      </varlistentry>
      <varlistentry>
        <term><link xlink:href="lib/Cloudflare/API/Hyperdrive.pm.md"><classname>Cloudflare::API::Hyperdrive</classname></link></term>
        <listitem><para>Connection configuration management, including the distinction between configuration and SQL access.</para></listitem>
      </varlistentry>
      <varlistentry>
        <term><link xlink:href="lib/Cloudflare/API/SecretsStore.pm.md"><classname>Cloudflare::API::SecretsStore</classname></link></term>
        <listitem><para>Stores, secret metadata, write-only values, and quota information.</para></listitem>
      </varlistentry>
      <varlistentry>
        <term><link xlink:href="lib/Cloudflare/API/Error.pm.md"><classname>Cloudflare::API::Error</classname></link></term>
        <listitem><para>The exception raised for a Cloudflare envelope that reports failure despite an HTTP success response.</para></listitem>
      </varlistentry>
    </variablelist>
    <para>For the script's full option reference, run <command>cloudflare-api --man</command>. The <command>--help</command> form is shorter when you only need to recall an option name.</para>
  </section>

  <section xml:id="utility-reference">
    <title>Utility reference</title>
    <para>The <link xlink:href="bin/cloudflare-api.md"><command>cloudflare-api</command> utility reference</link> documents the command's options, argument formats, pagination, output, and authentication in one place.</para>
  </section>

  <section xml:id="licensing-and-credits">
    <title>Licensing and credits</title>
    <para><classname>Cloudflare::API</classname> is copyright © 2026 Andrew Speer. It is free software, available under the same terms as the Perl 5 programming language system itself.</para>
    <para>This client depends on <classname>HTTP::API::Core</classname> for its HTTP work and on other Perl modules for JSON, HTTPS, and Worker upload support. Thanks to their authors and maintainers. Dependencies retain their own licenses; consult their distributions for the applicable terms. Cloudflare and Wrangler are services and tools supplied by Cloudflare, Inc.</para>
  </section>
</article>